Skip to content

Commit b7f943d

Browse files
committed
fix(webapp,metrics-pipeline): review hardening for overflow counters, cluster slots, and stream caps
Counter readings for names past the per-env cardinality cap are dropped instead of merging unrelated odometers under the overflow label (gauges still flow). The odometer key now shares the stream's shard hash tag so the INCR plus XADD script stays in one Cluster slot. The counter stream cap defaults lower when the stream shares the run-queue Redis. The per-bucket counter boundary undercount is documented on the delta columns.
1 parent f339c97 commit b7f943d

6 files changed

Lines changed: 37 additions & 10 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,7 +896,9 @@ const EnvironmentSchema = z
896896
QUEUE_METRICS_REDIS_USERNAME: z.string().optional(),
897897
QUEUE_METRICS_REDIS_PASSWORD: z.string().optional(),
898898
QUEUE_METRICS_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
899-
QUEUE_METRICS_COUNTER_STREAM_MAXLEN: z.coerce.number().int().default(8_000_000),
899+
// Default depends on where the stream lives: see metricsDefinition() in
900+
// queueMetrics.server.ts (2M on the shared run-queue Redis, 8M on a dedicated one).
901+
QUEUE_METRICS_COUNTER_STREAM_MAXLEN: z.coerce.number().int().optional(),
900902
// TTL (seconds) on the per-(queue,op) cumulative odometer key, refreshed on every write.
901903
// Idle-past-TTL queues purge and self-heal (restart from 1) on return; default 7 days.
902904
QUEUE_METRICS_COUNTER_ODOMETER_TTL_SECONDS: z.coerce.number().int().default(604_800),

