Skip to content

Commit e69f000

Browse files
committed
fix(run-engine): keep parked ck credit when pass 2 repairs a variant
Pass 2 repairs a variant that reached ckIndex with no ckVtime entry, which during a mixed rollout is one a flag-off instance re-enqueued after a flag-on instance had parked its tag. Both of its routes repaired at the floor and threw the parked credit away, so a variant could reset its clock by draining and coming back, which is the starvation the arrival rule exists to stop. The discovery batch registered at the floor and left the parked tag sitting in ckVtimeIdle. The immediate serve was worse: it took the floor as its tag, charged a quantum, drained, and re-parked the lower value over the credit the variant had earned, so the loss outlived the rollout instead of ending with it. Both now reclaim max(floor, parked), gated on the current score being at the floor so a live advanced tag can't be rewound. The gated batch already did this and its copy of the rule moves into a shared local, since a third copy of it was the alternative. The extra read only fires where there was no entry to begin with, so a serving call is unchanged. The invariant comment above these two sites said floor registration is safe here because everything they see is established work that lost its tag. That answers whether there is anything to stack behind, and says nothing about whether there is credit to reclaim. Reported by Devin on #4367.
1 parent 3c23a3a commit e69f000

2 files changed

Lines changed: 193 additions & 16 deletions

