Skip to content

Commit b932442

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 819e021 commit b932442

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
@@ -5163,7 +5163,7 @@ return __qmret(results)
51635163
// :ckVtime / :ckVtimeFloor keys hold virtual times; ckIndex and the master
51645164
// queue keep their timestamp score domain. The per-candidate serve body is a
51655165
// verbatim copy of dequeueMessagesFromCkQueueTracked's, with the marked NEW
5166-
// lines added (tag advance on serve, ZREM ckVtime on GC).
5166+
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1).
51675167
this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", {
51685168
numberOfKeys: 13,
51695169
lua: `
@@ -5240,7 +5240,7 @@ local attempted = {}
52405240
52415241
-- Per-candidate serve. Body is dequeueMessagesFromCkQueueTracked's per-candidate
52425242
-- block, verbatim, with the marked NEW lines added.
5243-
local function tryServe(ckQueueName)
5243+
local function tryServe(ckQueueName, mayRaiseFloor)
52445244
attempted[ckQueueName] = true
52455245
local fullQueueKey = keyPrefix .. ckQueueName
52465246
@@ -5287,8 +5287,10 @@ local function tryServe(ckQueueName)
52875287
local weight = 1
52885288
local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor)
52895289
if tag < floor then tag = floor end
5290-
-- Pass 1 walks in ascending tag order, so anything unvisited is above this.
5291-
if minServableTag == nil or tag < minServableTag then
5290+
-- Pass 1 only: it walks in ascending tag order, so anything it has not visited
5291+
-- sits above this. Pass 2 goes by message age, so its tag says nothing about the
5292+
-- entries it skipped and must not move the floor over them.
5293+
if mayRaiseFloor and (minServableTag == nil or tag < minServableTag) then
52925294
minServableTag = tag
52935295
end
52945296
redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName)
@@ -5313,9 +5315,6 @@ local function tryServe(ckQueueName)
53135315
redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW
53145316
else
53155317
redis.call('ZADD', ckIndexKey, any[2], ckQueueName)
5316-
-- Work but nothing ready (a nack backoff): not competing, so drop it from the fair
5317-
-- order rather than let it hoard credit. Rejoins at the floor when next served.
5318-
redis.call('ZREM', ckVtimeKey, ckQueueName)
53195318
end
53205319
end
53215320
end
@@ -5325,7 +5324,7 @@ end
53255324
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
53265325
for _, ckQueueName in ipairs(vtimeCandidates) do
53275326
if dequeuedCount >= actualMaxCount then break end
5328-
tryServe(ckQueueName)
5327+
tryServe(ckQueueName, true)
53295328
end
53305329
53315330
-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety).
@@ -5337,7 +5336,7 @@ if dequeuedCount < actualMaxCount then
53375336
for _, ckQueueName in ipairs(ckQueues) do
53385337
if dequeuedCount >= actualMaxCount then break end
53395338
if not attempted[ckQueueName] then
5340-
tryServe(ckQueueName)
5339+
tryServe(ckQueueName, false)
53415340
end
53425341
end
53435342
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)