Skip to content

Commit df8f1a9

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 a1d38ef commit df8f1a9

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
@@ -5060,7 +5060,7 @@ return __qmret(results)
50605060
// :ckVtime / :ckVtimeFloor keys hold virtual times; ckIndex and the master
50615061
// queue keep their timestamp score domain. The per-candidate serve body is a
50625062
// verbatim copy of dequeueMessagesFromCkQueueTracked's, with the marked NEW
5063-
// lines added (tag advance on serve, ZREM ckVtime on GC).
5063+
// lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1).
50645064
this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", {
50655065
numberOfKeys: 13,
50665066
lua: `
@@ -5137,7 +5137,7 @@ local attempted = {}
51375137
51385138
-- Per-candidate serve. Body is dequeueMessagesFromCkQueueTracked's per-candidate
51395139
-- block, verbatim, with the marked NEW lines added.
5140-
local function tryServe(ckQueueName)
5140+
local function tryServe(ckQueueName, mayRaiseFloor)
51415141
attempted[ckQueueName] = true
51425142
local fullQueueKey = keyPrefix .. ckQueueName
51435143
@@ -5184,8 +5184,10 @@ local function tryServe(ckQueueName)
51845184
local weight = 1
51855185
local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor)
51865186
if tag < floor then tag = floor end
5187-
-- Pass 1 walks in ascending tag order, so anything unvisited is above this.
5188-
if minServableTag == nil or tag < minServableTag then
5187+
-- Pass 1 only: it walks in ascending tag order, so anything it has not visited
5188+
-- sits above this. Pass 2 goes by message age, so its tag says nothing about the
5189+
-- entries it skipped and must not move the floor over them.
5190+
if mayRaiseFloor and (minServableTag == nil or tag < minServableTag) then
51895191
minServableTag = tag
51905192
end
51915193
redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName)
@@ -5210,9 +5212,6 @@ local function tryServe(ckQueueName)
52105212
redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW
52115213
else
52125214
redis.call('ZADD', ckIndexKey, any[2], ckQueueName)
5213-
-- Work but nothing ready (a nack backoff): not competing, so drop it from the fair
5214-
-- order rather than let it hoard credit. Rejoins at the floor when next served.
5215-
redis.call('ZREM', ckVtimeKey, ckQueueName)
52165215
end
52175216
end
52185217
end
@@ -5222,7 +5221,7 @@ end
52225221
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
52235222
for _, ckQueueName in ipairs(vtimeCandidates) do
52245223
if dequeuedCount >= actualMaxCount then break end
5225-
tryServe(ckQueueName)
5224+
tryServe(ckQueueName, true)
52265225
end
52275226
52285227
-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety).
@@ -5234,7 +5233,7 @@ if dequeuedCount < actualMaxCount then
52345233
for _, ckQueueName in ipairs(ckQueues) do
52355234
if dequeuedCount >= actualMaxCount then break end
52365235
if not attempted[ckQueueName] then
5237-
tryServe(ckQueueName)
5236+
tryServe(ckQueueName, false)
52385237
end
52395238
end
52405239
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)