Skip to content

Commit 2cb53b7

Browse files
committed
fix(webapp): stop a zero-limit queue reading as at capacity
A limit of 0 is zero capacity, not saturation: running >= 0 holds for every queue, so any backlog marked the queue degraded and offered Investigate, while the agent's own suggested prompt stayed silent. One predicate now decides it for the queue detail page, the queues list badge and the page mappers.
1 parent 0778055 commit 2cb53b7

7 files changed

Lines changed: 117 additions & 11 deletions

File tree

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,15 @@ describe("queueAgentPageContext", () => {
253253
expect(context?.signals).toEqual([]);
254254
});
255255

256+
it("emits nothing for a zero-limit queue, which has no capacity to be at", () => {
257+
const context = queueAgentPageContext(
258+
queueLoaderData({ concurrencyLimit: 0, running: 0, queued: 12 })
259+
);
260+
261+
expect(context?.page).toMatchObject({ health: "warn" });
262+
expect(context?.signals).toEqual([]);
263+
});
264+
256265
it("returns undefined for data it doesn't recognise", () => {
257266
expect(queueAgentPageContext(undefined)).toBeUndefined();
258267
expect(queueAgentPageContext({ queue: { name: "x" } })).toBeUndefined();

apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import type { AgentPageContext, AgentPageSignal } from "@internal/dashboard-agent-contracts";
66
import { z } from "zod";
7+
import { isQueueAtCapacity, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
78

89
export const FRESH_FAILURE_WINDOW_MS = 30 * 60_000;
910

@@ -149,15 +150,15 @@ export function queuesAgentPageContext(data: unknown): AgentPageContext | undefi
149150
const limit = concurrencyLimit * (burstFactor && burstFactor > 0 ? burstFactor : 1);
150151
const signals: AgentPageSignal[] = [];
151152

152-
if (limit > 0 && running >= limit && queued > 0) {
153+
if (isQueueAtCapacity({ running, queued, limit })) {
153154
signals.push({ kind: "concurrency_saturation", severity: queued >= limit ? "crit" : "warn" });
154155
}
155156

156157
return { page: { kind: "queues" }, signals };
157158
}
158159

159-
/** The queue detail route imports this as its `OLDEST_WAIT_WARNING_MS`. */
160-
export const QUEUE_OLDEST_WAIT_WARNING_MS = 5 * 60_000;
160+
/** Re-export under the mapper's name; the queue pages own the threshold. */
161+
export const QUEUE_OLDEST_WAIT_WARNING_MS = OLDEST_WAIT_WARNING_MS;
161162

162163
const queueLoaderDataSchema = z.object({
163164
queue: z.object({
@@ -202,7 +203,7 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
202203
const { name, paused, running, queued, concurrencyLimit } = parsed.data.queue;
203204
const { environmentConcurrencyLimit, oldestQueuedAt, loadedAt, ckBreakdown } = parsed.data;
204205
const limit = concurrencyLimit ?? environmentConcurrencyLimit ?? null;
205-
const atCapacity = limit !== null && limit > 0 && running >= limit && queued > 0;
206+
const atCapacity = isQueueAtCapacity({ running, queued, limit });
206207

207208
const oldestWait = oldestWaitMs(ckBreakdown?.keys ?? [], oldestQueuedAt, loadedAt);
208209
const waitingTooLong = oldestWait !== null && oldestWait >= QUEUE_OLDEST_WAIT_WARNING_MS;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isQueueAtCapacity, isQueueDegraded, OLDEST_WAIT_WARNING_MS } from "./queue-thresholds";
3+
4+
describe("isQueueAtCapacity", () => {
5+
it("is false for a zero limit with a backlog", () => {
6+
expect(isQueueAtCapacity({ running: 0, queued: 12, limit: 0 })).toBe(false);
7+
expect(isQueueAtCapacity({ running: 3, queued: 12, limit: 0 })).toBe(false);
8+
});
9+
10+
it("is false when no limit is known", () => {
11+
expect(isQueueAtCapacity({ running: 5, queued: 12, limit: null })).toBe(false);
12+
expect(isQueueAtCapacity({ running: 5, queued: 12, limit: undefined })).toBe(false);
13+
});
14+
15+
it("is true when a positive limit is full and work is waiting", () => {
16+
expect(isQueueAtCapacity({ running: 10, queued: 4, limit: 10 })).toBe(true);
17+
expect(isQueueAtCapacity({ running: 11, queued: 1, limit: 10 })).toBe(true);
18+
});
19+
20+
it("is false when the limit is full but nothing is waiting", () => {
21+
expect(isQueueAtCapacity({ running: 10, queued: 0, limit: 10 })).toBe(false);
22+
});
23+
});
24+
25+
describe("isQueueDegraded", () => {
26+
const base = { paused: false, oldestWaitMs: null };
27+
28+
it("does not degrade a zero-limit queue with a backlog", () => {
29+
expect(isQueueDegraded({ ...base, running: 0, queued: 12, limit: 0 })).toBe(false);
30+
});
31+
32+
it("degrades a saturated queue", () => {
33+
expect(isQueueDegraded({ ...base, running: 10, queued: 4, limit: 10 })).toBe(true);
34+
});
35+
36+
it("degrades on head-of-line wait regardless of the limit", () => {
37+
expect(
38+
isQueueDegraded({
39+
paused: false,
40+
running: 0,
41+
queued: 12,
42+
limit: 0,
43+
oldestWaitMs: OLDEST_WAIT_WARNING_MS,
44+
})
45+
).toBe(true);
46+
});
47+
48+
it("never degrades a paused queue", () => {
49+
expect(
50+
isQueueDegraded({
51+
paused: true,
52+
running: 10,
53+
queued: 4,
54+
limit: 10,
55+
oldestWaitMs: OLDEST_WAIT_WARNING_MS * 10,
56+
})
57+
).toBe(false);
58+
});
59+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,35 @@
11
/** Head-of-line wait at which a queue reads as stuck. */
22
export const OLDEST_WAIT_WARNING_MS = 5 * 60_000;
3+
4+
export type QueueCapacity = {
5+
running: number;
6+
queued: number;
7+
/** Effective limit: the queue's own, else the environment's. Null when neither is set. */
8+
limit: number | null | undefined;
9+
};
10+
11+
/**
12+
* Saturation: the queue is running everything it is allowed to and still has a backlog.
13+
* A limit of 0 is zero capacity, not saturation — `running >= 0` holds for every queue, so
14+
* without the guard any backlog would read as saturated.
15+
*/
16+
export function isQueueAtCapacity({ running, queued, limit }: QueueCapacity): boolean {
17+
if (limit === null || limit === undefined || limit <= 0) return false;
18+
return running >= limit && queued > 0;
19+
}
20+
21+
/** Whether the queue detail page offers Investigate. Paused is a state, not a fault. */
22+
export function isQueueDegraded({
23+
paused,
24+
oldestWaitMs,
25+
...capacity
26+
}: QueueCapacity & {
27+
paused: boolean | null | undefined;
28+
oldestWaitMs: number | null | undefined;
29+
}): boolean {
30+
if (paused) return false;
31+
if (isQueueAtCapacity(capacity)) return true;
32+
return (
33+
oldestWaitMs !== null && oldestWaitMs !== undefined && oldestWaitMs >= OLDEST_WAIT_WARNING_MS
34+
);
35+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import {
114114
useRememberQueueMetricsPeriod,
115115
} from "~/components/queues/queueMetricsPeriod";
116116
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
117+
import { isQueueAtCapacity } from "~/components/queues/queue-thresholds";
117118
import { pageMeta } from "~/utils/pageTitle";
118119

119120
const SearchParamsSchema = z.object({
@@ -1607,7 +1608,7 @@ type QueueHealthLabel = "Paused" | "At capacity" | "Backlogged" | "Active" | "Id
16071608
// health-column sort so the sorted order always matches the labels shown.
16081609
function queueHealthLabel({ paused, running, queued, limit }: QueueHealth): QueueHealthLabel {
16091610
if (paused) return "Paused";
1610-
if (running >= limit && queued > 0) return "At capacity";
1611+
if (isQueueAtCapacity({ running, queued, limit })) return "At capacity";
16111612
if (queued > 0) return "Backlogged";
16121613
if (running > 0) return "Active";
16131614
return "Idle";

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { MetricsLayout } from "~/components/layout/MetricsLayout";
88
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
99
import { BigNumber } from "~/components/metrics/BigNumber";
1010
import { Header3 } from "~/components/primitives/Headers";
11-
import { OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
11+
import { isQueueDegraded, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
1212
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
1313
import { Spinner } from "~/components/primitives/Spinner";
1414
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
@@ -277,11 +277,13 @@ export default function Page() {
277277
const selectedKey = value("key");
278278

279279
const oldestWaitMs = wholeQueueOldestWaitMs(ckBreakdown, oldestQueuedAt, loadedAt);
280-
const concurrencyLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
281-
const degraded =
282-
!queue.paused &&
283-
((queue.running >= concurrencyLimit && queue.queued > 0) ||
284-
(oldestWaitMs !== null && oldestWaitMs >= OLDEST_WAIT_WARNING_MS));
280+
const degraded = isQueueDegraded({
281+
paused: queue.paused,
282+
running: queue.running,
283+
queued: queue.queued,
284+
limit: queue.concurrencyLimit ?? environmentConcurrencyLimit,
285+
oldestWaitMs,
286+
});
285287

286288
return (
287289
<PageContainer>

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineConfig({
1919
"app/runEngine/services/**/*.test.ts",
2020
"app/utils/**/*.test.ts",
2121
"app/components/dashboard-agent/**/*.test.ts",
22+
"app/components/queues/**/*.test.ts",
2223
],
2324
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
2425
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts

0 commit comments

Comments
 (0)