Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4859684
feat(webapp,run-engine,core,clickhouse): surface total concurrency in…
matt-aitken Aug 29, 2026
8e05eab
fix(run-engine,clickhouse,webapp): total gauges on enqueue paths; res…
matt-aitken Aug 29, 2026
27736e5
fix(clickhouse): keep migration comments semicolon-free
matt-aitken Aug 29, 2026
0bfe599
fix(webapp): skip the total concurrency read when the queue has no cap
matt-aitken Aug 29, 2026
9bdefa7
feat(webapp): show the Total column in the non-metrics queues table too
matt-aitken Aug 29, 2026
cc243a8
feat(webapp): fold the total cap into the Limit column
matt-aitken Aug 29, 2026
b774a06
fix(webapp): saturate the total-cap warning on keyed runs only
matt-aitken Aug 29, 2026
d685030
refactor(webapp,core,clickhouse): combined concurrency in responses, …
matt-aitken Aug 29, 2026
aa618c7
feat(webapp): bracketed combined limit in the Limit column
matt-aitken Aug 29, 2026
5155fe6
fix(webapp): combined-limit tooltip renders beside the cell link
matt-aitken Aug 29, 2026
426d054
Better tooltip message
matt-aitken Aug 31, 2026
f19600c
docs(core): combined.current is the declared cap, clamped at admit time
matt-aitken Aug 31, 2026
1d69e74
fix(run-engine): sample the combined gauge after batch admission
matt-aitken Aug 31, 2026
ce2db76
refactor(run-engine,webapp): drop per-key override admit reads and li…
matt-aitken Aug 31, 2026
67d4ae4
refactor(webapp): concurrency keys resource stops reading per-key ove…
matt-aitken Aug 31, 2026
00f7dbb
refactor(run-engine): drop the now-unreferenced ck-limits key builders
matt-aitken Aug 31, 2026
9593586
chore: lift the run-queue knip ignore
matt-aitken Aug 31, 2026
465f7ae
fix(run-engine,webapp,clickhouse): review fixes for the metrics tier
matt-aitken Aug 31, 2026
889bbe1
chore: drop the pre-rename changeset superseded by the combined one
matt-aitken Aug 31, 2026
1a40d6d
perf(run-engine): share the combined-limit read between the admit gat…
matt-aitken Aug 31, 2026
1e46193
perf(run-engine): dequeue gauges sample once, at return
matt-aitken Aug 31, 2026
609a635
test(run-engine): pin dequeue-emitted gauges so a sampling regression…
matt-aitken Aug 31, 2026
8a636d4
test(run-engine): wait for the metrics emitter connection before exer…
matt-aitken Aug 31, 2026
de43327
test(run-engine,metrics-pipeline): bound emitter-readiness waits and …
matt-aitken Aug 31, 2026
b1be89c
test(run-engine): abort the readiness race timer so its losing branch…
matt-aitken Aug 31, 2026
3707f85
test(run-engine): close the emitter when the readiness wait times out
matt-aitken Aug 31, 2026
a858019
test(metrics-pipeline,run-engine): readiness wait for the per-stream …
matt-aitken Aug 31, 2026
fbf5268
test(run-engine): fire-and-forget the emitter close on readiness timeout
matt-aitken Aug 31, 2026
abd6436
rename(webapp,clickhouse): concurrency vocabulary for the unlaunched …
matt-aitken Sep 6, 2026
454e444
fix(webapp,clickhouse,run-engine): rename follow-ups from review
matt-aitken Sep 6, 2026
505c78d
fix(webapp,clickhouse): pre-cap history keeps its truthful gap in the…
matt-aitken Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/webapp/app/components/queues/QueueMetricCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ type QueueMetricChartProps = {
* are config values that existed all along, so carry the first value backward instead.
*/
carryBackfill?: string[];
/**
* Column that marks a bucket as genuinely sampled. When set, carryBackfill only
* overwrites buckets where this column is absent or zero, so history from before
* a config value existed keeps its truthful gap instead of inheriting the value.
*/
carryBackfillGuard?: string;
/** Show the series legend below the chart (use for multi-series charts). */
showLegend?: boolean;
/**
Expand Down Expand Up @@ -141,6 +147,7 @@ export function QueueMetricChart({
defaultPeriod,
warningOverlay,
carryBackfill,
carryBackfillGuard,
thresholdStroke,
onHasDataChange,
minBucketSeconds,
Expand Down Expand Up @@ -174,12 +181,15 @@ export function QueueMetricChart({
const first = points.findIndex((p) => toNumber(p[key]) > 0);
if (first > 0) {
const value = points[first]![key]!;
for (let i = 0; i < first; i++) points[i]![key] = value;
for (let i = 0; i < first; i++) {
if (carryBackfillGuard && toNumber(points[i]![carryBackfillGuard]) > 0) continue;
points[i]![key] = value;
}
}
}
}
return points;
}, [rows, series, carryBackfill, sampleCountColumn]);
}, [rows, series, carryBackfill, carryBackfillGuard, sampleCountColumn]);

const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
Expand Down Expand Up @@ -340,7 +350,7 @@ export function QueueSidebarStats({
};

const { rows, showLoading } = useQueueMetric(
`SELECT max(max_queued) AS peak_queued,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS worst_p95\nFROM queue_metrics`,
`SELECT max(max_queued) AS peak_queued,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS worst_p95\nFROM concurrency_metrics`,
{ ids, timeRange, queueName, defaultPeriod }
);
const row = rows[0];
Expand Down
10 changes: 5 additions & 5 deletions apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ const queuesDashboard: BuiltInDashboard = {
"t-pressure": { title: "Queue pressure", query: "", display: { type: "title" } },
pressure: {
title: "Queue pressure",
query: `SELECT queue,\n argMax(max_running, bucket_start) AS running,\n argMax(max_queued, bucket_start) AS queued,\n argMax(max_limit, bucket_start) AS limit,\n running + queued AS demand,\n max(max_queued) AS peak_queued,\n sum(throttled_count) AS throttled,\n multiIf(running >= limit AND queued > 0, 'queue-limited', queued > 0, 'backlogged', 'healthy') AS status\nFROM queue_metrics\nGROUP BY queue\nORDER BY peak_queued DESC`,
query: `SELECT queue,\n argMax(max_running, bucket_start) AS running,\n argMax(max_queued, bucket_start) AS queued,\n argMax(max_limit, bucket_start) AS limit,\n running + queued AS demand,\n max(max_queued) AS peak_queued,\n sum(throttled_count) AS throttled,\n multiIf(running >= limit AND queued > 0, 'queue-limited', queued > 0, 'backlogged', 'healthy') AS status\nFROM concurrency_metrics\nGROUP BY queue\nORDER BY peak_queued DESC`,
display: {
type: "table",
prettyFormatting: true,
Expand All @@ -644,7 +644,7 @@ const queuesDashboard: BuiltInDashboard = {
"t-trends": { title: "Per-queue trends", query: "", display: { type: "title" } },
"running-q": {
title: "Running by queue",
query: `SELECT timeBucket() AS t, queue, max(max_running) AS running\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`,
query: `SELECT timeBucket() AS t, queue, max(max_running) AS running\nFROM concurrency_metrics\nGROUP BY t, queue\nORDER BY t`,
// Grouped gauge: carry each queue's running across idle buckets (per-group LOCF).
fillGaps: true,
display: {
Expand All @@ -661,7 +661,7 @@ const queuesDashboard: BuiltInDashboard = {
},
"queued-q": {
title: "Queue depth (backlog) by queue",
query: `SELECT timeBucket() AS t, queue, max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`,
query: `SELECT timeBucket() AS t, queue, max(max_queued) AS queued\nFROM concurrency_metrics\nGROUP BY t, queue\nORDER BY t`,
// Grouped gauge: carry each queue's backlog across idle buckets (per-group LOCF).
fillGaps: true,
display: {
Expand All @@ -678,7 +678,7 @@ const queuesDashboard: BuiltInDashboard = {
},
"throttled-q": {
title: "Throttled buckets by queue",
query: `SELECT timeBucket() AS t, queue, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`,
query: `SELECT timeBucket() AS t, queue, sum(throttled_count) AS throttled\nFROM concurrency_metrics\nGROUP BY t, queue\nORDER BY t`,
// Grouped counter: per-group zero-fill so idle buckets read 0, not a gap.
fillGaps: true,
display: {
Expand All @@ -697,7 +697,7 @@ const queuesDashboard: BuiltInDashboard = {
title: "Enqueued vs started",
// Counter states merge per queue, then sum outside: a single merge across queues
// mixes unrelated odometers and returns wrong totals.
query: `SELECT t, sum(enq) AS enqueued, sum(st) AS started\nFROM (\n SELECT timeBucket() AS t, queue,\n deltaSumTimestampMerge(enqueue_delta) AS enq,\n deltaSumTimestampMerge(started_delta) AS st\n FROM queue_metrics\n GROUP BY t, queue\n)\nGROUP BY t\nORDER BY t`,
query: `SELECT t, sum(enq) AS enqueued, sum(st) AS started\nFROM (\n SELECT timeBucket() AS t, queue,\n deltaSumTimestampMerge(enqueue_delta) AS enq,\n deltaSumTimestampMerge(started_delta) AS st\n FROM concurrency_metrics\n GROUP BY t, queue\n)\nGROUP BY t\nORDER BY t`,
display: {
type: "chart",
chartType: "line",
Expand Down
25 changes: 23 additions & 2 deletions apps/webapp/app/presenters/v3/QueueListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
import { toQueueItem } from "./QueueRetrievePresenter.server";

type QueueListEngine = Pick<RunEngine, "lengthOfQueues" | "currentConcurrencyOfQueues">;
type QueueListEngine = Pick<
RunEngine,
"lengthOfQueues" | "currentConcurrencyOfQueues" | "totalConcurrencyOfQueues"
>;

export const QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE = 25;
const MAX_ITEMS_PER_PAGE = 100;
Expand All @@ -34,6 +37,9 @@ const queueListSelect = {
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
concurrencyLimitOverridePercent: true,
totalConcurrencyLimit: true,
totalConcurrencyLimitBase: true,
totalConcurrencyLimitOverriddenAt: true,
type: true,
paused: true,
} satisfies Prisma.TaskQueueSelect;
Expand Down Expand Up @@ -333,11 +339,15 @@ export class QueueListPresenter extends BasePresenter {
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: string | null;
concurrencyLimitOverridePercent: Prisma.Decimal | null;
totalConcurrencyLimit: number | null;
totalConcurrencyLimitBase: number | null;
totalConcurrencyLimitOverriddenAt: Date | null;
type: TaskQueueType;
paused: boolean;
}[]
): Promise<QueueListItem[]> {
const [queuedByQueue, runningByQueue] = await Promise.all([
const queuesWithTotalCap = queues.filter((q) => q.totalConcurrencyLimit !== null);
const [queuedByQueue, runningByQueue, totalRunningByQueue] = await Promise.all([
this.engineClient.lengthOfQueues(
environment,
queues.map((q) => q.name)
Expand All @@ -346,6 +356,12 @@ export class QueueListPresenter extends BasePresenter {
environment,
queues.map((q) => q.name)
),
queuesWithTotalCap.length > 0
? this.engineClient.totalConcurrencyOfQueues(
environment,
queuesWithTotalCap.map((q) => q.name)
)
: Promise.resolve({} as Record<string, number>),
]);

// Manually "join" the overridden users because there is no way to implement the relationship
Expand Down Expand Up @@ -373,6 +389,11 @@ export class QueueListPresenter extends BasePresenter {
? (overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null)
: null,
paused: queue.paused,
totalConcurrencyLimit: queue.totalConcurrencyLimit,
totalConcurrencyLimitBase: queue.totalConcurrencyLimitBase,
totalConcurrencyLimitOverriddenAt: queue.totalConcurrencyLimitOverriddenAt,
totalRunning:
queue.totalConcurrencyLimit !== null ? (totalRunningByQueue[queue.name] ?? 0) : null,
}),
// Prisma returns Decimal; the client only needs a plain number (null for absolute overrides).
concurrencyLimitOverridePercent:
Expand Down
22 changes: 22 additions & 0 deletions apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export class QueueRetrievePresenter extends BasePresenter {
const results = await Promise.all([
engine.lengthOfQueues(environment, [queue.name]),
engine.currentConcurrencyOfQueues(environment, [queue.name]),
queue.totalConcurrencyLimit != null
? engine.totalConcurrencyOfQueues(environment, [queue.name])
: undefined,
]);

// Transform queues to include running and queued counts
Expand All @@ -107,6 +110,11 @@ export class QueueRetrievePresenter extends BasePresenter {
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
paused: queue.paused,
totalConcurrencyLimit: queue.totalConcurrencyLimit ?? null,
totalConcurrencyLimitBase: queue.totalConcurrencyLimitBase ?? null,
totalConcurrencyLimitOverriddenAt: queue.totalConcurrencyLimitOverriddenAt ?? null,
totalRunning:
queue.totalConcurrencyLimit != null ? (results[2]?.[queue.name] ?? 0) : null,
}),
// The percent source-of-truth for percent-based overrides isn't part of the shared
// `QueueItem` schema (that's a public contract), so we surface it as an extra field on
Expand Down Expand Up @@ -148,6 +156,10 @@ export function toQueueItem(data: {
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: User | null;
paused: boolean;
totalConcurrencyLimit?: number | null;
totalConcurrencyLimitBase?: number | null;
totalConcurrencyLimitOverriddenAt?: Date | null;
totalRunning?: number | null;
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
return {
id: data.friendlyId,
Expand All @@ -164,6 +176,16 @@ export function toQueueItem(data: {
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
overriddenAt: data.concurrencyLimitOverriddenAt,
combined:
data.totalConcurrencyLimit !== undefined
? {
current: data.totalConcurrencyLimit,
base: data.totalConcurrencyLimitBase ?? null,
override: data.totalConcurrencyLimitOverriddenAt ? data.totalConcurrencyLimit : null,
overriddenAt: data.totalConcurrencyLimitOverriddenAt ?? null,
running: data.totalRunning ?? null,
}
: undefined,
},
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
releaseConcurrencyOnWaitpoint: true,
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/presenters/v3/reports/health/health-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ function queueWorstQuery(): string {
return `SELECT
queue AS name,
argMax(max_queued, bucket_start) AS latest_queued
FROM queue_metrics
FROM concurrency_metrics
GROUP BY queue
ORDER BY latest_queued DESC
LIMIT 20`;
Expand All @@ -228,7 +228,7 @@ FROM (
SELECT
deltaSumTimestampMerge(dlq_delta) AS dlq,
argMax(max_queued, bucket_start) AS latest_queued
FROM queue_metrics
FROM concurrency_metrics
GROUP BY queue
)`;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/presenters/v3/reports/report-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { loadHealthInput } from "./health/health-data";
import { type ReportViewModel } from "./report-view-model";

/** A query table a report may read. Same table names the query API authorizes against. */
export type ReportQueryTable = "runs" | "env_metrics" | "queue_metrics";
export type ReportQueryTable = "runs" | "env_metrics" | "concurrency_metrics";

export type ReportLoader<TInput> = {
/** Authorization metadata: the route derives its per-table JWT scope check from this. */
Expand All @@ -19,7 +19,7 @@ function defineReport<TInput>(loader: ReportLoader<TInput>): ReportLoader<unknow

export const REPORT_REGISTRY: Record<string, ReportLoader<unknown>> = {
health: defineReport({
tables: ["runs", "env_metrics", "queue_metrics"],
tables: ["runs", "env_metrics", "concurrency_metrics"],
load: (env, period) => loadHealthInput(env, period),
interpret: interpretHealth,
}),
Expand Down
Loading
Loading