Skip to content

Commit 05505d5

Browse files
committed
fix(webapp): carry a queue's depth forward across empty buckets
The per-queue metrics route mapped ClickHouse rows straight to an array, so a bucket with no sample shortened the trend and shifted every later point in time. Fill a fixed-width grid the way the two sibling callers do.
1 parent 2d23dde commit 05505d5

3 files changed

Lines changed: 120 additions & 7 deletions

File tree

apps/webapp/app/routes/api.v1.queues.$queueParam.metrics.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod";
33
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
44
import { logger } from "~/services/logger.server";
55
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
6+
import { queueDepthSeries } from "~/v3/queueDepthSeries";
67

78
/**
89
* Per-queue metrics over a window. `queueParam` is the queue name; `?type=task` (the default)
@@ -62,8 +63,12 @@ export const loader = createLoaderApiRoute(
6263
const windowMinutes = windowMs / 60_000;
6364
const bucketSeconds = Math.max(60, Math.round(windowMs / 1000 / TREND_POINTS));
6465
// Snap both bounds to the bucket grid so repeated calls share ClickHouse cache entries.
65-
const endMs = Math.ceil(Date.now() / (bucketSeconds * 1000)) * bucketSeconds * 1000;
66+
const bucketIntervalMs = bucketSeconds * 1000;
67+
const endMs = Math.ceil(Date.now() / bucketIntervalMs) * bucketIntervalMs;
6668
const startMs = endMs - windowMs;
69+
// The trend grid covers whole buckets, so a period that isn't a bucket multiple still lines up.
70+
const gridStartMs = Math.floor(startMs / bucketIntervalMs) * bucketIntervalMs;
71+
const numBuckets = Math.round((endMs - gridStartMs) / bucketIntervalMs);
6772

6873
try {
6974
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
@@ -115,12 +120,13 @@ export const loader = createLoaderApiRoute(
115120
startedCount,
116121
startedPerMin: Number((startedCount / windowMinutes).toFixed(2)),
117122
throttledCount: summary?.throttled_count ?? 0,
118-
bucketIntervalMs: bucketSeconds * 1000,
119-
// Oldest first; buckets with no sample are omitted, so gaps carry the previous depth.
120-
depthTrend: (trendRows ?? [])
121-
.slice()
122-
.sort((a, b) => a.bucket.localeCompare(b.bucket))
123-
.map((row) => row.depth),
123+
bucketIntervalMs,
124+
// Oldest first, one point per bucket: a bucket with no sample carries the previous depth.
125+
depthTrend: queueDepthSeries(trendRows ?? [], {
126+
startMs: gridStartMs,
127+
bucketIntervalMs,
128+
numBuckets,
129+
}).depth,
124130
});
125131
} catch (error) {
126132
// Rethrow Responses: swallowing one would turn it into a 500.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* `getQueueDepthSparklines` emits a row only for buckets that reported, so a caller has to place
3+
* every row on the bucket grid itself. Depth is carry-forward filled: no emission means unchanged,
4+
* not zero. Throttled is not filled — only real per-bucket counts tint a bar.
5+
*/
6+
7+
export type QueueDepthBucketRow = { bucket: string; depth: number; throttled: number };
8+
9+
export type QueueDepthGrid = { startMs: number; bucketIntervalMs: number; numBuckets: number };
10+
11+
/** Rows placed on the grid by bucket index. Rows outside the window are dropped. */
12+
export function indexQueueDepthRows(
13+
rows: QueueDepthBucketRow[],
14+
grid: QueueDepthGrid
15+
): Map<number, { depth: number; throttled: number }> {
16+
const byIndex = new Map<number, { depth: number; throttled: number }>();
17+
for (const row of rows) {
18+
const bucketMs = Date.parse(row.bucket.replace(" ", "T") + "Z");
19+
if (Number.isNaN(bucketMs)) continue;
20+
const index = Math.round((bucketMs - grid.startMs) / grid.bucketIntervalMs);
21+
if (index < 0 || index >= grid.numBuckets) continue;
22+
byIndex.set(index, { depth: row.depth, throttled: row.throttled });
23+
}
24+
return byIndex;
25+
}
26+
27+
/** A fixed-width series per grid bucket, so a gap can never shift later points in time. */
28+
export function fillQueueDepthSeries(
29+
byIndex: Map<number, { depth: number; throttled: number }>,
30+
numBuckets: number
31+
): { depth: number[]; throttled: number[] } {
32+
const depth: number[] = new Array(numBuckets);
33+
const throttled: number[] = new Array(numBuckets);
34+
let last = 0;
35+
for (let i = 0; i < numBuckets; i++) {
36+
const bucket = byIndex.get(i);
37+
if (bucket !== undefined) last = bucket.depth;
38+
depth[i] = last;
39+
throttled[i] = bucket?.throttled ?? 0;
40+
}
41+
return { depth, throttled };
42+
}
43+
44+
export function queueDepthSeries(
45+
rows: QueueDepthBucketRow[],
46+
grid: QueueDepthGrid
47+
): { depth: number[]; throttled: number[] } {
48+
return fillQueueDepthSeries(indexQueueDepthRows(rows, grid), grid.numBuckets);
49+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest";
2+
import { queueDepthSeries } from "~/v3/queueDepthSeries";
3+
4+
const BUCKET_MS = 300_000;
5+
const START_MS = Date.parse("2026-01-01T00:00:00Z");
6+
7+
function bucketAt(index: number): string {
8+
return new Date(START_MS + index * BUCKET_MS).toISOString().slice(0, 19).replace("T", " ");
9+
}
10+
11+
function row(index: number, depth: number, throttled = 0) {
12+
return { bucket: bucketAt(index), depth, throttled };
13+
}
14+
15+
const grid = { startMs: START_MS, bucketIntervalMs: BUCKET_MS, numBuckets: 6 };
16+
17+
describe("queueDepthSeries", () => {
18+
it("keeps later points in place when a bucket in the middle has no sample", () => {
19+
// Buckets 2 and 3 never reported; 4 and 5 must stay at index 4 and 5.
20+
const series = queueDepthSeries([row(0, 10), row(1, 20), row(4, 90), row(5, 95)], grid);
21+
22+
expect(series.depth).toEqual([10, 20, 20, 20, 90, 95]);
23+
});
24+
25+
it("emits one point per bucket regardless of how many rows came back", () => {
26+
expect(queueDepthSeries([row(3, 7)], grid).depth).toHaveLength(6);
27+
expect(queueDepthSeries([], grid).depth).toHaveLength(6);
28+
});
29+
30+
it("carries the previous depth across a gap rather than dropping to zero", () => {
31+
expect(queueDepthSeries([row(0, 42)], grid).depth).toEqual([42, 42, 42, 42, 42, 42]);
32+
});
33+
34+
it("starts at zero until the first sample", () => {
35+
expect(queueDepthSeries([row(2, 5)], grid).depth).toEqual([0, 0, 5, 5, 5, 5]);
36+
});
37+
38+
it("orders points oldest first whatever order the rows arrive in", () => {
39+
expect(queueDepthSeries([row(5, 95), row(0, 10), row(2, 30)], grid).depth).toEqual([
40+
10, 10, 30, 30, 30, 95,
41+
]);
42+
});
43+
44+
it("does not carry throttled counts across a gap", () => {
45+
expect(queueDepthSeries([row(0, 10, 4), row(3, 20, 1)], grid).throttled).toEqual([
46+
4, 0, 0, 1, 0, 0,
47+
]);
48+
});
49+
50+
it("drops rows outside the grid and unparseable buckets", () => {
51+
const series = queueDepthSeries(
52+
[row(-1, 999), row(6, 999), { bucket: "not-a-date", depth: 999, throttled: 0 }, row(1, 8)],
53+
grid
54+
);
55+
56+
expect(series.depth).toEqual([0, 8, 8, 8, 8, 8]);
57+
});
58+
});

0 commit comments

Comments
 (0)