Skip to content

Commit 1050195

Browse files
committed
fix(run-engine): stop the gated ck batch rewinding a live virtual-time tag
The gated-candidate block registers its batch with one variadic ZADD NX and then, if that added anything, walks every member of gatedPending applying its parked idle tag. The ZADD reports how many members it added but not which ones, so the correction lands on candidates it did not register. That reaches an already-registered variant whenever the pass-1 scan is truncated, since knownRegistered comes from that scan and it reads only scanLimit entries. A queue with more variants than that pushes registered ones into pass 2 as if they were new. Their idle entry also survives re-registration (the enqueue path reads the parked tag but never deletes it, and it is reaped only once the floor climbs past), so with floor < parked < live the XX write overwrites the live tag with the older one. The variant's clock winds back and it is served ahead of variants that are genuinely due, which is the opposite of what the feature is for and exactly what NX exists to prevent everywhere else. The current score is the discriminator: at the floor means the variant either just registered here or has no credit to lose, and above the floor means it has spent a turn and keeps its tag. Reading it first also skips the idle lookup for the advanced ones, so the branch gets cheaper rather than dearer, and the steady state is untouched because none of this runs unless something registered. Reported by Devin on #4367.
1 parent 62d6ce7 commit 1050195

2 files changed

Lines changed: 148 additions & 3 deletions

