Skip to content

Commit 9053f34

Browse files
committed
fix(run-engine): make ck vtime pass 2 discover unregistered variants
At the moment the flag is flipped, :ckVtime is empty and every already-queued variant is unregistered. The first dequeue has an empty pass 1, so pass 2 serves and registers up to actualMaxCount variants. From the next call on, pass 1 can serve one message per registered variant and actualMaxCount is often small, so pass 1 fills the batch off that cohort alone. Pass 2 was gated on dequeuedCount < actualMaxCount, so it never ran again, and the rest of the backlog stayed invisible until a registered variant fully drained or an enqueue or nack happened to land on it. A key that gets no further work has no other route into the fair order. Same reachability shape 1a6d1a5 fixed for registered-but-unservable variants, applied to the unregistered cohort. It also covers a mixed deploy, where an instance with the flag still off enqueues through the non-vtime command, and a :ckVtime that expired while ckIndex lived. Pass 2 now always runs. When the batch is already full it registers the variants pass 1 could not see, at the floor, instead of serving them, so the next call's pass 1 leads with them. Serving is still capped at actualMaxCount, so no serve happens that the old gate would have refused, and the fairness scenarios are byte-identical: ckSkew, ckTrickle, ckSybil, ckBalanced, ckManyKeys, ckHeavyIdle and ckStalledNewcomer all report the same numbers as before. Op cost is one extra fixed op per call. The pass-1 window read doubles as a free membership set, so nothing is registered twice, and the registrations are collected into a single variadic ZADD NX rather than one call each. Measured on the op-count budget test: 11.62 ops per dequeue over the flag-off path, against 10.90 before. The budget comment now counts 8 fixed ops rather than 7. Devin's suggestion of reserving a batch slot for pass 2 does not fix it. Pass 2 walks ckIndex in age order, so its one reserved slot always lands on a variant that is already registered and never reaches the cohort that is not. Measured against the new tests: identical to no fix at all, 12, 16 and 12 calls. Reported by Devin on #4367.
1 parent 1a6d1a5 commit 9053f34

3 files changed

Lines changed: 178 additions & 12 deletions

