Skip to content

Commit 85f0a82

Browse files
committed
fix(run-engine): stop concurrency-gated ck variants eating the fair pass
A candidate parked at its per-key concurrency ceiling fell out of tryServe returning nil, so pass 1 spent one of its window slots on a variant it could not serve. A gated variant's tag also stops advancing, so it keeps sorting to the front of ckVtime and is revisited first on every call. Enough of them and pass 1 serves nothing, ever, and the scheduler quietly runs on pass 2's age order instead. The original note on this said work conservation still held because pass 2 fills the batch, and that was the reason it was left alone. It does not hold. Where the gated variants are also the oldest, which is the ordinary case since a variant that has been queued longest is likely to be both old and saturated, pass 2's own window fills with the same variants and servable work behind them is reached by neither pass. The test added here starts from that shape and serves nothing at all before the fix, rather than serving in the wrong order. So a gated candidate now reports 'notReady', exactly as a future-scheduled head already did, and pass 1 reads past it without spending a slot. The read is bounded by scanLimit, which is already the cap on how far pass 1 will look. It is not free. On a fully gated call pass 1 now reads to scanLimit instead of stopping at the window: measured 53 Redis operations before and 80 after, every one of the 27 a SCARD, so roughly 10 usec. That is worth paying, because a fully gated call serves nothing either way, while a partially gated one goes from serving nothing to serving in fair order. A second test pins the op count against scanLimit plus the pass-2 window so the read cannot start running away. Reported by Devin on #4367.
1 parent a8e62f9 commit 85f0a82

4 files changed

