Skip to content

Commit b5f81b3

Browse files
committed
fix(run-engine): keep unservable ck variants in the fair order
Reverts the de-registration added earlier on this branch. Dropping a variant from ckVtime when it had work but nothing ready stranded it: pass 1 is the only reader of ckVtime, and pass 2 is skipped whenever pass 1 fills the batch, so on a queue busy enough to keep filling it the variant was never looked at again. A blind review measured one sitting unserved for over two thousand calls after its head became ready, and every nack backoff produces exactly that shape, so a single steady key could hold up another key's retries indefinitely. That is worse than the floor pinning it was meant to address. The floor advance from servable variants handles both cases on its own, so the de-registration bought nothing. It now also only takes its bound from pass 1. Pass 2 picks candidates by message age, so its tag implies nothing about the entries it skipped, and letting it move the floor stepped over registered variants that were servable and simply never visited, confiscating their credit on the next serve. Adds the regression test the earlier tests were missing. They proved the variant was evicted but never that it came back, which is the half that was broken.
1 parent 97f4fa9 commit b5f81b3

2 files changed

Lines changed: 95 additions & 34 deletions

File tree

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5105,7 +5105,7 @@ 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).
5108+
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1).
51095109
this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", {
51105110
numberOfKeys: 13,
51115111
lua: `
@@ -5182,7 +5182,7 @@ local attempted = {}
51825182
51835183
-- Per-candidate serve. Body is dequeueMessagesFromCkQueueTracked's per-candidate
51845184
-- block, verbatim, with the marked NEW lines added.
5185-
local function tryServe(ckQueueName)
5185+
local function tryServe(ckQueueName, mayRaiseFloor)
51865186
attempted[ckQueueName] = true
51875187
local fullQueueKey = keyPrefix .. ckQueueName
51885188
@@ -5229,8 +5229,10 @@ local function tryServe(ckQueueName)
52295229
local weight = 1
52305230
local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor)
52315231
if tag < floor then tag = floor end
5232-
-- Pass 1 walks in ascending tag order, so anything unvisited is above this.
5233-
if minServableTag == nil or tag < minServableTag then
5232+
-- Pass 1 only: it walks in ascending tag order, so anything it has not visited
5233+
-- sits above this. Pass 2 goes by message age, so its tag says nothing about the
5234+
-- entries it skipped and must not move the floor over them.
5235+
if mayRaiseFloor and (minServableTag == nil or tag < minServableTag) then
52345236
minServableTag = tag
52355237
end
52365238
redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName)
@@ -5255,9 +5257,6 @@ local function tryServe(ckQueueName)
52555257
redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW
52565258
else
52575259
redis.call('ZADD', ckIndexKey, any[2], ckQueueName)
5258-
-- Work but nothing ready (a nack backoff): not competing, so drop it from the fair
5259-
-- order rather than let it hoard credit. Rejoins at the floor when next served.
5260-
redis.call('ZREM', ckVtimeKey, ckQueueName)
52615260
end
52625261
end
52635262
end
@@ -5267,7 +5266,7 @@ end
52675266
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
52685267
for _, ckQueueName in ipairs(vtimeCandidates) do
52695268
if dequeuedCount >= actualMaxCount then break end
5270-
tryServe(ckQueueName)
5269+
tryServe(ckQueueName, true)
52715270
end
52725271
52735272
-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety).
@@ -5279,7 +5278,7 @@ if dequeuedCount < actualMaxCount then
52795278
for _, ckQueueName in ipairs(ckQueues) do
52805279
if dequeuedCount >= actualMaxCount then break end
52815280
if not attempted[ckQueueName] then
5282-
tryServe(ckQueueName)
5281+
tryServe(ckQueueName, false)
52835282
end
52845283
end
52855284
end

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

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -661,48 +661,110 @@ describe("CK virtual-time (SFQ) dequeue", () => {
661661
}
662662
);
663663

664+
redisTest("future-scheduled variants are skipped without advance", async ({ redisContainer }) => {
665+
const queue = createQueue(redisContainer);
666+
try {
667+
const t0 = Date.now() - 100_000;
668+
669+
// a normal ready variant so the :ck:* wildcard is selected from the master queue
670+
await queue.enqueueMessage({
671+
env: authenticatedEnvDev,
672+
message: makeMessage({ runId: "r-now", concurrencyKey: "now", timestamp: t0 }),
673+
workerQueue: authenticatedEnvDev.id,
674+
skipDequeueProcessing: true,
675+
});
676+
// a future-scheduled variant
677+
await queue.enqueueMessage({
678+
env: authenticatedEnvDev,
679+
message: makeMessage({
680+
runId: "r-future",
681+
concurrencyKey: "future",
682+
timestamp: Date.now() + 60_000,
683+
}),
684+
workerQueue: authenticatedEnvDev.id,
685+
skipDequeueProcessing: true,
686+
});
687+
688+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("now"));
689+
const futureVariant = variantName("future");
690+
await queue.redis.zadd(ckVtimeKey, 0, variantName("now"), 5, futureVariant);
691+
692+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
693+
const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
694+
695+
expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false);
696+
697+
// Not served, so not charged a quantum. It stays registered: pass 1 is the only
698+
// path that can reach it, so de-registering it would strand it while pass 1 is
699+
// busy. It no longer holds the floor down, which the floor tests cover.
700+
const futureTag = Number(await queue.redis.zscore(ckVtimeKey, futureVariant));
701+
expect(futureTag).toBe(5);
702+
} finally {
703+
await queue.quit();
704+
}
705+
});
706+
664707
redisTest(
665-
"future-scheduled variants are skipped, not advanced, and de-registered",
708+
"a variant whose backoff elapses is served even while pass 1 stays full",
709+
{ timeout: 120_000 },
666710
async ({ redisContainer }) => {
711+
// Pass 1 is the only path that reads ckVtime, and pass 2 is skipped whenever pass 1
712+
// fills the batch. Dropping a not-ready variant from ckVtime therefore stranded it
713+
// for as long as any other key kept the batch full: measured at over 2000 calls.
667714
const queue = createQueue(redisContainer);
668715
try {
669716
const t0 = Date.now() - 100_000;
670717

671-
// a normal ready variant so the :ck:* wildcard is selected from the master queue
672-
await queue.enqueueMessage({
673-
env: authenticatedEnvDev,
674-
message: makeMessage({ runId: "r-now", concurrencyKey: "now", timestamp: t0 }),
675-
workerQueue: authenticatedEnvDev.id,
676-
skipDequeueProcessing: true,
677-
});
678-
// a future-scheduled variant
718+
for (let k = 0; k < 3; k++) {
719+
for (let i = 0; i < 40; i++) {
720+
await queue.enqueueMessage({
721+
env: authenticatedEnvDev,
722+
message: makeMessage({
723+
runId: `b${k}-${i}`,
724+
concurrencyKey: `b${k}`,
725+
timestamp: t0 + i,
726+
}),
727+
workerQueue: authenticatedEnvDev.id,
728+
skipDequeueProcessing: true,
729+
});
730+
}
731+
}
679732
await queue.enqueueMessage({
680733
env: authenticatedEnvDev,
681734
message: makeMessage({
682-
runId: "r-future",
683-
concurrencyKey: "future",
684-
timestamp: Date.now() + 60_000,
735+
runId: "stalled-1",
736+
concurrencyKey: "stalled",
737+
timestamp: Date.now() + 400,
685738
}),
686739
workerQueue: authenticatedEnvDev.id,
687740
skipDequeueProcessing: true,
688741
});
689742

690-
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("now"));
691-
const futureVariant = variantName("future");
692-
await queue.redis.zadd(ckVtimeKey, 0, variantName("now"), 5, futureVariant);
693-
694743
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
695-
const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
744+
const drain = async (calls: number) => {
745+
let servedStalled = false;
746+
for (let call = 0; call < calls; call++) {
747+
const messages = await queue.testDequeueFromMasterQueue(
748+
shard,
749+
authenticatedEnvDev.id,
750+
1
751+
);
752+
for (const m of messages) {
753+
if (m.message.concurrencyKey === "stalled") servedStalled = true;
754+
await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, {
755+
skipDequeueProcessing: true,
756+
});
757+
}
758+
}
759+
return servedStalled;
760+
};
696761

697-
expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false);
762+
// Let the incumbents advance so the stalled variant holds the lowest tag, which is
763+
// what pulls it into the pass-1 window and onto the skip path.
764+
await drain(6);
765+
await new Promise((resolve) => setTimeout(resolve, 700));
698766

699-
// Not served, so never charged a quantum: its tag is not advanced past the 5 it
700-
// was seeded with. It is de-registered instead, because a variant with no ready
701-
// work is not competing and must not hold the floor down (a pinned floor is what
702-
// let a later arrival register underneath the established keys and take every
703-
// pass-1 slot). It rejoins at the floor of the day once it has ready work.
704-
const futureTag = await queue.redis.zscore(ckVtimeKey, futureVariant);
705-
expect(futureTag).toBeNull();
767+
expect(await drain(60)).toBe(true);
706768
} finally {
707769
await queue.quit();
708770
}

0 commit comments

Comments
 (0)