apps/webapp/app/v3/querySchemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ export const queueMetricsSchema: TableSchema = {
671671
mergeGroupKey: "queue",
672672
...column("String", {
673673
description:
674-
"Runs enqueued (cumulative-counter delta). Read with deltaSumTimestampMerge(enqueue_delta) grouped by queue. For totals across queues, sum the per-queue results in an outer query, never merge across queues.",
674+
"Runs enqueued (cumulative-counter delta). Read with deltaSumTimestampMerge(enqueue_delta) grouped by queue. For totals across queues, sum the per-queue results in an outer query, never merge across queues. Per-bucket values can undercount by one inter-reading delta at bucket boundaries (the bridge lives in the prior bucket's state); totals over the whole range are exact.",
675675
}),
676676
groupable: false,
677677
sortable: false,
@@ -682,7 +682,7 @@ export const queueMetricsSchema: TableSchema = {
682682
mergeGroupKey: "queue",
683683
...column("String", {
684684
description:
685-
"Runs dequeued/started (throughput). Read with deltaSumTimestampMerge(started_delta) grouped by queue. For totals across queues, sum the per-queue results in an outer query, never merge across queues.",
685+
"Runs dequeued/started (throughput). Read with deltaSumTimestampMerge(started_delta) grouped by queue. For totals across queues, sum the per-queue results in an outer query, never merge across queues. Per-bucket values can undercount by one inter-reading delta at bucket boundaries (the bridge lives in the prior bucket's state); totals over the whole range are exact.",
686686
coreColumn: true,
687687
}),
688688
groupable: false,

apps/webapp/app/v3/queueMetrics.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@ function metricsRedisOptions(): RedisOptions {
5656
// One stream family on the metrics Redis carrying both gauge snapshots and cumulative
5757
// counter readings; one consumer group reads it.
5858
function metricsDefinition(): MetricDefinition {
59+
// A stalled consumer holds up to maxLen entries per shard in Redis memory: cap lower
60+
// by default when the stream shares the queue-critical run-queue Redis.
61+
const defaultMaxLen = env.QUEUE_METRICS_REDIS_HOST ? 8_000_000 : 2_000_000;
5962
return {
6063
name: "queue_metrics",
6164
shardCount: env.QUEUE_METRICS_STREAM_SHARD_COUNT,
6265
consumerGroup: "queue_metrics_cg",
63-
maxLen: env.QUEUE_METRICS_COUNTER_STREAM_MAXLEN,
66+
maxLen: env.QUEUE_METRICS_COUNTER_STREAM_MAXLEN ?? defaultMaxLen,
6467
};
6568
}
6669

apps/webapp/app/v3/queueMetricsMapping.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export function mapEntryToRow(
7474
if (!descriptor || !descriptor.queue_name) return null;
7575
if (limiter) {
7676
descriptor.queue_name = limiter.limit(descriptor.environment_id, descriptor.queue_name);
77+
// Overflowed names share one row, but counter readings are per-queue odometers and
78+
// merging different odometers under one key produces garbage deltas: drop counters
79+
// for overflow, keep gauges (max across the overflow set is still meaningful).
80+
if (descriptor.queue_name === OVERFLOW_QUEUE_NAME && op !== "gauge") return null;
7781
}
7882

7983
const eventMs = entryTimeMs(entry.id) ?? Date.now();

apps/webapp/test/queueMetricsMapping.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,27 @@ describe("mapEntryToRow", () => {
122122
expect(mapEntryToRow({ id: "1-0", fields: { op: "ack" } })).toBeNull();
123123
});
124124

125-
it("applies the queue-name limiter", () => {
125+
it("applies the queue-name limiter: gauges overflow, counters drop", () => {
126126
const limiter = new QueueNameLimiter(1);
127127
const first = mapEntryToRow({ id: "1-0", fields: { op: "ack", q } }, limiter);
128128
expect(first!.queue_name).toBe("task/t");
129-
const second = mapEntryToRow(
130-
{ id: "1-1", fields: { op: "ack", q: "{org:o1}:proj:p1:env:e1:queue:task/other" } },
129+
130+
// Overflowed gauges keep flowing under the shared name (max stays meaningful).
131+
const overflowGauge = mapEntryToRow(
132+
{
133+
id: "1-1",
134+
fields: { op: "gauge", q: "{org:o1}:proj:p1:env:e1:queue:task/other", ql: "3" },
135+
},
136+
limiter
137+
);
138+
expect(overflowGauge!.queue_name).toBe(OVERFLOW_QUEUE_NAME);
139+
140+
// Overflowed counters are dropped: merging distinct odometers under one key
141+
// produces garbage deltas.
142+
const overflowCounter = mapEntryToRow(
143+
{ id: "1-2", fields: { op: "ack", q: "{org:o1}:proj:p1:env:e1:queue:task/other", cum: "4" } },
131144
limiter
132145
);
133-
expect(second!.queue_name).toBe(OVERFLOW_QUEUE_NAME);
146+
expect(overflowCounter).toBeNull();
134147
});
135148
});

internal-packages/metrics-pipeline/src/emitter.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,13 @@ export class MetricsStreamEmitter {
140140
if (this.redis.status !== "ready") return;
141141
const op = String(fields.op ?? "unknown");
142142
const q = String(fields.q ?? "");
143-
const odometerKey = `${this.def.name}_cum:${op}:${q}`;
144-
const stream = streamKey(this.def, shardFor(shardKey, this.def.shardCount));
143+
const shard = shardFor(shardKey, this.def.shardCount);
144+
const stream = streamKey(this.def, shard);
145+
// The odometer carries the stream's {shard} hash tag so INCR + XADD stay in one
146+
// Cluster slot (the shard is derived from the queue, so the mapping is stable).
147+
// The key format is part of the rolling-deploy data shape: concurrent old/new
148+
// emitters with different formats split an odometer and corrupt its deltas.
149+
const odometerKey = `${this.def.name}_cum:{${shard}}:${op}:${q}`;
145150
const extra: string[] = [];
146151
for (const [field, value] of Object.entries(fields)) {
147152
if (field === "op" || field === "q") continue;

0 commit comments

Comments
 (0)