File tree

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5725,9 +5725,18 @@ if gatedPending ~= nil then
57255725
end
57265726
if redis.call('ZADD', unpack(gatedArgs)) > 0 then
57275727
for _, ckQueueName in ipairs(gatedPending) do
5728-
local gateIdle = redis.call('ZSCORE', ckVtimeIdleKey, ckQueueName)
5729-
if gateIdle and tonumber(gateIdle) > floor then
5730-
redis.call('ZADD', ckVtimeKey, 'XX', gateIdle, ckQueueName)
5728+
-- Only the members this call put at the floor may take their parked tag back. The
5729+
-- batched ZADD reports how many it added but not which, and gatedPending can hold an
5730+
-- already-registered variant whenever the pass-1 scan was truncated, so the current
5731+
-- score is the discriminator: at the floor means it either just registered or has no
5732+
-- credit to lose either way. Without this the parked tag overwrites a live advanced
5733+
-- one and rewinds that variant's clock, which is the one thing NX exists to stop.
5734+
local gateCur = redis.call('ZSCORE', ckVtimeKey, ckQueueName)
5735+
if gateCur and tonumber(gateCur) <= floor then
5736+
local gateIdle = redis.call('ZSCORE', ckVtimeIdleKey, ckQueueName)
5737+
if gateIdle and tonumber(gateIdle) > floor then
5738+
redis.call('ZADD', ckVtimeKey, 'XX', gateIdle, ckQueueName)
5739+
end
57315740
end
57325741
end
57335742
redis.call('EXPIRE', ckVtimeKey, stateTtl)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
import type { InputPayload } from "../types.js";
9+
10+
// Devin on #4367: the gated-candidate block corrects every member of gatedPending with its
11+
// parked idle tag once the batched ZADD NX has added at least one member, rather than only
12+
// the members it actually added. A candidate that is already registered with an advanced
13+
// tag can therefore have that tag overwritten by an older parked one, which rewinds its
14+
// virtual clock and hands it a turn it has already taken.
15+
//
16+
// Reaching it needs the pass-1 scan to be incomplete, because that scan is the only thing
17+
// that decides knownRegistered. It reads scanLimit entries, so a queue holding more
18+
// variants than that pushes already-registered ones into pass 2 as if they were new.
19+
20+
const testOptions = {
21+
name: "rq",
22+
tracer: trace.getTracer("rq"),
23+
workers: 1,
24+
defaultEnvConcurrency: 100,
25+
logger: new Logger("RunQueue", "error"),
26+
retryOptions: {
27+
maxAttempts: 5,
28+
factor: 1.1,
29+
minTimeoutInMs: 100,
30+
maxTimeoutInMs: 1_000,
31+
randomize: true,
32+
},
33+
keys: new RunQueueFullKeyProducer(),
34+
};
35+
36+
const authenticatedEnvDev = {
37+
id: "e1234",
38+
type: "DEVELOPMENT" as const,
39+
maximumConcurrencyLimit: 100,
40+
concurrencyLimitBurstFactor: new Decimal(1),
41+
project: { id: "p1234" },
42+
organization: { id: "o1234" },
43+
};
44+
45+
const QUEUE = "task/my-task";
46+
47+
function createQueue(redisContainer: any): any {
48+
const redis = {
49+
keyPrefix: "runqueue:test:rewind:",
50+
host: redisContainer.getHost(),
51+
port: redisContainer.getPort(),
52+
};
53+
return new RunQueue({
54+
...testOptions,
55+
masterQueueConsumersDisabled: true,
56+
workerOptions: { disabled: true },
57+
// window = 1 * 1, so scanLimit is 2 and four variants overflow it.
58+
ckVirtualTimeScheduling: { enabled: true, scanWindowMultiplier: 1 },
59+
queueSelectionStrategy: new FairQueueSelectionStrategy({ redis, keys: testOptions.keys }),
60+
redis,
61+
} as any) as any;
62+
}
63+
64+
function makeMessage(overrides: Partial<InputPayload> = {}): InputPayload {
65+
return {
66+
runId: "r1",
67+
taskIdentifier: QUEUE,
68+
orgId: "o1234",
69+
projectId: "p1234",
70+
environmentId: "e1234",
71+
environmentType: "DEVELOPMENT",
72+
queue: QUEUE,
73+
timestamp: Date.now(),
74+
attempt: 0,
75+
...overrides,
76+
};
77+
}
78+
79+
const variantName = (ck: string) => testOptions.keys.queueKey(authenticatedEnvDev, QUEUE, ck);
80+
81+
describe("CK vtime: the gated batch must not rewind a live tag", () => {
82+
redisTest("a stale parked tag cannot undercut an advanced one", async ({ redisContainer }) => {
83+
const queue = createQueue(redisContainer);
84+
try {
85+
const t0 = Date.now() - 100_000;
86+
87+
// Head age decides pass 2's order, so victim is visited first.
88+
const cks = ["victim", "wnew", "aa", "bb"];
89+
for (let i = 0; i < cks.length; i++) {
90+
await queue.enqueueMessage({
91+
env: authenticatedEnvDev,
92+
message: makeMessage({ runId: `r-${cks[i]}`, concurrencyKey: cks[i], timestamp: t0 + i }),
93+
workerQueue: authenticatedEnvDev.id,
94+
skipDequeueProcessing: true,
95+
});
96+
}
97+
98+
const victim = variantName("victim");
99+
const wnew = variantName("wnew");
100+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(victim);
101+
const ckVtimeIdleKey = testOptions.keys.ckVtimeIdleKeyFromQueue(victim);
102+
103+
// aa and bb hold the two scan slots, so victim falls outside the scan and reaches
104+
// pass 2 with knownRegistered false even though it is registered.
105+
await queue.redis.zadd(ckVtimeKey, 0, variantName("aa"), 1, variantName("bb"), 10, victim);
106+
// wnew is genuinely unregistered, so the batched ZADD NX adds one member and the
107+
// correction loop runs at all.
108+
await queue.redis.zrem(ckVtimeKey, wnew);
109+
// Left behind by an earlier drain: the enqueue path reads this to restore credit but
110+
// never deletes it, and it is only reaped once the floor climbs past it.
111+
await queue.redis.zadd(ckVtimeIdleKey, 5, victim);
112+
113+
// Every variant parked at its per-key ceiling, so pass 1 serves nothing and pass 2
114+
// reaches the gated block.
115+
await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1);
116+
for (const ck of cks) {
117+
await queue.redis.sadd(
118+
testOptions.keys.queueCurrentConcurrencyKeyFromQueue(variantName(ck)),
119+
"occupant"
120+
);
121+
}
122+
123+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
124+
const served = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1);
125+
expect(served.length).toBe(0);
126+
127+
// wnew joined at the floor, which is the whole point of the block.
128+
expect(await queue.redis.zscore(ckVtimeKey, wnew)).toBe("0");
129+
// victim spent its credit already and must keep its advanced tag. Rewinding it to
130+
// the parked 5 would put it ahead of variants that are genuinely due.
131+
expect(await queue.redis.zscore(ckVtimeKey, victim)).toBe("10");
132+
} finally {
133+
await queue.quit();
134+
}
135+
});
136+
});

0 commit comments

Comments
 (0)