Skip to content

Commit 7d2f360

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
feat(redis-worker,run-engine): report the age of the oldest waiting batch item
Adds a metric for how long the oldest batch item has been waiting to be turned into a run, and makes the batch queue wait histograms cover waits longer than 10 seconds. FairQueue now emits `<name>.oldest_message_age`, a per-shard gauge in ms of the oldest message that is due but not yet dispatched (0 when nothing is due). It reads the head of each dispatch shard, and of the legacy master queue shard while it drains, so collection costs two `ZRANGEBYSCORE ... LIMIT 0 1` calls per shard. For the batch queue this is `batch-queue.oldest_message_age`. Unlike throughput or queue length, it shows a backlog the same way at any traffic level, so it is the signal to alert on. `batch_queue.item_queue_time` and `batch_queue.batch_processing_duration` used the default histogram buckets, which end at 10s, so every longer wait landed in the overflow bucket. They now have buckets up to 30 minutes. Mono-RevId: 31e0228bfd25ce7fb3bc26ded7c0f95e3159f554
1 parent 7342f2b commit 7d2f360

4 files changed

Lines changed: 106 additions & 0 deletions

File tree

‎internal-packages/run-engine/src/batch-queue/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export type { BatchQueueOptions, CompleteBatchResult, InitializeBatchOptions } f
5454
// Redis key for environment concurrency limits
5555
const ENV_CONCURRENCY_KEY_PREFIX = "batch:env_concurrency";
5656

57+
// The default histogram buckets stop at 10s, which hides multi-minute backlogs
58+
const QUEUE_WAIT_BUCKETS_MS = [
59+
10, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000, 60_000, 120_000, 300_000, 600_000,
60+
1_800_000,
61+
];
62+
5763
// Single worker queue ID for all batch items
5864
// BatchQueue uses a single shared worker queue - FairQueue handles fair scheduling,
5965
// then all messages are routed to this queue for BatchQueue's own consumer loop.
@@ -601,6 +607,7 @@ export class BatchQueue {
601607
{
602608
description: "Duration from batch creation to completion",
603609
unit: "ms",
610+
advice: { explicitBucketBoundaries: QUEUE_WAIT_BUCKETS_MS },
604611
}
605612
);
606613

