Skip to content

Commit b82f285

Browse files
committed
fix(run-engine): stop an unservable variant pinning the ck virtual-time floor
A concurrency-key variant that has queued work but nothing ready yet, which is what every nack with a retry backoff produces, stayed registered in ckVtime holding its old low tag. The floor is the lowest stored tag, so it froze there while the keys actually being served advanced. New keys register at the floor, so a key that arrived later started well below the established ones and won every pass-1 slot until it caught up, which is the starvation the feature is meant to remove. The dequeue path now de-registers a variant when it has work but none of it is ready, alongside the existing GC for variants with no work at all. It stays in ckIndex, so pass 2 still serves it in age order once its head is ready, and it rejoins the fair order at the current floor on its next enqueue, nack or serve. Idle keys no longer hoard priority credit either. Measured with a control against a treatment on the real dequeue path: with one future-headed variant present the floor stayed at 0 while served keys reached 25, and a newcomer took 20 of the next 20 serves. With the fix the same run matches the control, newcomer 5 of 20. Reported by Devin on #4367.
1 parent f2dcdba commit b82f285

2 files changed

Lines changed: 124 additions & 33 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5202,6 +5202,13 @@ local function tryServe(ckQueueName)
52025202
redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW
52035203
else
52045204
redis.call('ZADD', ckIndexKey, any[2], ckQueueName)
5205+
-- The variant has work but none of it is ready yet (a nack backoff, say), so it
5206+
-- is not competing for service and must not hold the floor down. While it sat in
5207+
-- ckVtime its low tag pinned the floor, and new keys register at the floor, so a
5208+
-- key arriving later started far below the established ones and took every pass-1
5209+
-- slot until it caught up. It re-registers at the floor of the day on its next
5210+
-- enqueue/nack, or when pass 2 serves it after its head becomes ready.
5211+
redis.call('ZREM', ckVtimeKey, ckQueueName)
52055212
end
52065213
end
52075214
end

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

Lines changed: 117 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -661,45 +661,129 @@ 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;
664+
redisTest(
665+
"future-scheduled variants are skipped, not advanced, and de-registered",
666+
async ({ redisContainer }) => {
667+
const queue = createQueue(redisContainer);
668+
try {
669+
const t0 = Date.now() - 100_000;
668670

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-
});
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
679+
await queue.enqueueMessage({
680+
env: authenticatedEnvDev,
681+
message: makeMessage({
682+
runId: "r-future",
683+
concurrencyKey: "future",
684+
timestamp: Date.now() + 60_000,
685+
}),
686+
workerQueue: authenticatedEnvDev.id,
687+
skipDequeueProcessing: true,
688+
});
687689

688-
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("now"));
689-
const futureVariant = variantName("future");
690-
await queue.redis.zadd(ckVtimeKey, 0, variantName("now"), 5, futureVariant);
690+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("now"));
691+
const futureVariant = variantName("future");
692+
await queue.redis.zadd(ckVtimeKey, 0, variantName("now"), 5, futureVariant);
691693

692-
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
693-
const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
694+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
695+
const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10);
694696

695-
expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false);
697+
expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false);
696698

697-
const futureTag = Number(await queue.redis.zscore(ckVtimeKey, futureVariant));
698-
expect(futureTag).toBe(5);
699-
} finally {
700-
await queue.quit();
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();
706+
} finally {
707+
await queue.quit();
708+
}
701709
}
702-
});
710+
);
711+
712+
redisTest(
713+
"an unservable variant does not pin the floor for later arrivals",
714+
async ({ redisContainer }) => {
715+
// Regression: a variant with work but nothing ready (a nack backoff is the common
716+
// case) used to sit in ckVtime holding the lowest tag. The floor is the minimum
717+
// stored tag, so it froze at that value while served keys advanced, and because new
718+
// keys register at the floor, a key arriving later started far below the established
719+
// ones and won every pass-1 slot until it caught up. That is the starvation this
720+
// feature exists to prevent, inverted.
721+
const queue = createQueue(redisContainer);
722+
try {
723+
const t0 = Date.now() - 100_000;
724+
725+
// Stalled: registered on enqueue, but its head never becomes ready during the test.
726+
await queue.enqueueMessage({
727+
env: authenticatedEnvDev,
728+
message: makeMessage({
729+
runId: "r-stalled",
730+
concurrencyKey: "stalled",
731+
timestamp: Date.now() + 60 * 60 * 1000,
732+
}),
733+
workerQueue: authenticatedEnvDev.id,
734+
skipDequeueProcessing: true,
735+
});
736+
737+
for (let i = 0; i < 12; i++) {
738+
await queue.enqueueMessage({
739+
env: authenticatedEnvDev,
740+
message: makeMessage({
741+
runId: `r-busy-${i}`,
742+
concurrencyKey: "busy",
743+
timestamp: t0 + i,
744+
}),
745+
workerQueue: authenticatedEnvDev.id,
746+
skipDequeueProcessing: true,
747+
});
748+
}
749+
750+
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
751+
for (let call = 0; call < 6; call++) {
752+
const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1);
753+
for (const m of messages) {
754+
await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, {
755+
skipDequeueProcessing: true,
756+
});
757+
}
758+
}
759+
760+
const busyVariant = variantName("busy");
761+
const floorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(busyVariant);
762+
const floor = Number((await queue.redis.get(floorKey)) ?? "0");
763+
764+
// The floor tracked the key that was actually being served.
765+
expect(floor).toBeGreaterThan(0);
766+
767+
// A key arriving now joins level with the established keys rather than underneath
768+
// them, so it gets its turn instead of monopolising the fair pass.
769+
await queue.enqueueMessage({
770+
env: authenticatedEnvDev,
771+
message: makeMessage({ runId: "r-newcomer", concurrencyKey: "newcomer", timestamp: t0 }),
772+
workerQueue: authenticatedEnvDev.id,
773+
skipDequeueProcessing: true,
774+
});
775+
776+
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(busyVariant);
777+
const newcomerTag = Number(await queue.redis.zscore(ckVtimeKey, variantName("newcomer")));
778+
const busyTag = Number(await queue.redis.zscore(ckVtimeKey, busyVariant));
779+
780+
expect(newcomerTag).toBe(floor);
781+
expect(busyTag - newcomerTag).toBeLessThanOrEqual(1);
782+
} finally {
783+
await queue.quit();
784+
}
785+
}
786+
);
703787

704788
redisTest(
705789
"enqueue registers the variant at the current floor with NX",

0 commit comments

Comments
 (0)