File tree

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

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5608,8 +5608,18 @@ local function tryServe(ckQueueName, mayRaiseFloor, knownRegistered)
56085608
56095609
-- Advance this variant's virtual time (weight hook: fixed 1 today)
56105610
local weight = 1
5611-
local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor)
5612-
if tag < floor then tag = floor end
5611+
local storedTag = redis.call('ZSCORE', ckVtimeKey, ckQueueName)
5612+
local tag = floor
5613+
if storedTag then
5614+
tag = tonumber(storedTag)
5615+
if tag < floor then tag = floor end
5616+
else
5617+
-- No entry means pass 2 reached a variant that lost its tag, so this serve is a
5618+
-- repair. It may still have credit parked from an earlier drain, and serving it
5619+
-- at the floor would hand that credit out a second time.
5620+
local parkedTag = redis.call('ZSCORE', ckVtimeIdleKey, ckQueueName)
5621+
if parkedTag and tonumber(parkedTag) > floor then tag = tonumber(parkedTag) end
5622+
end
56135623
-- Pass 1 only: it walks in ascending tag order, so anything it has not visited
56145624
-- sits above this. Pass 2 goes by message age, so its tag says nothing about the
56155625
-- entries it skipped and must not move the floor over them.
@@ -5726,7 +5736,25 @@ local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(curren
57265736
-- because everything these two see is established work that lost its tag, which a tenant
57275737
-- cannot mint into while the flag is on. A new enqueue-side path has to stack instead, or
57285738
-- fresh keys start entering at the floor again.
5739+
-- Only a member this call put at the floor may take its parked tag back: a batched ZADD
5740+
-- reports how many it added and not which, so a live advanced tag would otherwise be
5741+
-- rewound. Both of pass 2's registration routes need it, since a variant that drained
5742+
-- under the flag and was re-enqueued by a flag-off instance arrives here with credit
5743+
-- parked and no entry.
5744+
local function restoreParkedTags(names)
5745+
for _, ckQueueName in ipairs(names) do
5746+
local cur = redis.call('ZSCORE', ckVtimeKey, ckQueueName)
5747+
if cur and tonumber(cur) <= floor then
5748+
local parked = redis.call('ZSCORE', ckVtimeIdleKey, ckQueueName)
5749+
if parked and tonumber(parked) > floor then
5750+
redis.call('ZADD', ckVtimeKey, 'XX', parked, ckQueueName)
5751+
end
5752+
end
5753+
end
5754+
end
5755+
57295756
local discovered = nil
5757+
local discoveredNames = nil
57305758
for _, ckQueueName in ipairs(ckQueues) do
57315759
if not attempted[ckQueueName] then
57325760
if dequeuedCount < actualMaxCount then
@@ -5736,14 +5764,17 @@ for _, ckQueueName in ipairs(ckQueues) do
57365764
-- call however many variants it registers. Skipping attempted matters:
57375765
-- tryServe GCs a drained variant out of both indexes, and re-adding it here
57385766
-- would resurrect a ckVtime entry with no ckIndex member.
5739-
if discovered == nil then discovered = {ckVtimeKey, 'NX'} end
5767+
if discovered == nil then discovered = {ckVtimeKey, 'NX'}; discoveredNames = {} end
57405768
table.insert(discovered, tostring(floor))
57415769
table.insert(discovered, ckQueueName)
5770+
table.insert(discoveredNames, ckQueueName)
57425771
end
57435772
end
57445773
end
57455774
if discovered ~= nil then
5746-
redis.call('ZADD', unpack(discovered))
5775+
if redis.call('ZADD', unpack(discovered)) > 0 then
5776+
restoreParkedTags(discoveredNames)
5777+
end
57475778
end
57485779
57495780
-- One variadic ZADD NX settles the whole batch: it registers the genuinely unregistered
@@ -5760,18 +5791,7 @@ if gatedPending ~= nil then
57605791
table.insert(gatedArgs, ckQueueName)
57615792
end
57625793
if redis.call('ZADD', unpack(gatedArgs)) > 0 then
5763-
for _, ckQueueName in ipairs(gatedPending) do
5764-
-- The batch ZADD reports how many it added, not which, and gatedPending can hold an
5765-
-- already-registered variant when the pass-1 scan was truncated. At the floor is the
5766-
-- discriminator: anything above it has spent credit that must not be rewound.
5767-
local gateCur = redis.call('ZSCORE', ckVtimeKey, ckQueueName)
5768-
if gateCur and tonumber(gateCur) <= floor then
5769-
local gateIdle = redis.call('ZSCORE', ckVtimeIdleKey, ckQueueName)
5770-
if gateIdle and tonumber(gateIdle) > floor then
5771-
redis.call('ZADD', ckVtimeKey, 'XX', gateIdle, ckQueueName)
5772-
end
5773-
end
5774-
end
5794+
restoreParkedTags(gatedPending)
57755795
redis.call('EXPIRE', ckVtimeKey, stateTtl)
57765796
-- This is the one write path that touches ckVtime without going through the
57775797
-- floor-persist block below, which only runs when the call served something. Left
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
// Pass 2 repairs a variant that reached ckIndex with no ckVtime entry, which during a
11+
// mixed rollout is one a flag-off instance re-enqueued after a flag-on instance parked its
12+
// tag. Both of its routes used to repair at the floor and drop the parked credit: the
13+
// discovery batch registered there, and an immediate serve took the floor as its tag,
14+
// advanced it, and re-parked the lower value over the credit the variant had earned.
15+
//
16+
// The floor is held at 0 by a second variant throughout, so a restored tag can't be
17+
// mistaken for it.
18+
19+
const testOptions = {
20+
name: "rq",
21+
tracer: trace.getTracer("rq"),
22+
workers: 1,
23+
defaultEnvConcurrency: 25,
24+
logger: new Logger("RunQueue", "error"),
25+
retryOptions: {
26+
maxAttempts: 5,
27+
factor: 1.1,
28+
minTimeoutInMs: 100,
29+
maxTimeoutInMs: 1_000,
30+
randomize: true,
31+
},
32+
keys: new RunQueueFullKeyProducer(),
33+
};
34+
35+
const authenticatedEnvDev = {
36+
id: "e1234",
37+
type: "DEVELOPMENT" as const,
38+
maximumConcurrencyLimit: 10,
39+
concurrencyLimitBurstFactor: new Decimal(2.0),
40+
project: { id: "p1234" },
41+
organization: { id: "o1234" },
42+
};
43+
44+
const QUEUE = "task/my-task";
45+
46+
function createQueue(
47+
redisContainer: any,
48+
opts: { vtime?: boolean; maxAttempts?: number } = {}
49+
): any {
50+
const redis = {
51+
keyPrefix: "runqueue:test:",
52+
host: redisContainer.getHost(),
53+
port: redisContainer.getPort(),
54+
};
55+
return new RunQueue({
56+
...testOptions,
57+
masterQueueConsumersDisabled: true,
58+
workerOptions: { disabled: true },
59+
...(opts.maxAttempts
60+
? { retryOptions: { ...testOptions.retryOptions, maxAttempts: opts.maxAttempts } }
61+
: {}),
62+
...(opts.vtime === false ? {} : { ckVirtualTimeScheduling: { enabled: true } }),
63+
queueSelectionStrategy: new FairQueueSelectionStrategy({ redis, keys: testOptions.keys }),
64+
redis,
65+
} as any) as any;
66+
}
67+
68+
function makeMessage(overrides: Partial<InputPayload> = {}): InputPayload {
69+
return {
70+
runId: "r1",
71+
taskIdentifier: QUEUE,
72+
orgId: "o1234",
73+
projectId: "p1234",
74+
environmentId: "e1234",
75+
environmentType: "DEVELOPMENT",
76+
queue: QUEUE,
77+
timestamp: Date.now(),
78+
attempt: 0,
79+
...overrides,
80+
};
81+
}
82+
83+
const variantName = (ck: string) => testOptions.keys.queueKey(authenticatedEnvDev, QUEUE, ck);
84+
const shardFor = () => testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
85+
86+
describe("CK vtime: pass-2 repair keeps parked credit", () => {
87+
const setup = async (redisContainer: any) => {
88+
const on = createQueue(redisContainer);
89+
const off = createQueue(redisContainer, { vtime: false });
90+
const t0 = Date.now() - 100_000;
91+
92+
await on.enqueueMessage({
93+
env: authenticatedEnvDev,
94+
message: makeMessage({ runId: "h0", concurrencyKey: "heavy", timestamp: t0 }),
95+
workerQueue: authenticatedEnvDev.id,
96+
skipDequeueProcessing: true,
97+
});
98+
await on.enqueueMessage({
99+
env: authenticatedEnvDev,
100+
message: makeMessage({ runId: "l0", concurrencyKey: "light", timestamp: t0 + 1 }),
101+
workerQueue: authenticatedEnvDev.id,
102+
skipDequeueProcessing: true,
103+
});
104+
105+
const heavy = variantName("heavy");
106+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(heavy);
107+
const ckVtimeIdleKey = testOptions.keys.ckVtimeIdleKeyFromQueue(heavy);
108+
109+
// Serving heavy's only message drains it, so the dequeue parks its tag.
110+
await on.testDequeueFromMasterQueue(shardFor(), authenticatedEnvDev.id, 1);
111+
expect(await on.redis.zscore(ckVtimeKey, heavy)).toBeNull();
112+
// Lifted clear of the floor so a restore is unambiguous.
113+
await on.redis.zadd(ckVtimeIdleKey, 5, heavy);
114+
await on.acknowledgeMessage(authenticatedEnvDev.organization.id, "h0", {
115+
skipDequeueProcessing: true,
116+
});
117+
118+
// The rollout step: an instance with the flag off re-enqueues, so heavy reaches
119+
// ckIndex without a ckVtime entry and pass 2 is the only thing that can see it.
120+
await off.enqueueMessage({
121+
env: authenticatedEnvDev,
122+
message: makeMessage({ runId: "h1", concurrencyKey: "heavy", timestamp: t0 + 2 }),
123+
workerQueue: authenticatedEnvDev.id,
124+
skipDequeueProcessing: true,
125+
});
126+
expect(await on.redis.zscore(ckVtimeKey, heavy)).toBeNull();
127+
128+
return { on, off, heavy, ckVtimeKey, ckVtimeIdleKey };
129+
};
130+
131+
redisTest("the discovery batch registers it at its parked tag", async ({ redisContainer }) => {
132+
const { on, off, heavy, ckVtimeKey } = await setup(redisContainer);
133+
try {
134+
// maxCount 1, so pass 1 fills the batch on light and pass 2 only discovers.
135+
await on.testDequeueFromMasterQueue(shardFor(), authenticatedEnvDev.id, 1);
136+
expect(await on.redis.zscore(ckVtimeKey, heavy)).toBe("5");
137+
} finally {
138+
await off.quit();
139+
await on.quit();
140+
}
141+
});
142+
143+
redisTest("an immediate serve charges from its parked tag", async ({ redisContainer }) => {
144+
const { on, off, heavy, ckVtimeKey, ckVtimeIdleKey } = await setup(redisContainer);
145+
try {
146+
// maxCount 2 leaves pass 2 a slot, so heavy is served rather than just registered.
147+
await on.testDequeueFromMasterQueue(shardFor(), authenticatedEnvDev.id, 2);
148+
// Served from 5 and charged one quantum, then drained again and re-parked at 6.
149+
// Taking the floor as its tag would have re-parked 1 over the credit it earned.
150+
expect(await on.redis.zscore(ckVtimeKey, heavy)).toBeNull();
151+
expect(await on.redis.zscore(ckVtimeIdleKey, heavy)).toBe("6");
152+
} finally {
153+
await off.quit();
154+
await on.quit();
155+
}
156+
});
157+
});

0 commit comments

Comments
 (0)