@@ -612,6 +619,7 @@ export class BatchQueue {
612619
this.itemQueueTimeHistogram = meter.createHistogram("batch_queue.item_queue_time", {
613620
description: "Time from item enqueue to processing start",
614621
unit: "ms",
622+
advice: { explicitBucketBoundaries: QUEUE_WAIT_BUCKETS_MS },
615623
});
616624

617625
this.rateLimitDeniedCounter = meter.createCounter("batch_queue.rate_limit_denied", {

‎packages/redis-worker/src/fair-queue/index.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ export class FairQueue<TPayloadSchema extends AnyZodSchema = z.ZodUnknown> {
263263
getDLQLength: async (tenantId: string) => {
264264
return await this.getDeadLetterQueueLength(tenantId);
265265
},
266+
getOldestMessageAge: async (shardId: number) => {
267+
return await this.getOldestMessageAge(shardId);
268+
},
266269
shardCount: this.shardCount,
267270
observedTenants: options?.observedTenants,
268271
});
@@ -752,6 +755,24 @@ export class FairQueue<TPayloadSchema extends AnyZodSchema = z.ZodUnknown> {
752755
return dispatchCount + legacyCount;
753756
}
754757

758+
/**
759+
* Get the age in ms of the oldest due message not yet completed in a shard, or 0 if none.
760+
* Claims don't refresh the index scores, so claimed messages count until their queue next completes or empties.
761+
*/
762+
async getOldestMessageAge(shardId: number): Promise<number> {
763+
const now = Date.now();
764+
const [[oldestTenant], [oldestLegacyQueue]] = await Promise.all([
765+
this.tenantDispatch.getTenantsFromShard(shardId, 1, now),
766+
this.masterQueue.getQueuesFromShard(shardId, 1, now),
767+
]);
768+
const oldestScore = Math.min(
769+
oldestTenant?.score ?? Infinity,
770+
oldestLegacyQueue?.score ?? Infinity
771+
);
772+
if (!Number.isFinite(oldestScore)) return 0;
773+
return Math.max(0, now - oldestScore);
774+
}
775+
755776
/**
756777
* Get total in-flight message count.
757778
*/

‎packages/redis-worker/src/fair-queue/telemetry.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface FairQueueMetrics {
5959
dispatchLength: ObservableGauge;
6060
inflightCount: ObservableGauge;
6161
dlqLength: ObservableGauge;
62+
oldestMessageAge: ObservableGauge;
6263
}
6364

6465
/**
@@ -254,6 +255,7 @@ export class FairQueueTelemetry {
254255
getDispatchLength?: (shardId: number) => Promise<number>;
255256
getInflightCount?: (shardId: number) => Promise<number>;
256257
getDLQLength?: (tenantId: string) => Promise<number>;
258+
getOldestMessageAge?: (shardId: number) => Promise<number>;
257259
shardCount?: number;
258260
observedQueues?: string[];
259261
observedTenants?: string[];
@@ -320,6 +322,20 @@ export class FairQueueTelemetry {
320322
});
321323
}
322324

325+
if (callbacks.getOldestMessageAge && callbacks.shardCount) {
326+
const getOldestMessageAge = callbacks.getOldestMessageAge;
327+
const shardCount = callbacks.shardCount;
328+
329+
this.metrics.oldestMessageAge.addCallback(async (observableResult) => {
330+
for (let shardId = 0; shardId < shardCount; shardId++) {
331+
const age = await getOldestMessageAge(shardId);
332+
observableResult.observe(age, {
333+
[FairQueueAttributes.SHARD_ID]: shardId.toString(),
334+
});
335+
}
336+
});
337+
}
338+
323339
// DLQ length gauge
324340
if (callbacks.getDLQLength && callbacks.observedTenants) {
325341
const getDLQLength = callbacks.getDLQLength;
@@ -446,6 +462,11 @@ export class FairQueueTelemetry {
446462
description: "Number of messages in dead letter queue",
447463
unit: "messages",
448464
}),
465+
oldestMessageAge: this.meter.createObservableGauge(`${this.name}.oldest_message_age`, {
466+
description:
467+
"Age of the oldest due message not yet completed in a shard, including claimed messages until their queue next completes or empties",
468+
unit: "ms",
469+
}),
449470
};
450471
}
451472
}

‎packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,62 @@ describe("FairQueue", () => {
11131113

11141114
await queue.close();
11151115
});
1116+
1117+
redisTest(
1118+
"should report the age of the oldest due message",
1119+
{ timeout: 10000 },
1120+
async ({ redisOptions }) => {
1121+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
1122+
1123+
const scheduler = new DRRScheduler({
1124+
redis: redisOptions,
1125+
keys,
1126+
quantum: 10,
1127+
maxDeficit: 100,
1128+
});
1129+
1130+
const queue = new FairQueue({
1131+
redis: redisOptions,
1132+
keys,
1133+
scheduler,
1134+
shardCount: 1,
1135+
startConsumers: false,
1136+
workerQueue: {
1137+
resolveWorkerQueue: () => TEST_WORKER_QUEUE_ID,
1138+
},
1139+
});
1140+
1141+
expect(await queue.getOldestMessageAge(0)).toBe(0);
1142+
1143+
await queue.enqueue({
1144+
queueId: "tenant:t1:queue:q1",
1145+
tenantId: "t1",
1146+
payload: { value: "scheduled" },
1147+
timestamp: Date.now() + 60_000,
1148+
});
1149+
1150+
expect(await queue.getOldestMessageAge(0)).toBe(0);
1151+
1152+
await queue.enqueue({
1153+
queueId: "tenant:t2:queue:q1",
1154+
tenantId: "t2",
1155+
payload: { value: "recent" },
1156+
timestamp: Date.now() - 5_000,
1157+
});
1158+
await queue.enqueue({
1159+
queueId: "tenant:t3:queue:q1",
1160+
tenantId: "t3",
1161+
payload: { value: "old" },
1162+
timestamp: Date.now() - 120_000,
1163+
});
1164+
1165+
const age = await queue.getOldestMessageAge(0);
1166+
expect(age).toBeGreaterThanOrEqual(120_000);
1167+
expect(age).toBeLessThan(125_000);
1168+
1169+
await queue.close();
1170+
}
1171+
);
11161172
});
11171173

11181174
describe("two-stage processing with concurrency limits", () => {

0 commit comments

Comments
 (0)