File tree

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

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5061,6 +5061,9 @@ return __qmret(results)
50615061
// queue keep their timestamp score domain. The per-candidate serve body is a
50625062
// verbatim copy of dequeueMessagesFromCkQueueTracked's, with the marked NEW
50635063
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1).
5064+
// Pass 2 always runs: when the batch is already full it registers the variants
5065+
// pass 1 could not see rather than serving them, which is what keeps a backlog
5066+
// queued before the flag went on from being unreachable.
50645067
this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", {
50655068
numberOfKeys: 13,
50665069
lua: `
@@ -5219,24 +5222,49 @@ end
52195222
52205223
-- Pass 1: fair order (lowest virtual start tag first)
52215224
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
5225+
-- NEW: the window read doubles as a free membership set for pass 2's discovery
5226+
-- step. It is complete whenever ckVtime holds no more than window variants,
5227+
-- which is the common case; when it is truncated the discovery ZADD is NX so the
5228+
-- variants it cannot rule out cost correctness nothing.
5229+
local registered = {}
5230+
for _, ckQueueName in ipairs(vtimeCandidates) do
5231+
registered[ckQueueName] = true
5232+
end
52225233
for _, ckQueueName in ipairs(vtimeCandidates) do
52235234
if dequeuedCount >= actualMaxCount then break end
52245235
tryServe(ckQueueName, true)
52255236
end
52265237
52275238
-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety).
5228-
-- Never runs when pass 1 filled the batch.
5229-
if dequeuedCount < actualMaxCount then
5230-
-- Clamp to at least 3x so pass 2 never scans fewer index variants than the old command, preserving work conservation regardless of the configured multiplier
5231-
local pass2Window = math.max(window, actualMaxCount * 3)
5232-
local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, pass2Window)
5233-
for _, ckQueueName in ipairs(ckQueues) do
5234-
if dequeuedCount >= actualMaxCount then break end
5235-
if not attempted[ckQueueName] then
5239+
-- Clamp to at least 3x so pass 2 never scans fewer index variants than the old command, preserving work conservation regardless of the configured multiplier
5240+
local pass2Window = math.max(window, actualMaxCount * 3)
5241+
local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, pass2Window)
5242+
-- NEW: pass 2 runs even when pass 1 filled the batch. A variant that reached
5243+
-- ckIndex without a ckVtime entry (queued before the flag went on, enqueued by an
5244+
-- instance that still has it off, or left behind by an expired ckVtime) is invisible
5245+
-- to pass 1, and pass 1 filling the batch off the registered variants alone kept it
5246+
-- that way until one of them drained. With no batch slot left we only register it,
5247+
-- at the floor, so the next call's pass 1 leads with it. Serving is still capped at
5248+
-- actualMaxCount, so this adds no serve the old gate would have refused.
5249+
local discovered = nil
5250+
for _, ckQueueName in ipairs(ckQueues) do
5251+
if not attempted[ckQueueName] then
5252+
if dequeuedCount < actualMaxCount then
52365253
tryServe(ckQueueName, false)
5254+
elseif not registered[ckQueueName] then
5255+
-- Collected into one variadic ZADD: discovery costs at most a single op per
5256+
-- call however many variants it registers. Skipping attempted matters:
5257+
-- tryServe GCs a drained variant out of both indexes, and re-adding it here
5258+
-- would resurrect a ckVtime entry with no ckIndex member.
5259+
if discovered == nil then discovered = {ckVtimeKey, 'NX'} end
5260+
table.insert(discovered, tostring(floor))
5261+
table.insert(discovered, ckQueueName)
52375262
end
52385263
end
52395264
end
5265+
if discovered ~= nil then
5266+
redis.call('ZADD', unpack(discovered))
5267+
end
52405268
52415269
-- NEW: persist floor and refresh TTLs
52425270
if minServableTag ~= nil and minServableTag > floor then

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,4 +1273,140 @@ describe("CK virtual-time (SFQ) dequeue", () => {
12731273
}
12741274
}
12751275
);
1276+
1277+
// Cold start: the flag is turned on over a backlog that was queued while it was
1278+
// off, so every variant is in ckIndex and none is in :ckVtime. Pass 1 can only
1279+
// see registered variants, so the cohort pass 2 happens to register on the first
1280+
// call is the only cohort pass 1 ever serves; while that cohort keeps the batch
1281+
// full, pass 2 never runs again and the rest of the backlog is unreachable until
1282+
// the cohort drains. A variant that gets no further enqueues and no nacks has no
1283+
// other route into the fair order, so the bound below is the whole guarantee.
1284+
//
1285+
// The same shape covers a mixed deploy (an instance with the flag still off
1286+
// enqueues through the non-vtime command) and a :ckVtime that expired while
1287+
// ckIndex survived.
1288+
describe("cold start over an unregistered backlog", () => {
1289+
type ColdStartShape = {
1290+
variants: number;
1291+
perVariant: number;
1292+
maxCount: number;
1293+
scanWindowMultiplier?: number;
1294+
// Calls the coldest variant (newest head, so last in the age order pass 2
1295+
// walks) may wait before its first serve.
1296+
bound: number;
1297+
};
1298+
1299+
// Enqueues the backlog with the flag OFF, then reopens the same keyspace with
1300+
// it ON and drains, recording the call each variant was first served on.
1301+
async function runColdStart(redisContainer: any, shape: ColdStartShape) {
1302+
const t0 = Date.now() - 500_000;
1303+
const cks = Array.from(
1304+
{ length: shape.variants },
1305+
(_, k) => `ck${String(k).padStart(2, "0")}`
1306+
);
1307+
1308+
const before = createQueue(redisContainer, null);
1309+
try {
1310+
for (let i = 0; i < shape.perVariant; i++) {
1311+
for (let k = 0; k < cks.length; k++) {
1312+
await before.enqueueMessage({
1313+
env: authenticatedEnvDev,
1314+
message: makeMessage({
1315+
runId: `${cks[k]}-${i}`,
1316+
concurrencyKey: cks[k],
1317+
// 1s of head-age spacing per variant, so ck00 is oldest and the
1318+
// age order never reshuffles as heads advance by 1ms per serve.
1319+
timestamp: t0 + k * 1_000 + i,
1320+
}),
1321+
workerQueue: authenticatedEnvDev.id,
1322+
skipDequeueProcessing: true,
1323+
});
1324+
}
1325+
}
1326+
} finally {
1327+
await before.quit();
1328+
}
1329+
1330+
const after = createQueue(redisContainer, {
1331+
...(shape.scanWindowMultiplier === undefined
1332+
? {}
1333+
: { scanWindowMultiplier: shape.scanWindowMultiplier }),
1334+
});
1335+
try {
1336+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName(cks[0]!));
1337+
const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(variantName(cks[0]!));
1338+
// The premise: the whole backlog is in the age index and nothing is in the
1339+
// fair order.
1340+
expect(await after.redis.zcard(ckIndexKey)).toBe(shape.variants);
1341+
expect(await after.redis.zcard(ckVtimeKey)).toBe(0);
1342+
1343+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
1344+
const total = shape.variants * shape.perVariant;
1345+
const firstServeCall = new Map<string, number>();
1346+
let served = 0;
1347+
1348+
for (let call = 0; call < total + 10 && served < total; call++) {
1349+
const messages = await after.testDequeueFromMasterQueue(
1350+
shard,
1351+
authenticatedEnvDev.id,
1352+
shape.maxCount
1353+
);
1354+
for (const m of messages) {
1355+
const ck = m.message.concurrencyKey!;
1356+
if (!firstServeCall.has(ck)) firstServeCall.set(ck, call);
1357+
served++;
1358+
// Ack immediately so env concurrency never gates a serve: the only
1359+
// thing under test is reachability.
1360+
await after.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, {
1361+
skipDequeueProcessing: true,
1362+
});
1363+
}
1364+
}
1365+
1366+
return { firstServeCall, served, total, coldest: cks[cks.length - 1]! };
1367+
} finally {
1368+
await after.quit();
1369+
}
1370+
}
1371+
1372+
// Bounds are the measured value, not headroom: the harness has no wall-clock
1373+
// wait and no randomness. The pre-fix figure in each comment is what the same
1374+
// shape did when pass 2 was gated on dequeuedCount < actualMaxCount.
1375+
const shapes: [string, ColdStartShape][] = [
1376+
// Registered cohort (5) smaller than the backlog (8), both inside the
1377+
// pass-1 window (15) and the pass-2 scan window (15). Pre-fix: call 12.
1378+
["8 variants, batch 5", { variants: 8, perVariant: 12, maxCount: 5, bound: 1 }],
1379+
// Backlog exactly fills the pass-1 window (12), so discovery has to land in
1380+
// more than one call. Pre-fix: call 16.
1381+
["12 variants, batch 4", { variants: 12, perVariant: 8, maxCount: 4, bound: 2 }],
1382+
// scanWindowMultiplier 1 puts the pass-1 window (5) below the backlog, so
1383+
// the window read can no longer tell which variants are already registered
1384+
// and discovery falls back to the NX. Pre-fix: call 12.
1385+
[
1386+
"15 variants, batch 5, narrow fair window",
1387+
{ variants: 15, perVariant: 6, maxCount: 5, scanWindowMultiplier: 1, bound: 2 },
1388+
],
1389+
];
1390+
1391+
for (const [name, shape] of shapes) {
1392+
redisTest(
1393+
`${name}: the coldest variant is served within ${shape.bound + 1} calls`,
1394+
async ({ redisContainer }) => {
1395+
const { firstServeCall, served, total, coldest } = await runColdStart(
1396+
redisContainer,
1397+
shape
1398+
);
1399+
1400+
// Work conservation: the backlog still drains completely.
1401+
expect(served).toBe(total);
1402+
expect(firstServeCall.size).toBe(shape.variants);
1403+
1404+
expect(
1405+
firstServeCall.get(coldest),
1406+
`coldest variant ${coldest} first served on call ${firstServeCall.get(coldest)}`
1407+
).toBeLessThanOrEqual(shape.bound);
1408+
}
1409+
);
1410+
}
1411+
});
12761412
});

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,12 @@ describe("CK virtual-time concurrency and op-count budget", () => {
346346
expect(off.served).toBe(cks.length * perKey);
347347
expect(on.served).toBe(cks.length * perKey);
348348

349-
// Per dequeue call the vtime path adds at worst 7 fixed ops: GET floor,
350-
// ZRANGE min, ZRANGE window, the pass-2 ZRANGEBYSCORE, SET floor,
351-
// EXISTS ckVtime, EXPIRE ckVtime — plus per serve one ZSCORE and one ZADD.
352-
const budget = dequeueCalls * (7 + 2 * maxCount);
349+
// Per dequeue call the vtime path adds at worst 8 fixed ops: GET floor,
350+
// ZRANGE min, ZRANGE window, the pass-2 ZRANGEBYSCORE, the pass-2
351+
// discovery ZADD, SET floor, EXISTS ckVtime, EXPIRE ckVtime, plus per
352+
// serve one ZSCORE and one ZADD. The discovery ZADD is variadic, so it
353+
// stays a single op however many variants one call registers.
354+
const budget = dequeueCalls * (8 + 2 * maxCount);
353355
expect(
354356
on.totalCalls,
355357
`on_total ${on.totalCalls} exceeds off_total ${off.totalCalls} + budget ${budget}`

0 commit comments

Comments
 (0)