Lines changed: 301 additions & 15 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5632,6 +5632,16 @@ local function tryServe(ckQueueName, mayRaiseFloor, knownRegistered)
56325632
if gatedPending == nil then gatedPending = {} end
56335633
table.insert(gatedPending, ckQueueName)
56345634
end
5635+
-- NEW: report the gate the same way a future head is reported, so pass 1 declines to
5636+
-- spend a window slot on a candidate it cannot serve. Previously this fell through
5637+
-- returning nil and cost a slot, and because a gated variant's tag stops advancing it
5638+
-- also keeps sorting to the front and being revisited first, so enough of them
5639+
-- permanently consumed the fair pass and the scheduler ran on pass 2's age order
5640+
-- instead. Worse than that in the shape where the gated variants also hold the oldest
5641+
-- heads: pass 2's own window fills with them too and servable work behind them is
5642+
-- reached by neither pass for as long as the gate holds. Skipping without spending is
5643+
-- bounded by scanLimit, which is already the cap on how far pass 1 will read.
5644+
return 'notReady'
56355645
end
56365646
end
56375647
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import { trace } from "@internal/tracing";
3+
import { Logger } from "@trigger.dev/core/logger";
4+
import { Decimal } from "@trigger.dev/database";
5+
import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js";
6+
import { RunQueue } from "../index.js";
7+
import { RunQueueFullKeyProducer } from "../keyProducer.js";
8+
const keys = new RunQueueFullKeyProducer();
9+
const baseEnv: any = {
10+
id: "e1234",
11+
type: "DEVELOPMENT",
12+
maximumConcurrencyLimit: 100,
13+
concurrencyLimitBurstFactor: new Decimal(1),
14+
project: { id: "p1234" },
15+
organization: { id: "o1234" },
16+
};
17+
const QUEUE = "task/my-task";
18+
const mk = (o: any) => ({
19+
runId: "r1",
20+
taskIdentifier: QUEUE,
21+
orgId: "o1234",
22+
projectId: "p1234",
23+
environmentId: "e1234",
24+
environmentType: "DEVELOPMENT",
25+
queue: QUEUE,
26+
timestamp: Date.now(),
27+
attempt: 0,
28+
...o,
29+
});
30+
// Declining to spend a window slot on a gated candidate means pass 1 reads further, so a
31+
// fully-gated call costs more than it used to: measured 53 ops before and 80 after, the
32+
// whole difference being SCARDs. That is the price of not silently degrading to age order,
33+
// and it is worth paying because a fully-gated call serves nothing either way. What must
34+
// not happen is the read running away, so this pins it against scanLimit (window * 2)
35+
// rather than against the measured number, which would only be a tripwire.
36+
describe("op count: fully gated dequeue", () => {
37+
redisTest(
38+
"pass 1 reads further when everything is gated, but stays inside scanLimit",
39+
async ({ redisContainer }) => {
40+
const MAX = 10; // window = 30, scanLimit = 60
41+
const GATED = 80; // more than scanLimit, so both bounds bind
42+
const kp = "rq:opc:";
43+
const q: any = new RunQueue({
44+
name: "rq",
45+
tracer: trace.getTracer("rq"),
46+
workers: 1,
47+
defaultEnvConcurrency: 100,
48+
logger: new Logger("RunQueue", "error"),
49+
retryOptions: {
50+
maxAttempts: 5,
51+
factor: 1.1,
52+
minTimeoutInMs: 100,
53+
maxTimeoutInMs: 1000,
54+
randomize: true,
55+
},
56+
keys,
57+
masterQueueConsumersDisabled: true,
58+
workerOptions: { disabled: true },
59+
ckVirtualTimeScheduling: { enabled: true, scanWindowMultiplier: 3 },
60+
queueSelectionStrategy: new FairQueueSelectionStrategy({
61+
redis: { keyPrefix: kp, host: redisContainer.getHost(), port: redisContainer.getPort() },
62+
keys,
63+
}),
64+
redis: { keyPrefix: kp, host: redisContainer.getHost(), port: redisContainer.getPort() },
65+
} as any);
66+
const env = baseEnv;
67+
await q.updateEnvConcurrencyLimits(env);
68+
const shard = keys.masterQueueShardForEnvironment(env.id, 2);
69+
const t0 = Date.now() - 5000000;
70+
for (let i = 0; i < GATED; i++) {
71+
await q.enqueueMessage({
72+
env,
73+
message: mk({ runId: "g" + i, concurrencyKey: "gated-" + i, timestamp: t0 + i }),
74+
workerQueue: env.id,
75+
skipDequeueProcessing: true,
76+
});
77+
const members = Array.from({ length: 105 }, (_, k) => "busy-" + i + "-" + k);
78+
await q.redis.sadd(
79+
keys.queueKey(env, QUEUE, "gated-" + i) + ":currentConcurrency",
80+
...members
81+
);
82+
}
83+
await q.redis.config("RESETSTAT");
84+
const served = await q.testDequeueFromMasterQueue(shard, env.id, MAX);
85+
const info = await q.redis.info("commandstats");
86+
let total = 0;
87+
const per: Record<string, number> = {};
88+
for (const line of info.split("\n")) {
89+
const m = line.match(/^cmdstat_([a-z|]+):calls=(\d+)/);
90+
if (!m || ["info", "config"].includes(m[1])) continue;
91+
per[m[1]] = parseInt(m[2], 10);
92+
total += parseInt(m[2], 10);
93+
}
94+
// Nothing is servable, so the call is pure scan.
95+
expect(served.length).toBe(0);
96+
// window = MAX * 3 = 30, scanLimit = 60. Pass 1 may read up to scanLimit candidates and
97+
// pass 2 up to its own window, one SCARD each, so that sum is the ceiling.
98+
const scanLimit = MAX * 3 * 2;
99+
const pass2Window = MAX * 3;
100+
expect(per.scard ?? 0).toBeLessThanOrEqual(scanLimit + pass2Window);
101+
// And it must read past the old window bound, or the fix is not in effect.
102+
expect(per.scard ?? 0).toBeGreaterThan(MAX * 3);
103+
await q.quit();
104+
},
105+
120000
106+
);
107+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import { trace } from "@internal/tracing";
3+
import { Logger } from "@trigger.dev/core/logger";
4+
import { Decimal } from "@trigger.dev/database";
5+
import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js";
6+
import { RunQueue } from "../index.js";
7+
import { RunQueueFullKeyProducer } from "../keyProducer.js";
8+
9+
// Devin's finding B on #4367: a concurrency-gated candidate returns from tryServe without
10+
// the 'notReady' marker, so it spends one of pass 1's window slots even though it can
11+
// never be served. A gated variant also stops advancing its tag, so it keeps sorting to
12+
// the front and is revisited first on every call. Fill the window with them and pass 1
13+
// serves nothing, every call, and the scheduler silently degrades to pass 2's age order.
14+
//
15+
// Work conservation survives that, which is why it was originally waved through. What does
16+
// not survive is the feature's whole purpose: fair order. This pins the difference.
17+
18+
const testOptions = {
19+
name: "rq",
20+
tracer: trace.getTracer("rq"),
21+
workers: 1,
22+
defaultEnvConcurrency: 100,
23+
logger: new Logger("RunQueue", "error"),
24+
retryOptions: {
25+
maxAttempts: 5,
26+
factor: 1.1,
27+
minTimeoutInMs: 100,
28+
maxTimeoutInMs: 1000,
29+
randomize: true,
30+
},
31+
keys: new RunQueueFullKeyProducer(),
32+
};
33+
const baseEnv: any = {
34+
id: "e1234",
35+
type: "DEVELOPMENT",
36+
maximumConcurrencyLimit: 100,
37+
concurrencyLimitBurstFactor: new Decimal(1),
38+
project: { id: "p1234" },
39+
organization: { id: "o1234" },
40+
};
41+
const QUEUE = "task/my-task";
42+
const makeMessage = (o: any) => ({
43+
runId: "r1",
44+
taskIdentifier: QUEUE,
45+
orgId: "o1234",
46+
projectId: "p1234",
47+
environmentId: "e1234",
48+
environmentType: "DEVELOPMENT",
49+
queue: QUEUE,
50+
timestamp: Date.now(),
51+
attempt: 0,
52+
...o,
53+
});
54+
const variantName = (ck: string) => testOptions.keys.queueKey(baseEnv, QUEUE, ck);
55+
56+
function createQueue(rc: any, keyPrefix: string) {
57+
return new RunQueue({
58+
...testOptions,
59+
masterQueueConsumersDisabled: true,
60+
workerOptions: { disabled: true },
61+
ckVirtualTimeScheduling: { enabled: true, scanWindowMultiplier: 3 },
62+
queueSelectionStrategy: new FairQueueSelectionStrategy({
63+
redis: { keyPrefix, host: rc.getHost(), port: rc.getPort() },
64+
keys: testOptions.keys,
65+
}),
66+
redis: { keyPrefix, host: rc.getHost(), port: rc.getPort() },
67+
} as any) as any;
68+
}
69+
70+
describe("CK vtime: gated variants and the pass-1 window", () => {
71+
redisTest(
72+
"gated variants must not spend the fair pass's budget",
73+
async ({ redisContainer }) => {
74+
const MAX = 2; // window = MAX * 3 = 6
75+
const GATED = 8; // more than the window, all sorting ahead on tag
76+
const queue = createQueue(redisContainer, "runqueue:test:gatedwin:");
77+
78+
try {
79+
const env = baseEnv;
80+
await queue.updateEnvConcurrencyLimits(env);
81+
const shard = testOptions.keys.masterQueueShardForEnvironment(env.id, 2);
82+
const t0 = Date.now() - 5_000_000;
83+
84+
// Gated variants: queued work, but each parked at its per-key ceiling so it can
85+
// never be served. Enqueued first so their heads are oldest too.
86+
for (let i = 0; i < GATED; i++) {
87+
await queue.enqueueMessage({
88+
env,
89+
message: makeMessage({
90+
runId: `g-${i}`,
91+
concurrencyKey: `gated-${i}`,
92+
timestamp: t0 + i,
93+
}),
94+
workerQueue: env.id,
95+
skipDequeueProcessing: true,
96+
});
97+
}
98+
99+
// "owed" is what fair order says to serve next: the lowest tag among servable
100+
// variants. Its head is the NEWEST, so age order would put it last.
101+
await queue.enqueueMessage({
102+
env,
103+
message: makeMessage({
104+
runId: "owed-0",
105+
concurrencyKey: "owed",
106+
timestamp: t0 + 900_000,
107+
}),
108+
workerQueue: env.id,
109+
skipDequeueProcessing: true,
110+
});
111+
// "old" has the OLDEST head of the servable pair but a higher tag, so age order
112+
// serves it first and fair order serves it second.
113+
await queue.enqueueMessage({
114+
env,
115+
message: makeMessage({ runId: "old-0", concurrencyKey: "old", timestamp: t0 + 100 }),
116+
workerQueue: env.id,
117+
skipDequeueProcessing: true,
118+
});
119+
120+
// Park every gated variant at its ceiling.
121+
const limit = 100;
122+
for (let i = 0; i < GATED; i++) {
123+
const members = Array.from({ length: limit + 5 }, (_, k) => `busy-${i}-${k}`);
124+
await queue.redis.sadd(`${variantName(`gated-${i}`)}:currentConcurrency`, ...members);
125+
}
126+
127+
// Tags: gated variants lowest so they lead pass 1, then owed, then old.
128+
const ckv = testOptions.keys.ckVtimeKeyFromQueue(variantName("owed"));
129+
for (let i = 0; i < GATED; i++) await queue.redis.zadd(ckv, 0, variantName(`gated-${i}`));
130+
await queue.redis.zadd(ckv, 1, variantName("owed"));
131+
await queue.redis.zadd(ckv, 5, variantName("old"));
132+
133+
const served: string[] = [];
134+
for (let c = 0; c < 4 && served.length < 2; c++) {
135+
for (const m of await queue.testDequeueFromMasterQueue(shard, env.id, MAX)) {
136+
served.push(m.message.concurrencyKey as string);
137+
await queue.acknowledgeMessage(env.organization.id, m.messageId, {
138+
skipDequeueProcessing: true,
139+
});
140+
}
141+
}
142+
143+
// Fair order is the point of the feature: lowest tag first. If gated variants have
144+
// eaten the window, pass 1 served nothing and pass 2's age order ran instead,
145+
// which puts "old" first.
146+
expect(served[0]).toBe("owed");
147+
} finally {
148+
await queue.quit();
149+
}
150+
},
151+
60_000
152+
);
153+
});

internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -230,17 +230,34 @@ describe("FairDequeuingStrategy", () => {
230230
envId: "env-3",
231231
});
232232

233-
const startDistribute1 = performance.now();
233+
// Command counts rather than wall clock. What this test is really asserting is that
234+
// the second call reuses the snapshot instead of rebuilding it, and the timing ratio
235+
// it used to assert was a proxy for that: sub-millisecond durations compared as a
236+
// ratio, which flakes the moment anything else is running on the box. Counting the
237+
// commands the strategy issues measures the same thing and cannot be perturbed by
238+
// load.
239+
const counter = createRedisClient(redis);
240+
const commandCount = async () => {
241+
const info = await counter.info("commandstats");
242+
let total = 0;
243+
for (const line of info.split("\n")) {
244+
const m = line.match(/^cmdstat_([a-z|]+):calls=(\d+)/);
245+
if (!m || ["info", "config"].includes(m[1])) continue;
246+
total += parseInt(m[2], 10);
247+
}
248+
return total;
249+
};
250+
251+
await counter.config("RESETSTAT");
252+
const before1 = await commandCount();
234253

235254
const envResult = await strategy.distributeFairQueuesFromParentQueue(
236255
"parent-queue",
237256
"consumer-1"
238257
);
239258
const result = flattenResults(envResult);
240259

241-
const distribute1Duration = performance.now() - startDistribute1;
242-
243-
console.log("First distribution took", distribute1Duration, "ms");
260+
const distribute1Commands = (await commandCount()) - before1;
244261

245262
expect(result).toHaveLength(3);
246263
// Should only get the two oldest queues
@@ -249,33 +266,32 @@ describe("FairDequeuingStrategy", () => {
249266
const queue3 = keyProducer.queueKey("org-3", "proj-3", "env-3", "queue-3");
250267
expect(result).toEqual([queue2, queue1, queue3]);
251268

252-
const startDistribute2 = performance.now();
269+
const before2 = await commandCount();
253270

254271
const _result2 = await strategy.distributeFairQueuesFromParentQueue(
255272
"parent-queue",
256273
"consumer-1"
257274
);
258275

259-
const distribute2Duration = performance.now() - startDistribute2;
260-
261-
console.log("Second distribution took", distribute2Duration, "ms");
276+
const distribute2Commands = (await commandCount()) - before2;
262277

263-
// Make sure the second call is more than 2 times faster than the first
264-
expect(distribute2Duration).toBeLessThan(distribute1Duration / 2);
278+
// Reused snapshot: the second call does materially less Redis work than the first.
279+
expect(distribute2Commands).toBeLessThan(distribute1Commands / 2);
265280

266-
const startDistribute3 = performance.now();
281+
const before3 = await commandCount();
267282

268283
const _result3 = await strategy.distributeFairQueuesFromParentQueue(
269284
"parent-queue",
270285
"consumer-1"
271286
);
272287

273-
const distribute3Duration = performance.now() - startDistribute3;
288+
const distribute3Commands = (await commandCount()) - before3;
274289

275-
console.log("Third distribution took", distribute3Duration, "ms");
290+
// The snapshot has aged out by now, so the third call rebuilds it and pays the full
291+
// cost again rather than the cached one.
292+
expect(distribute3Commands).toBeGreaterThan(distribute2Commands * 2);
276293

277-
// Make sure the third call is more than 4 times the second
278-
expect(distribute3Duration).toBeGreaterThan(distribute2Duration * 2);
294+
await counter.quit();
279295
}
280296
);
281297

0 commit comments

Comments
 (0)