Skip to content

Commit 477004f

Browse files
committed
fix(webapp): apply the sparse-series bucket floor to the queue detail charts
The queue detail page has the same event-driven series as the Queues list hero row, and the same problem: scheduling delay and throttling only have samples when something started or was held back, so at the 10-second width a short range picks, most buckets held nothing and the lines read as a run of zeros. Scheduling delay (p50/p95/p99), Throttled, and the per-key mean delay on the Concurrency keys tab now take the same 60-second floor, and the two delay charts break where a bucket genuinely has no samples instead of drawing a zero. The gauge charts on this page (concurrency, queue depth, keys with backlog, worst key wait) carry forward and read correctly at any width, so they are left alone. None of this page's charts carry a headline readout, so there is no share-of- buckets figure here to skew the way the list page's throttled readout did. The floor and the no-samples break are plumbed through the shared queue-metric card, so the task detail page and run inspector can opt in later without further changes.
1 parent 97875c8 commit 477004f

2 files changed

Lines changed: 35 additions & 6 deletions

File tree

  • apps/webapp/app

apps/webapp/app/components/queues/QueueMetricCards.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export function useQueueMetric(
5454
defaultPeriod?: string;
5555
/** Poll ClickHouse on this cadence (ms). Omit to use the query's default interval. */
5656
refreshIntervalMs?: number;
57+
/** Floor for the bucket width, for series too sparse to read at the range's natural width. */
58+
minBucketSeconds?: number;
5759
}
5860
) {
5961
return useMetricResourceQuery(query, {
@@ -62,6 +64,7 @@ export function useQueueMetric(
6264
defaultPeriod: opts.defaultPeriod ?? QUEUE_METRICS_DEFAULT_PERIOD,
6365
queues: [opts.queueName],
6466
fillGaps: opts.fillGaps,
67+
minBucketSeconds: opts.minBucketSeconds,
6568
refreshIntervalMs: opts.refreshIntervalMs,
6669
});
6770
}
@@ -120,6 +123,14 @@ type QueueMetricChartProps = {
120123
/** Reports whether the chart has data to plot (false once it settles on the "no activity" state),
121124
* so a wrapping card can hide the legend to match. */
122125
onHasDataChange?: (hasData: boolean) => void;
126+
/** Floor for the bucket width, for series too sparse to read at the range's natural width. */
127+
minBucketSeconds?: number;
128+
/**
129+
* Column whose value counts the samples behind the plotted series. Where it is zero the metric
130+
* has nothing to report, so every series breaks there instead of reading as a real zero. Keep it
131+
* out of `series` — it is read for this test only, never drawn.
132+
*/
133+
sampleCountColumn?: string;
123134
};
124135

125136
// Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel.
@@ -136,22 +147,26 @@ export function QueueMetricChart({
136147
carryBackfill,
137148
thresholdStroke,
138149
onHasDataChange,
150+
minBucketSeconds,
151+
sampleCountColumn,
139152
}: QueueMetricChartProps) {
140153
const { rows, showLoading, failed } = useQueueMetric(query, {
141154
ids,
142155
timeRange,
143156
queueName,
144157
fillGaps,
145158
defaultPeriod,
159+
minBucketSeconds,
146160
});
147161

148162
const data = useMemo(() => {
149163
const points = rows
150164
.map((r) => {
151-
const point: { bucket: number } & Record<string, number> = {
165+
const point: { bucket: number } & Record<string, number | null> = {
152166
bucket: clickhouseTimeToMs(r.t),
153167
};
154-
for (const s of series) point[s.key] = toNumber(r[s.key]);
168+
const hasSamples = sampleCountColumn ? toNumber(r[sampleCountColumn]) > 0 : true;
169+
for (const s of series) point[s.key] = hasSamples ? toNumber(r[s.key]) : null;
155170
return point;
156171
})
157172
.filter((p) => Number.isFinite(p.bucket));
@@ -160,15 +175,15 @@ export function QueueMetricChart({
160175
// value and carry it back over the earlier buckets so the line doesn't start at a false 0.
161176
if (carryBackfill?.length) {
162177
for (const key of carryBackfill) {
163-
const first = points.findIndex((p) => p[key] > 0);
178+
const first = points.findIndex((p) => toNumber(p[key]) > 0);
164179
if (first > 0) {
165180
const value = points[first]![key]!;
166181
for (let i = 0; i < first; i++) points[i]![key] = value;
167182
}
168183
}
169184
}
170185
return points;
171-
}, [rows, series, carryBackfill]);
186+
}, [rows, series, carryBackfill, sampleCountColumn]);
172187

173188
const chartConfig = useMemo(() => {
174189
const cfg: ChartConfig = {};

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
191191

192192
const CK_LIVE_LIMIT = 50;
193193

194+
/**
195+
* Bucket floor for this page's event-driven charts (scheduling delay, throttling). Their samples
196+
* only exist when something started or was held back, so at the 10-second width a short range
197+
* picks, most buckets hold nothing and the line reads as a run of zeros. Gauge charts on this page
198+
* (concurrency, queue depth, backlogged keys) carry forward and are left at the natural width.
199+
*/
200+
const SPARSE_CHART_MIN_BUCKET_SECONDS = 60;
201+
194202
// Whole-queue oldest wait right now: for keyed queues the per-key breakdown carries the oldest
195203
// enqueue time per key, so the queue's oldest is the max wait across keys; otherwise fall back to
196204
// the queue's oldest message directly. Returns null when nothing is waiting.
@@ -470,8 +478,10 @@ function OverviewCharts({
470478
info="How long runs wait before they start."
471479
showLegend
472480
className="aspect-[2/1]"
473-
query={`SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[1]) AS p50,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[4]) AS p99\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
481+
query={`SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[1]) AS p50,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[4]) AS p99,\n sum(wait_ms_count) AS samples\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
474482
fillGaps
483+
minBucketSeconds={SPARSE_CHART_MIN_BUCKET_SECONDS}
484+
sampleCountColumn="samples"
475485
ids={ids}
476486
timeRange={timeRange}
477487
queueName={queueName}
@@ -493,6 +503,7 @@ function OverviewCharts({
493503
className="aspect-[2/1] sm:col-span-2 sm:aspect-[4/1]"
494504
query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
495505
fillGaps
506+
minBucketSeconds={SPARSE_CHART_MIN_BUCKET_SECONDS}
496507
ids={ids}
497508
timeRange={timeRange}
498509
queueName={queueName}
@@ -1003,7 +1014,10 @@ function KeyDrilldown({
10031014
<QueueDetailChartCard
10041015
title={`Key ${keyName}: mean scheduling delay`}
10051016
className="aspect-[2/1]"
1006-
query={`SELECT timeBucket() AS t, if(sum(wait_ms_count) > 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS wait\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
1017+
query={`SELECT timeBucket() AS t, if(sum(wait_ms_count) > 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS wait, sum(wait_ms_count) AS samples\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
1018+
fillGaps
1019+
minBucketSeconds={SPARSE_CHART_MIN_BUCKET_SECONDS}
1020+
sampleCountColumn="samples"
10071021
ids={ids}
10081022
timeRange={timeRange}
10091023
queueName={queueName}

0 commit comments

Comments
 (0)