Skip to content

Commit 3f4c27c

Browse files
committed
fix(run-engine): stop unready ck variants blocking the fair pass, and idle polls writing
Pass 1 now steps over a variant whose head is scheduled in the future without spending one of its window slots, so a retry storm across enough keys can no longer fill the window with variants that cannot be served and freeze the virtual-time floor. The variant stays registered and stays scanned, which is what keeps it reachable; only the budget is spared. The scan is capped at twice the window, so a wider block still degrades to pass 2's age order. A dequeue that serves nothing now persists nothing. Both things that block would write are re-derivable: minServableTag is only set inside a successful serve, and discovery only runs once the batch is full, so the floor read-repair is recomputed from ckVtime on the next call anyway. Refits the two tests whose premise these change: the freeze test now pins the residual beyond the scan cap, and the floor test pins that a zero-serve call persists nothing while the repair still lands on the next serving call.
1 parent 305d308 commit 3f4c27c

2 files changed

Lines changed: 80 additions & 30 deletions

File tree

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

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5105,7 +5105,9 @@ return __qmret(results)
51055105
// :ckVtime / :ckVtimeFloor keys hold virtual times; ckIndex and the master
51065106
// queue keep their timestamp score domain. The per-candidate serve body is a
51075107
// verbatim copy of dequeueMessagesFromCkQueueTracked's, with the marked NEW
5108-
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1).
5108+
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1,
5109+
// and the notReady report that lets pass 1 step over a future-headed variant without
5110+
// spending a window slot on it).
51095111
// Pass 2 always runs: when the batch is already full it registers the variants
51105112
// pass 1 could not see rather than serving them, which is what keeps a backlog
51115113
// queued before the flag went on from being unreachable.
@@ -5164,6 +5166,10 @@ if actualMaxCount <= 0 then
51645166
end
51655167
51665168
local window = actualMaxCount * windowMultiplier
5169+
-- Pass 1 reads further than it will spend, so a variant whose head is scheduled in the
5170+
-- future can be passed over without costing a window slot. Capped rather than unbounded:
5171+
-- a block wider than this still degrades to pass 2's age order, which is safe.
5172+
local scanLimit = window * 2
51675173
51685174
-- Floor only ever rises, by two independent routes: to the lowest tag on record (repairs
51695175
-- a floor that was lost while ckVtime survived), and to the lowest tag actually servable
@@ -5235,6 +5241,10 @@ local function tryServe(ckQueueName, mayRaiseFloor)
52355241
-- Pass 1 only: it walks in ascending tag order, so anything it has not visited
52365242
-- sits above this. Pass 2 goes by message age, so its tag says nothing about the
52375243
-- entries it skipped and must not move the floor over them.
5244+
-- Pass 1 now steps over future-headed variants below this tag, so the floor can
5245+
-- rise past one of them. That is the same forfeiture a variant at its concurrency
5246+
-- ceiling already takes: it keeps its entry, loses the sub-floor credit, and is
5247+
-- clamped up to the floor when it next becomes servable.
52385248
if mayRaiseFloor and (minServableTag == nil or tag < minServableTag) then
52395249
minServableTag = tag
52405250
end
@@ -5260,24 +5270,37 @@ local function tryServe(ckQueueName, mayRaiseFloor)
52605270
redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW
52615271
else
52625272
redis.call('ZADD', ckIndexKey, any[2], ckQueueName)
5273+
-- NEW: backlog, but the head is scheduled later, so nothing here is servable this
5274+
-- call. The readiness is already known from the ZRANGEBYSCORE above, so reporting
5275+
-- it costs nothing and lets pass 1 decline to spend a window slot on it.
5276+
return 'notReady'
52635277
end
52645278
end
52655279
end
52665280
end
52675281
52685282
-- Pass 1: fair order (lowest virtual start tag first)
5269-
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
5270-
-- NEW: the window read doubles as a free membership set for pass 2's discovery
5271-
-- step. It is complete whenever ckVtime holds no more than window variants,
5283+
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, scanLimit - 1)
5284+
-- NEW: the scan read doubles as a free membership set for pass 2's discovery
5285+
-- step. It is complete whenever ckVtime holds no more than scanLimit variants,
52725286
-- which is the common case; when it is truncated the discovery ZADD is NX so the
52735287
-- variants it cannot rule out cost correctness nothing.
52745288
local registered = {}
52755289
for _, ckQueueName in ipairs(vtimeCandidates) do
52765290
registered[ckQueueName] = true
52775291
end
5292+
-- NEW: a variant whose head is scheduled in the future stays registered and stays
5293+
-- scanned, it just does not spend one of the window's slots. Without this a retry storm
5294+
-- across enough keys fills the window with variants that cannot be served, pass 1 serves
5295+
-- nothing, and because minServableTag is the only route that can lift the floor over a
5296+
-- stale low tag, the floor freezes for as long as the storm lasts. Every other outcome
5297+
-- (served, gated on concurrency, drained, reaped) still spends a slot, as before.
5298+
local windowBudget = window
52785299
for _, ckQueueName in ipairs(vtimeCandidates) do
5279-
if dequeuedCount >= actualMaxCount then break end
5280-
tryServe(ckQueueName, true)
5300+
if dequeuedCount >= actualMaxCount or windowBudget <= 0 then break end
5301+
if tryServe(ckQueueName, true) ~= 'notReady' then
5302+
windowBudget = windowBudget - 1
5303+
end
52815304
end
52825305
52835306
-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety).
@@ -5311,13 +5334,21 @@ if discovered ~= nil then
53115334
redis.call('ZADD', unpack(discovered))
53125335
end
53135336
5314-
-- NEW: persist floor and refresh TTLs
5315-
if minServableTag ~= nil and minServableTag > floor then
5316-
floor = minServableTag
5317-
end
5318-
redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl)
5319-
if redis.call('EXISTS', ckVtimeKey) == 1 then
5320-
redis.call('EXPIRE', ckVtimeKey, stateTtl)
5337+
-- NEW: persist floor and refresh TTLs. A call that served nothing writes nothing: the two
5338+
-- things this block would persist are both re-derivable, since minServableTag is only set
5339+
-- inside a successful serve and pass 2's discovery only runs once the batch is full, so
5340+
-- the only floor movement on a zero-serve call is the min-tag read-repair, which is
5341+
-- recomputed from ckVtime at the top of every call anyway. Idle polling a queue whose work
5342+
-- is all future-scheduled or concurrency-gated therefore costs no writes, matching the old
5343+
-- command's early return.
5344+
if dequeuedCount > 0 then
5345+
if minServableTag ~= nil and minServableTag > floor then
5346+
floor = minServableTag
5347+
end
5348+
redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl)
5349+
if redis.call('EXISTS', ckVtimeKey) == 1 then
5350+
redis.call('EXPIRE', ckVtimeKey, stateTtl)
5351+
end
53215352
end
53225353
53235354
-- Rebalance master queue (ckIndex keeps its timestamp domain)

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

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,7 @@ describe("CK virtual-time (SFQ) dequeue", () => {
301301
prevFloor = floor;
302302
}
303303

304-
// Final settle: gate both variants (limit 1 + an occupied slot) so nothing
305-
// is served (no advance), and the floor read-repairs up to the current min tag.
304+
// Final settle: gate both variants (limit 1 + an occupied slot) so nothing is served.
306305
await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1);
307306
for (const ck of cks) {
308307
await queue.redis.sadd(
@@ -312,12 +311,28 @@ describe("CK virtual-time (SFQ) dequeue", () => {
312311
}
313312
await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2);
314313

315-
const floorAfter = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0");
316-
expect(floorAfter).toBeGreaterThanOrEqual(prevFloor);
317-
318314
const minEntry = await queue.redis.zrange(ckVtimeKey, 0, 0, "WITHSCORES");
319315
const minTag = Number(minEntry[1]);
320-
expect(floorAfter).toBe(minTag);
316+
expect(minTag).toBeGreaterThan(prevFloor);
317+
318+
// A call that serves nothing persists nothing. The read-repair to the min tag is
319+
// recomputed from ckVtime at the top of every call, so leaving it unwritten here
320+
// costs nothing and keeps an idle poll free of writes.
321+
const floorWhileGated = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0");
322+
expect(floorWhileGated).toBe(prevFloor);
323+
324+
// Free a slot: the next serving call persists the repaired floor, so the repair
325+
// itself is intact, it is only the write that waits for a serve.
326+
for (const ck of cks) {
327+
await queue.redis.srem(
328+
testOptions.keys.queueCurrentConcurrencyKeyFromQueue(variantName(ck)),
329+
"occupant"
330+
);
331+
}
332+
await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2);
333+
334+
const floorAfter = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0");
335+
expect(floorAfter).toBeGreaterThanOrEqual(minTag);
321336
expect(floorAfter).toBeGreaterThan(10);
322337
} finally {
323338
await queue.quit();
@@ -917,14 +932,14 @@ describe("CK virtual-time (SFQ) dequeue", () => {
917932
);
918933

919934
redisTest(
920-
"a pass-1 window filled entirely with unservable variants degrades to age order, and recovers",
935+
"a scan filled entirely with unservable variants degrades to age order, and recovers",
921936
async ({ redisContainer }) => {
922-
// The single-stalled case above is handled by the minServableTag route: a servable
923-
// variant inside the window raises the floor over the stalled tag. That route needs
924-
// pass 1 to serve something. With every window slot (actualMaxCount * multiplier,
925-
// 3 here) held by unservable variants, pass 1 serves nothing, minServableTag stays
926-
// nil, and the min-tag route is pinned by those same stalled tags, so the floor
927-
// cannot move for as long as the block lasts.
937+
// Pass 1 steps over a future-headed variant without spending a window slot, so the
938+
// window alone can no longer be blocked. The scan behind it is capped though, at
939+
// scanLimit = 2 * window (6 here), and this is the residual: with every scanned
940+
// position held by an unservable variant, pass 1 still serves nothing, minServableTag
941+
// stays nil, and the min-tag route is pinned by those same stalled tags, so the floor
942+
// cannot move until the block thins out.
928943
//
929944
// Two properties of that state are worth pinning down. It stays work-conserving:
930945
// pass 2 keeps serving in age order, which is the flag-off behaviour, so a full
@@ -937,9 +952,9 @@ describe("CK virtual-time (SFQ) dequeue", () => {
937952
const t0 = Date.now() - 100_000;
938953

939954
// Names decide tie order at equal tags, and the point of the fixture is that the
940-
// blockers hold every window slot: a0/a1/a2 sort below zbusy, so the window read
941-
// returns only them.
942-
const blockers = ["a0", "a1", "a2"];
955+
// blockers hold every scanned position: a0..a5 sort below zbusy, so the scan read
956+
// returns only them and zbusy is never reached.
957+
const blockers = ["a0", "a1", "a2", "a3", "a4", "a5"];
943958
for (const ck of blockers) {
944959
await queue.enqueueMessage({
945960
env: authenticatedEnvDev,
@@ -1035,8 +1050,12 @@ describe("CK virtual-time (SFQ) dequeue", () => {
10351050
}
10361051
}
10371052

1053+
// The unblocked cohort plus the arrival is 7 keys sitting at the floor against an
1054+
// incumbent on FREEZE_CALLS, so levelling up costs 7 * FREEZE_CALLS serves before
1055+
// the incumbent competes again. Drain comfortably past that rather than right on
1056+
// the boundary, or the bound below is measuring the cutoff instead of the debt.
10381057
const servedAfter: Record<string, number> = {};
1039-
for (let call = 0; call < 60; call++) {
1058+
for (let call = 0; call < (blockers.length + 1) * FREEZE_CALLS + 60; call++) {
10401059
for (const ck of await drainOne()) {
10411060
servedAfter[ck] = (servedAfter[ck] ?? 0) + 1;
10421061
}

0 commit comments

Comments
 (0)