From 55a5d9cbfd92a9e04592cac213d4b8c7b1b59c6b Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:34:35 +0100 Subject: [PATCH 01/17] feat(run-engine): concurrency-key virtual-time key builders Task 1 of the CK virtual-time (SFQ) scheduling plan. Adds ckVtimeKeyFromQueue and ckVtimeFloorKeyFromQueue (reusing the same base-queue normalisation as ckIndexKeyFromQueue, so :ck:* and :ck: map to one base key), the RunQueueKeyProducer interface signatures, and byte-exact key tests. No runtime behaviour yet. --- .../run-engine/src/run-queue/keyProducer.ts | 10 ++++++++++ .../src/run-queue/tests/keyProducer.test.ts | 15 +++++++++++++++ .../run-engine/src/run-queue/types.ts | 2 ++ 3 files changed, 27 insertions(+) diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index 3be79a4fabc..4fbe28818be 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -22,6 +22,8 @@ const constants = { MASTER_QUEUE_PART: "masterQueue", WORKER_QUEUE_PART: "workerQueue", CK_INDEX_PART: "ckIndex", + CK_VTIME_PART: "ckVtime", + CK_VTIME_FLOOR_PART: "ckVtimeFloor", LENGTH_COUNTER_PART: "lengthCounter", RUNNING_COUNTER_PART: "runningCounter", } as const; @@ -317,6 +319,14 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return `${baseQueue}:${constants.CK_INDEX_PART}`; } + ckVtimeKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_PART}`; + } + + ckVtimeFloorKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_FLOOR_PART}`; + } + baseQueueKeyFromQueue(queue: string): string { return queue.replace(/:ck:.+$/, ""); } diff --git a/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts index 3e31085d678..8fcf5e079c3 100644 --- a/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts @@ -432,4 +432,19 @@ describe("KeyProducer", () => { "{org:o1234}:proj:p1234:env:e1234:queue:task/foo:ck:*" ); }); + + it("produces ckVtime keys from a CK variant queue name", () => { + const keys = new RunQueueFullKeyProducer(); + const q = "{org:o1}:proj:p1:env:e1:queue:task/my-task:ck:tenant-a"; + expect(keys.ckVtimeKeyFromQueue(q)).toBe( + "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtime" + ); + expect(keys.ckVtimeFloorKeyFromQueue(q)).toBe( + "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtimeFloor" + ); + // ck wildcard and base-queue inputs normalise the same way + expect(keys.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe( + keys.ckVtimeKeyFromQueue(q) + ); + }); }); diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 0905f3971de..a98051e76a7 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -129,6 +129,8 @@ export interface RunQueueKeyProducer { // CK index methods ckIndexKeyFromQueue(queue: string): string; + ckVtimeKeyFromQueue(queue: string): string; + ckVtimeFloorKeyFromQueue(queue: string): string; baseQueueKeyFromQueue(queue: string): string; isCkWildcard(queue: string): boolean; toCkWildcard(queue: string): string; From 00714b58701e7fde159d187e251d0d10c9fec8e0 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:38:53 +0100 Subject: [PATCH 02/17] feat(run-engine): ckVirtualTimeScheduling options flag Task 2. Adds the off-by-default ckVirtualTimeScheduling option (enabled, quantum, scanWindowMultiplier, stateTtlSeconds) to RunQueueOptions and resolves it into private constructor fields for later tasks to read. No behaviour wired yet. --- .../run-engine/src/run-queue/index.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index a0571206538..2c85cd9d6e1 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -118,6 +118,21 @@ export type RunQueueOptions = { /** Visibility timeout for TTL worker jobs (ms, default: 30000) */ visibilityTimeoutMs?: number; }; + /** + * Fair (virtual-time / SFQ) ordering across concurrency-key variants of a + * base queue. Off by default; when off, the exact pre-existing Lua commands + * run and no vtime keys are created. See docs/superpowers/plans/ + * 2026-07-23-ck-virtual-time-scheduling-plan.md. + */ + ckVirtualTimeScheduling?: { + enabled: boolean; + /** Virtual-time advance per serve (dimensionless). Default 1. */ + quantum?: number; + /** Pass-1 candidate window = actualMaxCount * this. Default 3. */ + scanWindowMultiplier?: number; + /** EXPIRE applied to ckVtime/ckVtimeFloor on every write. Default 86400. */ + stateTtlSeconds?: number; + }; }; export interface ConcurrencySweeperCallback { @@ -202,10 +217,18 @@ export class RunQueue { private _observableWorkerQueues: Set = new Set(); private _meter: Meter; private _queueCooloffStates: Map = new Map(); + readonly #ckVtimeEnabled: boolean; + readonly #ckVtimeQuantum: number; + readonly #ckVtimeWindowMultiplier: number; + readonly #ckVtimeStateTtl: number; constructor(public readonly options: RunQueueOptions) { this.shardCount = options.shardCount ?? 2; this.counterTtlSeconds = options.counterTtlSeconds ?? 86400; + this.#ckVtimeEnabled = options.ckVirtualTimeScheduling?.enabled ?? false; + this.#ckVtimeQuantum = options.ckVirtualTimeScheduling?.quantum ?? 1; + this.#ckVtimeWindowMultiplier = options.ckVirtualTimeScheduling?.scanWindowMultiplier ?? 3; + this.#ckVtimeStateTtl = options.ckVirtualTimeScheduling?.stateTtlSeconds ?? 86400; this.retryOptions = options.retryOptions ?? defaultRetrySettings; this.redis = createRedisClient(options.redis, { onError: (error) => { From 1acbf902596cacf73979c510145d242df87ed096 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 01:00:18 +0100 Subject: [PATCH 03/17] feat(run-engine): two-pass virtual-time CK dequeue command Task 3 (core). Adds dequeueMessagesFromCkQueueVtimeTracked: a new flag-selected Lua command that orders concurrency-key variants by SFQ virtual time. Pass 1 takes candidates from a new :ckVtime ZSET by lowest tag, runs today's per-candidate serve body verbatim (per-key gate, TTL/normal/stale branches, counters, ckIndex rebalance), advances the served variant's tag within the batch, and GCs empty variants from ckVtime too. Pass 2 fills in today's age order (window clamped to >= maxCount*3) so the command is a strict superset of today: work-conserving and mixed-deploy safe. Monotonic floor in :ckVtimeFloor; new variants enter at the floor. The old tracked/untracked scripts are byte-identical and only run when the flag is off. 10 behaviour tests incl. a discriminating vtime-beats-age-order case. --- .../run-engine/src/run-queue/index.ts | 287 ++++++++- .../src/run-queue/tests/ckVtime.test.ts | 586 ++++++++++++++++++ 2 files changed, 853 insertions(+), 20 deletions(-) create mode 100644 internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 2c85cd9d6e1..45efa14cc54 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -2226,26 +2226,56 @@ export class RunQueue { const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(ckWildcardQueue); - const result = await this.redis.dequeueMessagesFromCkQueueTracked( - //keys - ckIndexKey, - queueConcurrencyLimitKey, - envConcurrencyLimitKey, - envConcurrencyLimitBurstFactorKey, - envCurrentConcurrencyKey, - messageKeyPrefix, - envQueueKey, - masterQueueKey, - ttlQueueKey, - lengthCounterKey, - //args - ckWildcardQueue, - String(Date.now()), - String(this.options.defaultEnvConcurrency), - String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), - this.options.redis.keyPrefix ?? "", - String(maxCount) - ); + if (this.#ckVtimeEnabled) { + span.setAttribute("ck_vtime_enabled", true); + } + + const result = this.#ckVtimeEnabled + ? await this.redis.dequeueMessagesFromCkQueueVtimeTracked( + //keys + ckIndexKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + envCurrentConcurrencyKey, + messageKeyPrefix, + envQueueKey, + masterQueueKey, + ttlQueueKey, + lengthCounterKey, + this.keys.ckVtimeKeyFromQueue(ckWildcardQueue), + this.keys.ckVtimeFloorKeyFromQueue(ckWildcardQueue), + //args + ckWildcardQueue, + String(Date.now()), + String(this.options.defaultEnvConcurrency), + String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), + this.options.redis.keyPrefix ?? "", + String(maxCount), + String(this.#ckVtimeQuantum), + String(this.#ckVtimeWindowMultiplier), + String(this.#ckVtimeStateTtl) + ) + : await this.redis.dequeueMessagesFromCkQueueTracked( + //keys + ckIndexKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + envCurrentConcurrencyKey, + messageKeyPrefix, + envQueueKey, + masterQueueKey, + ttlQueueKey, + lengthCounterKey, + //args + ckWildcardQueue, + String(Date.now()), + String(this.options.defaultEnvConcurrency), + String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), + this.options.redis.keyPrefix ?? "", + String(maxCount) + ); if (!result) { span.setAttribute("message_count", 0); @@ -4304,6 +4334,198 @@ else redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) end +return results + `, + }); + + // Virtual-time (SFQ) variant of dequeueMessagesFromCkQueueTracked. + // Flag-selected via ckVirtualTimeScheduling. Orders concurrency-key variants + // by virtual-time tag (ckVtime ZSET) instead of head timestamp, layered under + // the existing per-variant concurrency gate. Two passes: pass 1 serves in fair + // (lowest-tag) order; pass 2 fills the batch + discovers unregistered variants + // in the existing age order (work conservation, mixed-deploy safety). Only the + // :ckVtime / :ckVtimeFloor keys hold virtual times; ckIndex and the master + // queue keep their timestamp score domain. The per-candidate serve body is a + // verbatim copy of dequeueMessagesFromCkQueueTracked's, with the marked NEW + // lines added (tag advance on serve, ZREM ckVtime on GC). + this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", { + numberOfKeys: 12, + lua: ` +local ckIndexKey = KEYS[1] +local queueConcurrencyLimitKey = KEYS[2] +local envConcurrencyLimitKey = KEYS[3] +local envConcurrencyLimitBurstFactorKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local messageKeyPrefix = KEYS[6] +local envQueueKey = KEYS[7] +local masterQueueKey = KEYS[8] +local ttlQueueKey = KEYS[9] +local lengthCounterKey = KEYS[10] +local ckVtimeKey = KEYS[11] +local ckVtimeFloorKey = KEYS[12] + +local ckWildcardName = ARGV[1] +local currentTime = tonumber(ARGV[2]) +local defaultEnvConcurrencyLimit = ARGV[3] +local defaultEnvConcurrencyBurstFactor = ARGV[4] +local keyPrefix = ARGV[5] +local maxCount = tonumber(ARGV[6] or '1') +local quantum = tonumber(ARGV[7] or '1') +local windowMultiplier = tonumber(ARGV[8] or '3') +local stateTtl = tonumber(ARGV[9] or '86400') + +local function decrLengthCounter() + if tonumber(redis.call('GET', lengthCounterKey) or '0') > 0 then + redis.call('DECR', lengthCounterKey) + end +end + +-- Check env concurrency +local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') +local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) +local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor) + +if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then + return nil +end + +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) + +local envAvailableCapacity = envConcurrencyLimitWithBurstFactor - envCurrentConcurrency +local actualMaxCount = math.min(maxCount, envAvailableCapacity) + +if actualMaxCount <= 0 then + return nil +end + +local window = actualMaxCount * windowMultiplier + +-- Monotonic floor, advanced to the minimum stored virtual-time tag +local floor = tonumber(redis.call('GET', ckVtimeFloorKey) or '0') +local minEntry = redis.call('ZRANGE', ckVtimeKey, 0, 0, 'WITHSCORES') +if #minEntry > 0 then + local minTag = tonumber(minEntry[2]) + if minTag > floor then + floor = minTag + end +end + +local results = {} +local dequeuedCount = 0 +local attempted = {} + +-- Per-candidate serve. Body is dequeueMessagesFromCkQueueTracked's per-candidate +-- block, verbatim, with the marked NEW lines added. +local function tryServe(ckQueueName) + attempted[ckQueueName] = true + local fullQueueKey = keyPrefix .. ckQueueName + + local ckConcurrencyKey = fullQueueKey .. ':currentConcurrency' + local ckCurrentConcurrency = tonumber(redis.call('SCARD', ckConcurrencyKey) or '0') + + if ckCurrentConcurrency < queueConcurrencyLimit then + local messages = redis.call('ZRANGEBYSCORE', fullQueueKey, '-inf', tostring(currentTime), 'WITHSCORES', 'LIMIT', 0, 1) + + if #messages >= 2 then + local messageId = messages[1] + local messageScore = messages[2] + + local messageKey = messageKeyPrefix .. messageId + local messagePayload = redis.call('GET', messageKey) + + if messagePayload then + local messageData = cjson.decode(messagePayload) + local ttlExpiresAt = messageData and messageData.ttlExpiresAt + + if ttlExpiresAt and ttlExpiresAt <= currentTime then + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + else + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + redis.call('SADD', ckConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + + if ttlQueueKey and ttlQueueKey ~= '' and ttlExpiresAt then + local ttlMember = ckQueueName .. '|' .. messageId .. '|' .. (messageData.orgId or '') + redis.call('ZREM', ttlQueueKey, ttlMember) + end + + table.insert(results, messageId) + table.insert(results, messageScore) + table.insert(results, messagePayload) + + dequeuedCount = dequeuedCount + 1 + + -- NEW: advance this variant's virtual time (weight hook: fixed 1 today) + local weight = 1 + local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor) + if tag < floor then tag = floor end + redis.call('ZADD', ckVtimeKey, tag + (quantum / weight), ckQueueName) + end + else + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + end + + local earliest = redis.call('ZRANGE', fullQueueKey, 0, 0, 'WITHSCORES') + if #earliest == 0 then + redis.call('ZREM', ckIndexKey, ckQueueName) + redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW + else + redis.call('ZADD', ckIndexKey, earliest[2], ckQueueName) + end + else + local any = redis.call('ZRANGE', fullQueueKey, 0, 0, 'WITHSCORES') + if #any == 0 then + redis.call('ZREM', ckIndexKey, ckQueueName) + redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW + else + redis.call('ZADD', ckIndexKey, any[2], ckQueueName) + end + end + end +end + +-- Pass 1: fair order (lowest virtual start tag first) +local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1) +for _, ckQueueName in ipairs(vtimeCandidates) do + if dequeuedCount >= actualMaxCount then break end + tryServe(ckQueueName) +end + +-- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety). +-- Never runs when pass 1 filled the batch. +if dequeuedCount < actualMaxCount then + -- Clamp to at least 3x so pass 2 never scans fewer index variants than the old command, preserving work conservation regardless of the configured multiplier + local pass2Window = math.max(window, actualMaxCount * 3) + local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, pass2Window) + for _, ckQueueName in ipairs(ckQueues) do + if dequeuedCount >= actualMaxCount then break end + if not attempted[ckQueueName] then + tryServe(ckQueueName) + end + end +end + +-- NEW: persist floor and refresh TTLs +redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl) +if redis.call('EXISTS', ckVtimeKey) == 1 then + redis.call('EXPIRE', ckVtimeKey, stateTtl) +end + +-- Rebalance master queue (ckIndex keeps its timestamp domain) +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + return results `, }); @@ -5631,6 +5853,31 @@ declare module "@internal/redis" { callback?: Callback ): Result; + dequeueMessagesFromCkQueueVtimeTracked( + ckIndexKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + envCurrentConcurrencyKey: string, + messageKeyPrefix: string, + envQueueKey: string, + masterQueueKey: string, + ttlQueueKey: string, + lengthCounterKey: string, + ckVtimeKey: string, + ckVtimeFloorKey: string, + ckWildcardName: string, + currentTime: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + keyPrefix: string, + maxCount: string, + quantum: string, + windowMultiplier: string, + stateTtlSeconds: string, + callback?: Callback + ): Result; + dequeueMessageFromKeyTracked( messageKey: string, keyPrefix: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts new file mode 100644 index 00000000000..26628f1fda0 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -0,0 +1,586 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { Decimal } from "@trigger.dev/database"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import type { InputPayload } from "../types.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +type VtimeOverrides = { + enabled?: boolean; + quantum?: number; + scanWindowMultiplier?: number; + stateTtlSeconds?: number; +}; + +function createQueue(redisContainer: any, vtime: VtimeOverrides = {}) { + return new RunQueue({ + ...testOptions, + ckVirtualTimeScheduling: { + enabled: true, + ...vtime, + }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); +} + +function makeMessage(overrides: Partial = {}): InputPayload { + return { + runId: "r1", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, + ...overrides, + }; +} + +// The ckVtime/ckIndex member for a variant is the fully-qualified variant queue +// key (org:proj:env:queue:...:ck:), which is exactly what queueKey() produces. +function variantName(ck: string): string { + return testOptions.keys.queueKey(authenticatedEnvDev, "task/my-task", ck); +} + +const QUEUE = "task/my-task"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("CK virtual-time (SFQ) dequeue", () => { + redisTest("vtime order beats head-timestamp order", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // 30 old messages on heavy, timestamps t0..t0+29 + for (let i = 0; i < 30; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `h${i}`, concurrencyKey: "heavy", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + // 3 much newer messages on light + for (let i = 0; i < 3; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `l${i}`, concurrencyKey: "light", timestamp: t0 + 1000 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const heavyVariant = variantName("heavy"); + const lightVariant = variantName("light"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(heavyVariant); + + // Task 4 (enqueue registration) isn't done yet; seed both variants at tag 0. + await queue.redis.zadd(ckVtimeKey, 0, heavyVariant, 0, lightVariant); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + const lightServedInCall: number[] = []; + const lightSeen = new Set(); + + for (let call = 0; call < 3; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + + // At most one message per variant per call. + const heavyCount = messages.filter((m) => m.message.concurrencyKey === "heavy").length; + const lightCount = messages.filter((m) => m.message.concurrencyKey === "light").length; + expect(heavyCount).toBeLessThanOrEqual(1); + expect(lightCount).toBeLessThanOrEqual(1); + + for (const m of messages) { + if (m.message.concurrencyKey === "light" && !lightSeen.has(m.messageId)) { + lightSeen.add(m.messageId); + lightServedInCall.push(call); + } + // ack to free concurrency between calls + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + // All 3 light messages served within the first 3 calls (age order alone + // would have drained the 30 heavy messages first). + expect(lightSeen.size).toBe(3); + } finally { + await queue.quit(); + } + }); + + redisTest( + "vtime order wins over older head when maxCount forces a choice", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // Old messages on heavy: age order strictly favours heavy. + for (let i = 0; i < 5; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `h${i}`, concurrencyKey: "heavy", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + // Newer messages on light (two, so light isn't GC'd after its serve). + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `l${i}`, + concurrencyKey: "light", + timestamp: t0 + 1000, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const heavyVariant = variantName("heavy"); + const lightVariant = variantName("light"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(heavyVariant); + + // Seed vtime the OPPOSITE way to age order: heavy has the HIGH tag, + // light the LOW tag. + await queue.redis.zadd(ckVtimeKey, 10, heavyVariant, 0, lightVariant); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // maxCount 1 with two ready variants: ordering alone decides who is + // served. Age order (the old command) would pick heavy's older head; + // vtime rank must pick light's lower tag. + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1); + + expect(messages.length).toBe(1); + expect(messages[0]!.message.concurrencyKey).toBe("light"); + + // Light's tag advanced by the quantum (=1); heavy's is untouched. + const lightTag = Number(await queue.redis.zscore(ckVtimeKey, lightVariant)); + const heavyTag = Number(await queue.redis.zscore(ckVtimeKey, heavyVariant)); + expect(lightTag).toBe(1); + expect(heavyTag).toBe(10); + } finally { + await queue.quit(); + } + } + ); + + redisTest("tags advance per serve within one batched call", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + const cks = ["a", "b", "c", "d", "e"]; + + // Two messages per variant so a single serve doesn't drain (and GC) it, + // letting us observe the advanced tag afterwards. + for (const ck of cks) { + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const seedArgs: (string | number)[] = []; + for (const ck of cks) { + seedArgs.push(0, variantName(ck)); + } + await queue.redis.zadd(ckVtimeKey, ...(seedArgs as [number, string])); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 5); + + expect(messages.length).toBe(5); + + for (const ck of cks) { + const score = await queue.redis.zscore(ckVtimeKey, variantName(ck)); + expect(Number(score)).toBe(1); + } + } finally { + await queue.quit(); + } + }); + + redisTest("floor is monotonic and read-repairs", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + const cks = ["a", "b"]; + + // enough messages per variant to keep serving for many calls + for (const ck of cks) { + for (let i = 0; i < 25; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + await queue.redis.zadd(ckVtimeKey, 0, variantName("a"), 0, variantName("b")); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + let prevFloor = 0; + for (let call = 0; call < 20; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThanOrEqual(prevFloor); + prevFloor = floor; + } + + // Final settle: gate both variants (limit 1 + an occupied slot) so nothing + // is served (no advance), and the floor read-repairs up to the current min tag. + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1); + for (const ck of cks) { + await queue.redis.sadd( + testOptions.keys.queueCurrentConcurrencyKeyFromQueue(variantName(ck)), + "occupant" + ); + } + await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + + const floorAfter = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floorAfter).toBeGreaterThanOrEqual(prevFloor); + + const minEntry = await queue.redis.zrange(ckVtimeKey, 0, 0, "WITHSCORES"); + const minTag = Number(minEntry[1]); + expect(floorAfter).toBe(minTag); + expect(floorAfter).toBeGreaterThan(10); + } finally { + await queue.quit(); + } + }); + + redisTest( + "new key initialises at the floor, not zero and not behind the backlog", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + const cks = ["a", "b"]; + + for (const ck of cks) { + for (let i = 0; i < 25; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + await queue.redis.zadd(ckVtimeKey, 0, variantName("a"), 0, variantName("b")); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // Drive tags up to ~20. + for (let call = 0; call < 20; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThan(10); + + // Register a fresh variant at the current floor (ZADD NX simulates + // enqueue-time registration, which is a later task). + const freshVariant = variantName("fresh"); + // two messages so the fresh variant isn't GC'd on its first serve + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-fresh-${i}`, concurrencyKey: "fresh", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + await queue.redis.zadd(ckVtimeKey, "NX", floor, freshVariant); + + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + const served = messages.some((m) => m.message.concurrencyKey === "fresh"); + expect(served).toBe(true); + + const freshTag = Number(await queue.redis.zscore(ckVtimeKey, freshVariant)); + // initialised at the floor and advanced by quantum (=1), not stuck at 1 + expect(freshTag).toBe(floor + 1); + } finally { + await queue.quit(); + } + } + ); + + redisTest("no service, no advance", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // ck:a has messages but its concurrency slot will be occupied + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-a", concurrencyKey: "a", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + for (const ck of ["b", "c"]) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}`, concurrencyKey: ck, timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + await queue.redis.zadd( + ckVtimeKey, + 0, + variantName("a"), + 0, + variantName("b"), + 0, + variantName("c") + ); + + // base queue concurrency limit of 1 + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1); + + // occupy ck:a's single slot (equivalent to a prior dequeue-without-ack) + await queue.redis.sadd( + testOptions.keys.queueCurrentConcurrencyKeyFromQueue(variantName("a")), + "occupant" + ); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + + const servedCks = messages.map((m) => m.message.concurrencyKey); + expect(servedCks).not.toContain("a"); + expect(servedCks).toContain("b"); + expect(servedCks).toContain("c"); + + // ck:a's tag is unchanged (never served) + const aTag = Number(await queue.redis.zscore(ckVtimeKey, variantName("a"))); + expect(aTag).toBe(0); + } finally { + await queue.quit(); + } + }); + + redisTest("GC on empty variant removes it from ckIndex and ckVtime", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-a", concurrencyKey: "a", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + // second variant so ckVtime/ckIndex don't fully disappear, keeping the test focused + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-b", concurrencyKey: "b", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(aVariant); + await queue.redis.zadd(ckVtimeKey, 0, aVariant, 0, variantName("b")); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); + + const inVtime = await queue.redis.zscore(ckVtimeKey, aVariant); + const inIndex = await queue.redis.zscore(ckIndexKey, aVariant); + expect(inVtime).toBeNull(); + expect(inIndex).toBeNull(); + } finally { + await queue.quit(); + } + }); + + redisTest("TTL is set and refreshed on ckVtime and ckVtimeFloor", async ({ redisContainer }) => { + const stateTtlSeconds = 3600; + const queue = createQueue(redisContainer, { stateTtlSeconds }); + try { + const t0 = Date.now() - 100_000; + + // two messages on one variant so ckVtime survives (not GC'd) after a serve + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-a-${i}`, concurrencyKey: "a", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(aVariant); + await queue.redis.zadd(ckVtimeKey, 0, aVariant); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1); + + const vtimeTtl = await queue.redis.pttl(ckVtimeKey); + const floorTtl = await queue.redis.pttl(ckVtimeFloorKey); + + expect(vtimeTtl).toBeGreaterThan(0); + expect(vtimeTtl).toBeLessThanOrEqual(stateTtlSeconds * 1000); + expect(floorTtl).toBeGreaterThan(0); + expect(floorTtl).toBeLessThanOrEqual(stateTtlSeconds * 1000); + } finally { + await queue.quit(); + } + }); + + redisTest("pass 2 fill serves unregistered variants and registers them", async ({ + redisContainer, + }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // Enqueue on a variant but do NOT register it in ckVtime (simulating an + // enqueue from old code / before Task 4). Two messages so the variant + // survives its first serve and we can observe it was registered. + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-a-${i}`, concurrencyKey: "a", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + // ensure no ckVtime entry exists for it + await queue.redis.zrem(ckVtimeKey, aVariant); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + + expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); + + const tag = await queue.redis.zscore(ckVtimeKey, aVariant); + expect(tag).not.toBeNull(); + } finally { + await queue.quit(); + } + }); + + redisTest("future-scheduled variants are skipped without advance", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // a normal ready variant so the :ck:* wildcard is selected from the master queue + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-now", concurrencyKey: "now", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + // a future-scheduled variant + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: "r-future", + concurrencyKey: "future", + timestamp: Date.now() + 60_000, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("now")); + const futureVariant = variantName("future"); + await queue.redis.zadd(ckVtimeKey, 0, variantName("now"), 5, futureVariant); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + + expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false); + + const futureTag = Number(await queue.redis.zscore(ckVtimeKey, futureVariant)); + expect(futureTag).toBe(5); + } finally { + await queue.quit(); + } + }); +}); From c06f918ec78481e381600bb6d12a70f692d0784e Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 01:47:22 +0100 Subject: [PATCH 04/17] feat(run-engine): register CK variants in vtime index on enqueue Task 4. Adds enqueueMessageCkVtimeTracked / enqueueMessageWithTtlCkVtimeTracked: the existing tracked enqueue scripts verbatim plus a slow-path ZADD ckVtime NX at the current floor (never rewinds an advanced tag), so a brand-new key is in the fair order from its first enqueue (the sybil fix). Fast path does not register. Old enqueue scripts unchanged; flag-off byte-identical. Tests 10/12 cover registration-at-floor and fast-path purity; test 4 un-seeded. --- .../run-engine/src/run-queue/index.ts | 535 +++++++++++++++--- .../src/run-queue/tests/ckVtime.test.ts | 116 +++- 2 files changed, 582 insertions(+), 69 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 45efa14cc54..8f6d84d8842 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -1926,72 +1926,145 @@ export class RunQueue { const ckKeyPrefix = this.options.redis.keyPrefix ?? ""; if (ttlInfo) { - result = await this.redis.enqueueMessageWithTtlCkTracked( - // keys - masterQueueKey, - queueKey, - messageKey, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - queueCurrentDequeuedKey, - envCurrentDequeuedKey, - envQueueKey, - ttlInfo.ttlQueueKey, - ckIndexKey, - workerQueueKey, - queueConcurrencyLimitKey, - envConcurrencyLimitKey, - envConcurrencyLimitBurstFactorKey, - lengthCounterKey, - baseQueueKey, - // args - queueName, - messageId, - messageData, - messageScore, - ttlInfo.ttlMember, - String(ttlInfo.ttlExpiresAt), - ckWildcardName, - messageKeyValue, - defaultEnvConcurrencyLimit, - defaultEnvConcurrencyBurstFactor, - currentTime, - enableFastPathArg, - ckKeyPrefix, - String(this.counterTtlSeconds) - ); + result = this.#ckVtimeEnabled + ? await this.redis.enqueueMessageWithTtlCkVtimeTracked( + // keys + masterQueueKey, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ttlInfo.ttlQueueKey, + ckIndexKey, + workerQueueKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, + this.keys.ckVtimeKeyFromQueue(message.queue), + this.keys.ckVtimeFloorKeyFromQueue(message.queue), + // args + queueName, + messageId, + messageData, + messageScore, + ttlInfo.ttlMember, + String(ttlInfo.ttlExpiresAt), + ckWildcardName, + messageKeyValue, + defaultEnvConcurrencyLimit, + defaultEnvConcurrencyBurstFactor, + currentTime, + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds), + String(this.#ckVtimeStateTtl) + ) + : await this.redis.enqueueMessageWithTtlCkTracked( + // keys + masterQueueKey, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ttlInfo.ttlQueueKey, + ckIndexKey, + workerQueueKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, + // args + queueName, + messageId, + messageData, + messageScore, + ttlInfo.ttlMember, + String(ttlInfo.ttlExpiresAt), + ckWildcardName, + messageKeyValue, + defaultEnvConcurrencyLimit, + defaultEnvConcurrencyBurstFactor, + currentTime, + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds) + ); } else { - result = await this.redis.enqueueMessageCkTracked( - // keys - masterQueueKey, - queueKey, - messageKey, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - queueCurrentDequeuedKey, - envCurrentDequeuedKey, - envQueueKey, - ckIndexKey, - workerQueueKey, - queueConcurrencyLimitKey, - envConcurrencyLimitKey, - envConcurrencyLimitBurstFactorKey, - lengthCounterKey, - baseQueueKey, - // args - queueName, - messageId, - messageData, - messageScore, - ckWildcardName, - messageKeyValue, - defaultEnvConcurrencyLimit, - defaultEnvConcurrencyBurstFactor, - currentTime, - enableFastPathArg, - ckKeyPrefix, - String(this.counterTtlSeconds) - ); + result = this.#ckVtimeEnabled + ? await this.redis.enqueueMessageCkVtimeTracked( + // keys + masterQueueKey, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ckIndexKey, + workerQueueKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, + this.keys.ckVtimeKeyFromQueue(message.queue), + this.keys.ckVtimeFloorKeyFromQueue(message.queue), + // args + queueName, + messageId, + messageData, + messageScore, + ckWildcardName, + messageKeyValue, + defaultEnvConcurrencyLimit, + defaultEnvConcurrencyBurstFactor, + currentTime, + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds), + String(this.#ckVtimeStateTtl) + ) + : await this.redis.enqueueMessageCkTracked( + // keys + masterQueueKey, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ckIndexKey, + workerQueueKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, + // args + queueName, + messageId, + messageData, + messageScore, + ckWildcardName, + messageKeyValue, + defaultEnvConcurrencyLimit, + defaultEnvConcurrencyBurstFactor, + currentTime, + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds) + ); } } else if (ttlInfo) { // Use the TTL-aware enqueue that atomically adds to both queues @@ -3699,6 +3772,267 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +return 0 + `, + }); + + // Vtime variant of enqueueMessageCkTracked (feature-flagged via + // ckVirtualTimeScheduling.enabled). Identical script body, plus slow-path + // registration of the variant into the :ckVtime ZSET at the floor (NX), so a + // brand-new key is present in the fair order from its first enqueue. The + // fast path (direct-to-worker-queue) neither registers nor advances. + this.redis.defineCommand("enqueueMessageCkVtimeTracked", { + numberOfKeys: 17, + lua: ` +local masterQueueKey = KEYS[1] +local queueKey = KEYS[2] +local messageKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ckIndexKey = KEYS[9] +-- Fast-path keys (KEYS 10-13) +local workerQueueKey = KEYS[10] +local queueConcurrencyLimitKey = KEYS[11] +local envConcurrencyLimitKey = KEYS[12] +local envConcurrencyLimitBurstFactorKey = KEYS[13] +-- Counter keys (KEYS 14-15) +local lengthCounterKey = KEYS[14] +local baseQueueKey = KEYS[15] +-- Virtual-time keys (KEYS 16-17) +local ckVtimeKey = KEYS[16] +local ckVtimeFloorKey = KEYS[17] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local ckWildcardName = ARGV[5] +-- Fast-path args (ARGV 6-10) +local messageKeyValue = ARGV[6] +local defaultEnvConcurrencyLimit = ARGV[7] +local defaultEnvConcurrencyBurstFactor = ARGV[8] +local currentTime = ARGV[9] +local enableFastPath = ARGV[10] +-- keyPrefix for prepending to variant names stored as values in ckIndex +local keyPrefix = ARGV[11] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[12] +-- TTL (seconds) applied to ckVtime on registration +local stateTtl = ARGV[13] + +-- Fast path: check if we can skip the queue and go directly to worker queue +if enableFastPath == '1' then + local available = redis.call('ZRANGEBYSCORE', queueKey, '-inf', currentTime, 'LIMIT', 0, 1) + if #available == 0 then + local envCurrent = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') + local envLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) + local envBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) + local envLimitWithBurst = math.floor(envLimit * envBurstFactor) + + if envCurrent < envLimitWithBurst then + local queueCurrent = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') + local queueLimit = math.min( + tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), + envLimit + ) + + if queueCurrent < queueLimit then + redis.call('SET', messageKey, messageData) + redis.call('SADD', queueCurrentConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + redis.call('RPUSH', workerQueueKey, messageKeyValue) + -- Fast-path skips the CK variant zset entirely; lengthCounter is unchanged. + -- runningCounter is bumped later by dequeueMessageFromKeyTracked when the + -- worker pulls the message from the worker queue. + return 1 + end + end + end +end + +-- Slow path: normal enqueue +redis.call('SET', messageKey, messageData) + +-- Lazy-init lengthCounter from existing ckIndex variants (once per base queue per 24h). +-- The 24h TTL means the counter periodically re-anchors to truth, bounding any drift +-- that accumulated during rolling-deploy overlap windows. +-- Run BEFORE the ZADD so we capture pre-state; the subsequent INCR accounts for the new message. +-- The counter tracks ONLY CK-variant messages — the read path adds ZCARD(base) separately, +-- so the base zset is intentionally excluded here. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- INCR is gated on ZADD returning 1 (new entry). A duplicate enqueue (same messageId +-- already in the variant zset) returns 0 and must not bump the counter. +local added = redis.call('ZADD', queueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], queueName) +end + +-- Register this variant in the virtual-time index at the floor. NX means an +-- already-advanced tag is never rewound. +local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' +redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName) +redis.call('EXPIRE', ckVtimeKey, stateTtl) + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx > 0 then + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, queueName) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +return 0 + `, + }); + + // Vtime variant of enqueueMessageWithTtlCkTracked. Same slow-path-only + // registration as enqueueMessageCkVtimeTracked above. + this.redis.defineCommand("enqueueMessageWithTtlCkVtimeTracked", { + numberOfKeys: 18, + lua: ` +local masterQueueKey = KEYS[1] +local queueKey = KEYS[2] +local messageKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ttlQueueKey = KEYS[9] +local ckIndexKey = KEYS[10] +-- Fast-path keys (KEYS 11-14) +local workerQueueKey = KEYS[11] +local queueConcurrencyLimitKey = KEYS[12] +local envConcurrencyLimitKey = KEYS[13] +local envConcurrencyLimitBurstFactorKey = KEYS[14] +-- Counter keys (KEYS 15-16) +local lengthCounterKey = KEYS[15] +local baseQueueKey = KEYS[16] +-- Virtual-time keys (KEYS 17-18) +local ckVtimeKey = KEYS[17] +local ckVtimeFloorKey = KEYS[18] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local ttlMember = ARGV[5] +local ttlScore = ARGV[6] +local ckWildcardName = ARGV[7] +-- Fast-path args (ARGV 8-12) +local messageKeyValue = ARGV[8] +local defaultEnvConcurrencyLimit = ARGV[9] +local defaultEnvConcurrencyBurstFactor = ARGV[10] +local currentTime = ARGV[11] +local enableFastPath = ARGV[12] +-- keyPrefix for prepending to variant names stored as values in ckIndex +local keyPrefix = ARGV[13] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[14] +-- TTL (seconds) applied to ckVtime on registration +local stateTtl = ARGV[15] + +-- Fast path: check if we can skip the queue and go directly to worker queue +if enableFastPath == '1' then + local available = redis.call('ZRANGEBYSCORE', queueKey, '-inf', currentTime, 'LIMIT', 0, 1) + if #available == 0 then + local envCurrent = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') + local envLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) + local envBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) + local envLimitWithBurst = math.floor(envLimit * envBurstFactor) + + if envCurrent < envLimitWithBurst then + local queueCurrent = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') + local queueLimit = math.min( + tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), + envLimit + ) + + if queueCurrent < queueLimit then + redis.call('SET', messageKey, messageData) + redis.call('SADD', queueCurrentConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + redis.call('RPUSH', workerQueueKey, messageKeyValue) + return 1 + end + end + end +end + +-- Slow path: normal enqueue +redis.call('SET', messageKey, messageData) + +-- Lazy-init lengthCounter from existing ckIndex variants (once per base queue per 24h). +-- See enqueueMessageCkTracked for the TTL rationale. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- INCR is gated on ZADD returning 1 (new entry). +local added = redis.call('ZADD', queueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) +redis.call('ZADD', ttlQueueKey, ttlScore, ttlMember) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], queueName) +end + +-- Register this variant in the virtual-time index at the floor. NX means an +-- already-advanced tag is never rewound. +local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' +redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName) +redis.call('EXPIRE', ckVtimeKey, stateTtl) + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx > 0 then + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, queueName) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + return 0 `, }); @@ -5833,6 +6167,77 @@ declare module "@internal/redis" { callback?: Callback ): Result; + enqueueMessageCkVtimeTracked( + masterQueueKey: string, + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ckIndexKey: string, + workerQueueKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + lengthCounterKey: string, + baseQueueKey: string, + ckVtimeKey: string, + ckVtimeFloorKey: string, + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + ckWildcardName: string, + messageKeyValue: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + currentTime: string, + enableFastPath: string, + keyPrefix: string, + counterTtl: string, + stateTtl: string, + callback?: Callback + ): Result; + + enqueueMessageWithTtlCkVtimeTracked( + masterQueueKey: string, + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ttlQueueKey: string, + ckIndexKey: string, + workerQueueKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + lengthCounterKey: string, + baseQueueKey: string, + ckVtimeKey: string, + ckVtimeFloorKey: string, + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + ttlMember: string, + ttlScore: string, + ckWildcardName: string, + messageKeyValue: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + currentTime: string, + enableFastPath: string, + keyPrefix: string, + counterTtl: string, + stateTtl: string, + callback?: Callback + ): Result; + dequeueMessagesFromCkQueueTracked( ckIndexKey: string, queueConcurrencyLimitKey: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts index 26628f1fda0..30f1c349c05 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -334,7 +334,8 @@ describe("CK virtual-time (SFQ) dequeue", () => { const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); - await queue.redis.zadd(ckVtimeKey, 0, variantName("a"), 0, variantName("b")); + // No direct ZADD seeding: the enqueues above register a and b at the + // initial floor (0) themselves. const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); @@ -351,8 +352,8 @@ describe("CK virtual-time (SFQ) dequeue", () => { const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); expect(floor).toBeGreaterThan(10); - // Register a fresh variant at the current floor (ZADD NX simulates - // enqueue-time registration, which is a later task). + // A fresh variant registers itself at the current floor via the + // enqueue-time registration (ZADD NX in the enqueue script). const freshVariant = variantName("fresh"); // two messages so the fresh variant isn't GC'd on its first serve for (let i = 0; i < 2; i++) { @@ -363,7 +364,6 @@ describe("CK virtual-time (SFQ) dequeue", () => { skipDequeueProcessing: true, }); } - await queue.redis.zadd(ckVtimeKey, "NX", floor, freshVariant); const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); const served = messages.some((m) => m.message.concurrencyKey === "fresh"); @@ -583,4 +583,112 @@ describe("CK virtual-time (SFQ) dequeue", () => { await queue.quit(); } }); + + redisTest("enqueue registers the variant at the current floor with NX", async ({ + redisContainer, + }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // two variants with enough messages that the drive loop never drains them + for (const ck of ["a", "b"]) { + for (let i = 0; i < 10; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + + // enqueue registered both variants at the initial floor (0), before any dequeue + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("a")))).toBe(0); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("b")))).toBe(0); + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // drive the floor up to ~5 via serves + for (let call = 0; call < 8; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThanOrEqual(5); + + // a fresh key enqueued now lands exactly at the floor (no dequeue in between) + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-fresh-0", concurrencyKey: "fresh", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("fresh")))).toBe(floor); + + // NX: enqueueing on a key whose tag is already 9 never rewinds it + await queue.redis.zadd(ckVtimeKey, 9, variantName("nine")); + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-nine-0", concurrencyKey: "nine", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("nine")))).toBe(9); + } finally { + await queue.quit(); + } + }); + + redisTest("fast path leaves vtime state untouched", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + + // empty variant + free capacity: the fast path fires and skips the + // variant zset entirely + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-fast", concurrencyKey: "a" }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + enableFastPath: true, + }); + + // fast-path proof: nothing landed in the variant zset.. + expect(await queue.redis.zcard(aVariant)).toBe(0); + // ..and no vtime registration happened + expect(await queue.redis.zscore(ckVtimeKey, aVariant)).toBeNull(); + + // saturate capacity so the next enqueue takes the slow path + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1); + await queue.redis.sadd( + testOptions.keys.queueCurrentConcurrencyKeyFromQueue(aVariant), + "occupant" + ); + + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-slow", concurrencyKey: "a" }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + enableFastPath: true, + }); + + // slow path taken and the variant is registered + expect(await queue.redis.zcard(aVariant)).toBe(1); + expect(await queue.redis.zscore(ckVtimeKey, aVariant)).not.toBeNull(); + } finally { + await queue.quit(); + } + }); }); From 98bc219ce6bbc46ab83c2a382b63fa20e4e3bcc1 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 03:19:04 +0100 Subject: [PATCH 05/17] feat(run-engine): re-register CK variants in vtime index on nack Task 5. Adds nackMessageCkVtimeTracked: the existing tracked nack script verbatim plus a slow-path ZADD ckVtime NX at the floor after the CK-index rebalance, so a variant a nack revives from GC rejoins the fair order. Old nack script unchanged; flag-off byte-identical. Test 13 covers nack re-registration; test 14 is a 200-op closure-invariant property test (ckIndex subset of ckVtime). Also disables the background master-queue consumers + worker in this file's createQueue helper (matching the RunQueue test convention) so the tests verify the Lua invariant under controlled ops instead of racing a background consumer (was a ~36% flake). --- .../run-engine/src/run-queue/index.ts | 200 ++++++++- .../src/run-queue/tests/ckVtime.test.ts | 403 +++++++++++++----- 2 files changed, 467 insertions(+), 136 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 8f6d84d8842..946ba0ab103 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -2707,28 +2707,56 @@ export class RunQueue { const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(message.queue); const runningCounterKey = this.keys.queueRunningCounterKeyFromQueue(message.queue); - await this.redis.nackMessageCkTracked( - //keys - masterQueueKey, - messageKey, - messageQueue, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - queueCurrentDequeuedKey, - envCurrentDequeuedKey, - envQueueKey, - ckIndexKey, - lengthCounterKey, - runningCounterKey, - //args - messageId, - messageQueue, - JSON.stringify(message), - String(messageScore), - ckWildcardName, - this.options.redis.keyPrefix ?? "", - String(this.counterTtlSeconds) - ); + if (this.#ckVtimeEnabled) { + await this.redis.nackMessageCkVtimeTracked( + //keys + masterQueueKey, + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ckIndexKey, + lengthCounterKey, + runningCounterKey, + this.keys.ckVtimeKeyFromQueue(message.queue), + this.keys.ckVtimeFloorKeyFromQueue(message.queue), + //args + messageId, + messageQueue, + JSON.stringify(message), + String(messageScore), + ckWildcardName, + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds), + String(this.#ckVtimeStateTtl) + ); + } else { + await this.redis.nackMessageCkTracked( + //keys + masterQueueKey, + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + envQueueKey, + ckIndexKey, + lengthCounterKey, + runningCounterKey, + //args + messageId, + messageQueue, + JSON.stringify(message), + String(messageScore), + ckWildcardName, + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds) + ); + } } else { await this.redis.nackMessage( //keys @@ -5455,6 +5483,109 @@ else redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) end +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, messageQueueName) +`, + }); + + // Vtime variant of nackMessageCkTracked (feature-flagged via + // ckVirtualTimeScheduling.enabled). Identical script body, plus registration + // of the variant into the :ckVtime ZSET at the floor (NX), so a GC'd variant + // that a nack revives rejoins the fair order. + this.redis.defineCommand("nackMessageCkVtimeTracked", { + numberOfKeys: 13, + lua: ` +-- Keys: +local masterQueueKey = KEYS[1] +local messageKey = KEYS[2] +local messageQueueKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ckIndexKey = KEYS[9] +local lengthCounterKey = KEYS[10] +local runningCounterKey = KEYS[11] +-- Virtual-time keys (KEYS 12-13) +local ckVtimeKey = KEYS[12] +local ckVtimeFloorKey = KEYS[13] + +-- Args: +local messageId = ARGV[1] +local messageQueueName = ARGV[2] +local messageData = ARGV[3] +local messageScore = tonumber(ARGV[4]) +local ckWildcardName = ARGV[5] +-- keyPrefix for prepending to variant names stored as values in ckIndex (lazy-init only) +local keyPrefix = ARGV[6] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[7] +-- TTL (seconds) applied to ckVtime on registration +local stateTtl = ARGV[8] + +local function decrFloored(key) + if tonumber(redis.call('GET', key) or '0') > 0 then + redis.call('DECR', key) + end +end + +-- Update the message data +redis.call('SET', messageKey, messageData) + +-- Update the concurrency keys. nack only DECRs runningCounter, never INCRs it, +-- so we skip the eager lazy-init here (unlike releaseConcurrencyTracked, which +-- mirrors the same DECR pattern with init). A post-TTL nack's floored DECR +-- no-ops; the next dequeueMessageFromKeyTracked reseeds from current state. +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) +if removedFromDequeued == 1 then + decrFloored(runningCounterKey) +end + +-- Lazy-init lengthCounter if missing (e.g. expired via 24h TTL). nack re-queues a +-- message, which means lengthCounter must be present before we INCR. Without this, +-- a nack after counter expiry would create the counter at 1 and stay drifted until +-- next reset. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- Enqueue the message back into the CK-specific queue. INCR lengthCounter only if +-- it's a new entry (ZADD returns 1). +local added = redis.call('ZADD', messageQueueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], messageQueueName) +end + +-- Register this variant in the virtual-time index at the floor. NX means an +-- already-advanced tag is never rewound. +local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' +redis.call('ZADD', ckVtimeKey, 'NX', vfloor, messageQueueName) +redis.call('EXPIRE', ckVtimeKey, stateTtl) + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + -- Remove old-format entry from master queue (transition cleanup) redis.call('ZREM', masterQueueKey, messageQueueName) `, @@ -6333,6 +6464,31 @@ declare module "@internal/redis" { callback?: Callback ): Result; + nackMessageCkVtimeTracked( + masterQueueKey: string, + messageKey: string, + messageQueue: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ckIndexKey: string, + lengthCounterKey: string, + runningCounterKey: string, + ckVtimeKey: string, + ckVtimeFloorKey: string, + messageId: string, + messageQueueName: string, + messageData: string, + messageScore: string, + ckWildcardName: string, + keyPrefix: string, + counterTtl: string, + stateTtl: string, + callback?: Callback + ): Result; + moveToDeadLetterQueueCkTracked( masterQueueKey: string, messageKey: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts index 30f1c349c05..101508196a6 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -43,6 +43,10 @@ type VtimeOverrides = { function createQueue(redisContainer: any, vtime: VtimeOverrides = {}) { return new RunQueue({ ...testOptions, + // These tests drive every op themselves (testDequeueFromMasterQueue + skipDequeueProcessing), + // so the autonomous master-queue consumers and background worker must not race them. + masterQueueConsumersDisabled: true, + workerOptions: { disabled: true }, ckVirtualTimeScheduling: { enabled: true, ...vtime, @@ -325,7 +329,11 @@ describe("CK virtual-time (SFQ) dequeue", () => { for (let i = 0; i < 25; i++) { await queue.enqueueMessage({ env: authenticatedEnvDev, - message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), + message: makeMessage({ + runId: `r-${ck}-${i}`, + concurrencyKey: ck, + timestamp: t0 + i, + }), workerQueue: authenticatedEnvDev.id, skipDequeueProcessing: true, }); @@ -359,7 +367,11 @@ describe("CK virtual-time (SFQ) dequeue", () => { for (let i = 0; i < 2; i++) { await queue.enqueueMessage({ env: authenticatedEnvDev, - message: makeMessage({ runId: `r-fresh-${i}`, concurrencyKey: "fresh", timestamp: t0 + i }), + message: makeMessage({ + runId: `r-fresh-${i}`, + concurrencyKey: "fresh", + timestamp: t0 + i, + }), workerQueue: authenticatedEnvDev.id, skipDequeueProcessing: true, }); @@ -435,42 +447,45 @@ describe("CK virtual-time (SFQ) dequeue", () => { } }); - redisTest("GC on empty variant removes it from ckIndex and ckVtime", async ({ redisContainer }) => { - const queue = createQueue(redisContainer); - try { - const t0 = Date.now() - 100_000; + redisTest( + "GC on empty variant removes it from ckIndex and ckVtime", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: "r-a", concurrencyKey: "a", timestamp: t0 }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); - // second variant so ckVtime/ckIndex don't fully disappear, keeping the test focused - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: "r-b", concurrencyKey: "b", timestamp: t0 }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-a", concurrencyKey: "a", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + // second variant so ckVtime/ckIndex don't fully disappear, keeping the test focused + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-b", concurrencyKey: "b", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); - const aVariant = variantName("a"); - const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); - const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(aVariant); - await queue.redis.zadd(ckVtimeKey, 0, aVariant, 0, variantName("b")); + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(aVariant); + await queue.redis.zadd(ckVtimeKey, 0, aVariant, 0, variantName("b")); - const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); - const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); - expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); - const inVtime = await queue.redis.zscore(ckVtimeKey, aVariant); - const inIndex = await queue.redis.zscore(ckIndexKey, aVariant); - expect(inVtime).toBeNull(); - expect(inIndex).toBeNull(); - } finally { - await queue.quit(); + const inVtime = await queue.redis.zscore(ckVtimeKey, aVariant); + const inIndex = await queue.redis.zscore(ckIndexKey, aVariant); + expect(inVtime).toBeNull(); + expect(inIndex).toBeNull(); + } finally { + await queue.quit(); + } } - }); + ); redisTest("TTL is set and refreshed on ckVtime and ckVtimeFloor", async ({ redisContainer }) => { const stateTtlSeconds = 3600; @@ -508,41 +523,42 @@ describe("CK virtual-time (SFQ) dequeue", () => { } }); - redisTest("pass 2 fill serves unregistered variants and registers them", async ({ - redisContainer, - }) => { - const queue = createQueue(redisContainer); - try { - const t0 = Date.now() - 100_000; + redisTest( + "pass 2 fill serves unregistered variants and registers them", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; - // Enqueue on a variant but do NOT register it in ckVtime (simulating an - // enqueue from old code / before Task 4). Two messages so the variant - // survives its first serve and we can observe it was registered. - for (let i = 0; i < 2; i++) { - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: `r-a-${i}`, concurrencyKey: "a", timestamp: t0 + i }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); - } + // Enqueue on a variant but do NOT register it in ckVtime (simulating an + // enqueue from old code / before Task 4). Two messages so the variant + // survives its first serve and we can observe it was registered. + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-a-${i}`, concurrencyKey: "a", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } - const aVariant = variantName("a"); - const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); - // ensure no ckVtime entry exists for it - await queue.redis.zrem(ckVtimeKey, aVariant); + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + // ensure no ckVtime entry exists for it + await queue.redis.zrem(ckVtimeKey, aVariant); - const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); - const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); - expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); + expect(messages.some((m) => m.message.concurrencyKey === "a")).toBe(true); - const tag = await queue.redis.zscore(ckVtimeKey, aVariant); - expect(tag).not.toBeNull(); - } finally { - await queue.quit(); + const tag = await queue.redis.zscore(ckVtimeKey, aVariant); + expect(tag).not.toBeNull(); + } finally { + await queue.quit(); + } } - }); + ); redisTest("future-scheduled variants are skipped without advance", async ({ redisContainer }) => { const queue = createQueue(redisContainer); @@ -584,69 +600,74 @@ describe("CK virtual-time (SFQ) dequeue", () => { } }); - redisTest("enqueue registers the variant at the current floor with NX", async ({ - redisContainer, - }) => { - const queue = createQueue(redisContainer); - try { - const t0 = Date.now() - 100_000; + redisTest( + "enqueue registers the variant at the current floor with NX", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; - // two variants with enough messages that the drive loop never drains them - for (const ck of ["a", "b"]) { - for (let i = 0; i < 10; i++) { - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: t0 + i }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); + // two variants with enough messages that the drive loop never drains them + for (const ck of ["a", "b"]) { + for (let i = 0; i < 10; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-${ck}-${i}`, + concurrencyKey: ck, + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } } - } - const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); - const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); - // enqueue registered both variants at the initial floor (0), before any dequeue - expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("a")))).toBe(0); - expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("b")))).toBe(0); + // enqueue registered both variants at the initial floor (0), before any dequeue + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("a")))).toBe(0); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("b")))).toBe(0); - const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); - // drive the floor up to ~5 via serves - for (let call = 0; call < 8; call++) { - const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); - for (const m of messages) { - await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { - skipDequeueProcessing: true, - }); + // drive the floor up to ~5 via serves + for (let call = 0; call < 8; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } } - } - const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); - expect(floor).toBeGreaterThanOrEqual(5); + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThanOrEqual(5); - // a fresh key enqueued now lands exactly at the floor (no dequeue in between) - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: "r-fresh-0", concurrencyKey: "fresh", timestamp: t0 }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); - expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("fresh")))).toBe(floor); + // a fresh key enqueued now lands exactly at the floor (no dequeue in between) + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-fresh-0", concurrencyKey: "fresh", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("fresh")))).toBe(floor); - // NX: enqueueing on a key whose tag is already 9 never rewinds it - await queue.redis.zadd(ckVtimeKey, 9, variantName("nine")); - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: makeMessage({ runId: "r-nine-0", concurrencyKey: "nine", timestamp: t0 }), - workerQueue: authenticatedEnvDev.id, - skipDequeueProcessing: true, - }); - expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("nine")))).toBe(9); - } finally { - await queue.quit(); + // NX: enqueueing on a key whose tag is already 9 never rewinds it + await queue.redis.zadd(ckVtimeKey, 9, variantName("nine")); + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-nine-0", concurrencyKey: "nine", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + expect(Number(await queue.redis.zscore(ckVtimeKey, variantName("nine")))).toBe(9); + } finally { + await queue.quit(); + } } - }); + ); redisTest("fast path leaves vtime state untouched", async ({ redisContainer }) => { const queue = createQueue(redisContainer); @@ -691,4 +712,158 @@ describe("CK virtual-time (SFQ) dequeue", () => { await queue.quit(); } }); + + redisTest("nack re-registers a GC'd variant", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // one message on ck:a, plenty on ck:b so serves keep flowing and the floor rises + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-a-0", concurrencyKey: "a", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + for (let i = 0; i < 10; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-b-${i}`, concurrencyKey: "b", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const aVariant = variantName("a"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(aVariant); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(aVariant); + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(aVariant); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // first dequeue serves a's only message: a is drained and GC'd from both indexes + let aMessageId: string | undefined; + const first = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of first) { + if (m.message.concurrencyKey === "a") { + aMessageId = m.messageId; + } else { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + expect(aMessageId).toBeDefined(); + expect(await queue.redis.zscore(ckVtimeKey, aVariant)).toBeNull(); + expect(await queue.redis.zscore(ckIndexKey, aVariant)).toBeNull(); + + // drive the floor up via b serves + for (let call = 0; call < 6; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 1); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThan(0); + + // nack with an immediate retry score so the revived message is servable now + await queue.nackMessage({ + orgId: authenticatedEnvDev.organization.id, + messageId: aMessageId!, + retryAt: Date.now(), + skipDequeueProcessing: true, + }); + + // the variant is back in ckIndex AND in ckVtime at the floor + expect(await queue.redis.zscore(ckIndexKey, aVariant)).not.toBeNull(); + expect(Number(await queue.redis.zscore(ckVtimeKey, aVariant))).toBe(floor); + + // and a subsequent dequeue serves it + const after = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 10); + expect(after.some((m) => m.messageId === aMessageId)).toBe(true); + } finally { + await queue.quit(); + } + }); + + redisTest("ckVtime membership tracks ckIndex membership", async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const cks = ["a", "b", "c", "d", "e", "f", "g", "h"]; + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(variantName("a")); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // deterministic LCG so failures reproduce + let seed = 123456789; + const rand = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const pick = (n: number) => Math.floor(rand() * n); + + const inFlight: string[] = []; + let nextRun = 0; + + for (let step = 0; step < 200; step++) { + const op = pick(4); + let opName = "noop"; + + if (op === 0) { + opName = "enqueue"; + const ck = cks[pick(cks.length)]!; + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r${nextRun++}`, + concurrencyKey: ck, + timestamp: Date.now() - 100_000, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } else if (op === 1) { + opName = "dequeue"; + const messages = await queue.testDequeueFromMasterQueue( + shard, + authenticatedEnvDev.id, + 1 + pick(4) + ); + for (const m of messages) { + inFlight.push(m.messageId); + } + } else if (op === 2 && inFlight.length > 0) { + opName = "ack"; + const [id] = inFlight.splice(pick(inFlight.length), 1); + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, id!, { + skipDequeueProcessing: true, + }); + } else if (op === 3 && inFlight.length > 0) { + opName = "nack"; + const [id] = inFlight.splice(pick(inFlight.length), 1); + await queue.nackMessage({ + orgId: authenticatedEnvDev.organization.id, + messageId: id!, + retryAt: Date.now(), + skipDequeueProcessing: true, + }); + } + + // closure invariant: every ckIndex member is a ckVtime member. The + // converse may transiently not hold (stale ckVtime entries GC on scan). + const members = await queue.redis.zrange(ckIndexKey, 0, -1); + for (const member of members) { + const tag = await queue.redis.zscore(ckVtimeKey, member); + expect( + tag, + `step ${step} (${opName}): ${member} in ckIndex but not ckVtime` + ).not.toBeNull(); + } + } + } finally { + await queue.quit(); + } + }); }); From c9f51e185d885a3ac782903bb516b40e3ed6f252 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 10:52:07 +0100 Subject: [PATCH 06/17] test(run-engine): fairness scenarios on the real batched dequeue path Task 6. New ckVtimeFairness.test.ts drives the real batched Lua (maxCount=10) with flag-ON-vs-OFF ratio assertions over 5 ported spike scenarios, closing the spike's maxCount=1 fidelity gap. ckSkew/ckTrickle: starved-key wait ON <= 0.3x OFF; ckSybil (the case per-key caps cannot fix): light wait ON <= 0.7x OFF + first serve within 3 steps; ckBalanced no-harm; ckHeavyIdle equal drain steps (work conservation). Uses envConcurrencyLimit 1 to force serialization so the ON/OFF contrast is non-vacuous (one-msg-per-variant-per-call would otherwise hide order at limit 4). Reviewer traced each OFF baseline through the real Lua to confirm the assertions genuinely discriminate. --- .../run-queue/tests/ckVtimeFairness.test.ts | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts new file mode 100644 index 00000000000..8b1fde18c43 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts @@ -0,0 +1,468 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { Decimal } from "@trigger.dev/database"; +import { appendFileSync } from "node:fs"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import type { InputPayload } from "../types.js"; + +// Fairness scenarios driven through the REAL batched dequeue path (maxCount 10), +// closing the spike's maxCount=1 fidelity gap. Every assertion is a ratio between +// a flag-ON and a flag-OFF run of the same scenario (identical enqueue order and +// timestamps), so the tests are stable in CI. +// +// Scenario shapes are ported from the throwaway fairness spike (ckScenarios.ts / +// capsFairness.bench.test.ts): the message counts and head-age structure are +// copied as values, nothing is imported from the spike. +// +// Harness: a deterministic step loop. All messages are enqueued before step 0 +// with explicit past timestamps (a pre-existing backlog), so every message's +// logical arrival step is 0 and its wait is simply the step it was served at. +// Each step makes one dequeue call with maxCount 10, records the serves, then +// acks in-flight messages whose logical hold has elapsed (servedAt + hold <= +// step), which is how the env concurrency contends across steps. No wall-clock +// sleeps and no randomness anywhere. + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +function createQueue(redisContainer: any, keyPrefix: string, vtimeEnabled: boolean) { + return new RunQueue({ + ...testOptions, + // The step loop drives every op itself (testDequeueFromMasterQueue + + // skipDequeueProcessing), so the autonomous master-queue consumers and the + // background worker must not race it. + masterQueueConsumersDisabled: true, + workerOptions: { disabled: true }, + ckVirtualTimeScheduling: { + enabled: vtimeEnabled, + }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix, + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix, + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); +} + +function makeMessage(overrides: Partial = {}): InputPayload { + return { + runId: "r1", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, + ...overrides, + }; +} + +type ScenarioMessage = { runId: string; ck: string; timestamp: number }; + +type Scenario = { + name: string; + messages: ScenarioMessage[]; + // Effective env concurrency for the run (burst factor is pinned to 1.0). + // This is the contention knob: it caps how many serves fit in one dequeue + // call (actualMaxCount = min(maxCount, available env capacity)). + envConcurrencyLimit: number; + // Logical hold: a served message occupies its env slot until the end of + // step servedAt + holdSteps, when it is acked. + holdSteps: number; + // Safety cap so a work-conservation bug fails the count assertions instead + // of hanging the test. + maxSteps: number; +}; + +type ServeRecord = { step: number; ck: string; messageId: string }; + +type ScenarioResult = { + serves: ServeRecord[]; + // step at which the last message was served + drainStep: number; + // serves that happened in steps where >= 2 keys still had queued backlog + contentionServes: { total: number; byCk: Map }; +}; + +async function runScenario( + redisContainer: any, + scenario: Scenario, + vtimeEnabled: boolean +): Promise { + // Separate key prefix per run: the ON and OFF runs of a scenario share one + // Redis container but never share state. + const keyPrefix = `runqueue:test:${scenario.name}:${vtimeEnabled ? "on" : "off"}:`; + const queue = createQueue(redisContainer, keyPrefix, vtimeEnabled); + + try { + const env = { + ...authenticatedEnvDev, + maximumConcurrencyLimit: scenario.envConcurrencyLimit, + concurrencyLimitBurstFactor: new Decimal(1), + }; + await queue.updateEnvConcurrencyLimits(env); + + for (const msg of scenario.messages) { + await queue.enqueueMessage({ + env, + message: makeMessage({ + runId: msg.runId, + concurrencyKey: msg.ck, + timestamp: msg.timestamp, + }), + workerQueue: env.id, + skipDequeueProcessing: true, + }); + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(env.id, 2); + const total = scenario.messages.length; + + const remaining = new Map(); + for (const m of scenario.messages) { + remaining.set(m.ck, (remaining.get(m.ck) ?? 0) + 1); + } + + const serves: ServeRecord[] = []; + const inFlight: { messageId: string; servedAtStep: number }[] = []; + const contentionServes = { total: 0, byCk: new Map() }; + let drainStep = -1; + + for (let step = 0; step < scenario.maxSteps && serves.length < total; step++) { + // evaluated before the dequeue: does this step have cross-key contention? + let keysWithBacklog = 0; + for (const count of remaining.values()) { + if (count > 0) keysWithBacklog++; + } + + const messages = await queue.testDequeueFromMasterQueue(shard, env.id, 10); + + for (const m of messages) { + const ck = m.message.concurrencyKey ?? ""; + serves.push({ step, ck, messageId: m.messageId }); + remaining.set(ck, (remaining.get(ck) ?? 0) - 1); + inFlight.push({ messageId: m.messageId, servedAtStep: step }); + if (keysWithBacklog >= 2) { + contentionServes.total++; + contentionServes.byCk.set(ck, (contentionServes.byCk.get(ck) ?? 0) + 1); + } + if (serves.length === total) { + drainStep = step; + } + } + + // release env/queue concurrency for serves whose hold has elapsed + for (let i = inFlight.length - 1; i >= 0; i--) { + const entry = inFlight[i]!; + if (entry.servedAtStep + scenario.holdSteps <= step) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, entry.messageId, { + skipDequeueProcessing: true, + }); + inFlight.splice(i, 1); + } + } + } + + return { serves, drainStep, contentionServes }; + } finally { + await queue.quit(); + } +} + +// Wait per message = serve step - arrival step, and arrival is step 0 for the +// whole pre-enqueued backlog, so the wait is just the serve step. +function meanWait(result: ScenarioResult, matches: (ck: string) => boolean): number { + const waits = result.serves.filter((s) => matches(s.ck)).map((s) => s.step); + expect(waits.length).toBeGreaterThan(0); + return waits.reduce((a, b) => a + b, 0) / waits.length; +} + +function firstServeStep(result: ScenarioResult, matches: (ck: string) => boolean): number { + const first = result.serves.find((s) => matches(s.ck)); + expect(first).toBeDefined(); + return first!.step; +} + +// No loss and no double-serve, in both runs. +function assertConservation(scenario: Scenario, on: ScenarioResult, off: ScenarioResult) { + expect(on.serves.length).toBe(scenario.messages.length); + expect(off.serves.length).toBe(scenario.messages.length); + expect(new Set(on.serves.map((s) => s.messageId)).size).toBe(scenario.messages.length); + expect(new Set(off.serves.map((s) => s.messageId)).size).toBe(scenario.messages.length); +} + +function debugLog(name: string, data: Record) { + if (process.env.CK_FAIRNESS_DEBUG) { + // the test reporter swallows console output, so append to a file instead + appendFileSync( + process.env.CK_FAIRNESS_DEBUG, + `[ckVtimeFairness] ${name} ${JSON.stringify(data)}\n` + ); + } +} + +vi.setConfig({ testTimeout: 120_000 }); + +describe("CK virtual-time fairness on the real batched dequeue path", () => { + // ckSkew (spike shape): one heavy key with a 120-message backlog on an old + // shared head, 4 light keys with 10 messages each on later heads. + // + // Contention regime: env limit 1, hold 3. The batched dequeue serves at most + // one message per variant per call, so at env limit 4 (the spike's driver + // setting) a single heavy key cannot crowd out 4 light keys at all: both + // flags serve every light key each round and the ON/OFF ratio sits near 1. + // The head-age starvation the spike measured appears on this path when env + // capacity serializes the calls (limit 1): flag OFF then always picks the + // globally oldest head, which is heavy for its whole backlog. + redisTest("ckSkew: light keys stop waiting behind the heavy backlog", async ({ + redisContainer, + }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 120; i++) { + messages.push({ runId: `heavy-${i}`, ck: "heavy", timestamp: t0 }); + } + for (let i = 0; i < 10; i++) { + for (let k = 0; k < 4; k++) { + messages.push({ + runId: `light${k}-${i}`, + ck: `light${k}`, + timestamp: t0 + 10_000 + i * 4 + k, + }); + } + } + const scenario: Scenario = { + name: "ckSkew", + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + const isLight = (ck: string) => ck.startsWith("light"); + const onWait = meanWait(on, isLight); + const offWait = meanWait(off, isLight); + debugLog("ckSkew", { onWait, offWait, ratio: onWait / offWait }); + + // Heavy's wait may rise under the fair order; that is expected and not + // asserted down. + expect(onWait).toBeLessThanOrEqual(0.3 * offWait); + }); + + // ckTrickle (spike shape): one bulk key with a 120-message backlog on an old + // shared head, two trickle keys with 15 messages each on later heads. Same + // serialized contention regime as ckSkew, same assertion. + redisTest("ckTrickle: trickle keys stop waiting behind the bulk backlog", async ({ + redisContainer, + }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 120; i++) { + messages.push({ runId: `bulk-${i}`, ck: "bulk", timestamp: t0 }); + } + for (let i = 0; i < 15; i++) { + for (let k = 0; k < 2; k++) { + messages.push({ + runId: `trickle${k}-${i}`, + ck: `trickle${k}`, + timestamp: t0 + 10_000 + i * 2 + k, + }); + } + } + const scenario: Scenario = { + name: "ckTrickle", + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + const isTrickle = (ck: string) => ck.startsWith("trickle"); + const onWait = meanWait(on, isTrickle); + const offWait = meanWait(off, isTrickle); + debugLog("ckTrickle", { onWait, offWait, ratio: onWait / offWait }); + + expect(onWait).toBeLessThanOrEqual(0.3 * offWait); + }); + + // ckSybil (spike shape, the case per-key caps cannot fix): 20 attacker keys + // with 8 messages each, all on older heads, and 1 light key with 10 newer + // messages. 21 variants against a batch of 10 exercises the batched path + // properly: flag OFF walks the age order and only reaches the light key when + // the attackers are nearly drained; flag ON serves the light key from the + // floor on its first fair round. + redisTest("ckSybil: many attacker keys cannot starve a light key", async ({ + redisContainer, + }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 8; i++) { + for (let k = 0; k < 20; k++) { + const ck = `att${String(k).padStart(2, "0")}`; + messages.push({ runId: `${ck}-${i}`, ck, timestamp: t0 + i * 20 + k }); + } + } + for (let i = 0; i < 10; i++) { + messages.push({ runId: `light-${i}`, ck: "light", timestamp: t0 + 50_000 + i }); + } + const scenario: Scenario = { + name: "ckSybil", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 300, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + const isLight = (ck: string) => ck === "light"; + + // Reachability at the floor: enqueue registered the light key at the + // floor, so it is served within the first 3 steps even though 20 attacker + // variants sit ahead of it in age order. + const onFirstServe = firstServeStep(on, isLight); + expect(onFirstServe).toBeLessThanOrEqual(2); + + const onWait = meanWait(on, isLight); + const offWait = meanWait(off, isLight); + + // Contention-window share (directional, per the spike's confounding + // caveat; the wait ratio is the headline): over the steps where >= 2 keys + // had queued backlog, light's served fraction is at least half its fair + // share of 1/21. + const lightContentionServes = on.contentionServes.byCk.get("light") ?? 0; + const lightShare = lightContentionServes / on.contentionServes.total; + + debugLog("ckSybil", { + onWait, + offWait, + ratio: onWait / offWait, + onFirstServe, + lightShare, + fairShare: 1 / 21, + }); + + expect(onWait).toBeLessThanOrEqual(0.7 * offWait); + expect(lightShare).toBeGreaterThanOrEqual(0.5 * (1 / 21)); + }); + + // ckBalanced (spike shape, no-harm check): 4 symmetric keys with 25 messages + // each. The fair order must not make the symmetric case worse. + redisTest("ckBalanced: fair order does not hurt the symmetric case", async ({ + redisContainer, + }) => { + const t0 = Date.now() - 500_000; + const cks = ["bal0", "bal1", "bal2", "bal3"]; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 25; i++) { + for (let k = 0; k < cks.length; k++) { + messages.push({ + runId: `${cks[k]}-${i}`, + ck: cks[k]!, + timestamp: t0 + i * 4 + k, + }); + } + } + const scenario: Scenario = { + name: "ckBalanced", + messages, + envConcurrencyLimit: 4, + holdSteps: 3, + maxSteps: 500, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + const maxPerKeyMeanWait = (result: ScenarioResult) => + Math.max(...cks.map((ck) => meanWait(result, (c) => c === ck))); + + const onMax = maxPerKeyMeanWait(on); + const offMax = maxPerKeyMeanWait(off); + debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax }); + + expect(onMax).toBeLessThanOrEqual(1.25 * offMax); + }); + + // ckHeavyIdle (spike shape, work conservation): a single key with 60 + // messages and nothing else contending. Any extra step to drain under the + // fair order is a work-conservation bug, so the step counts must be exactly + // equal. + redisTest("ckHeavyIdle: a lone key drains in exactly the same steps", async ({ + redisContainer, + }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 60; i++) { + messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i }); + } + const scenario: Scenario = { + name: "ckHeavyIdle", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 300, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + debugLog("ckHeavyIdle", { onDrainStep: on.drainStep, offDrainStep: off.drainStep }); + + expect(on.drainStep).toBeGreaterThanOrEqual(0); + expect(on.drainStep).toBe(off.drainStep); + }); +}); From 87de494a8f74b58959f23cff5f113381a92a5c69 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:22:07 +0100 Subject: [PATCH 07/17] test(run-engine): multi-consumer correctness + op-count budget Task 7. New ckVtimeConcurrency.test.ts: two concurrent consumer instances on one base queue serve every message exactly once (no double-serve, no lost message), ckVtime drains empty and the floor never rewinds; concurrent enqueue-during-dequeue never rewinds a tag; and a per-dequeue op-count budget (<= off + 50*(6+2*maxCount)) pins the vtime overhead. Verifies the atomic-single-Lua correctness story. --- .../tests/ckVtimeConcurrency.test.ts | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts new file mode 100644 index 00000000000..6c0003caeb9 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts @@ -0,0 +1,375 @@ +import { createRedisClient } from "@internal/redis"; +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { Decimal } from "@trigger.dev/database"; +import { setTimeout as sleep } from "node:timers/promises"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import type { InputPayload } from "../types.js"; + +// Multi-consumer / multi-shard correctness for CK virtual-time scheduling, plus +// an op-count budget pinning the per-dequeue overhead of the vtime path. +// +// The correctness argument for concurrent consumers is that every ckVtime / +// ckIndex mutation happens inside a single Lua script and Redis serialises +// scripts. These tests check the scripts do not assume any cross-call state: +// two RunQueue instances hammering the same keyspace must still serve every +// message exactly once, never rewind a tag, and leave the vtime state clean. + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +function createQueue(redisContainer: any, keyPrefix: string, vtimeEnabled: boolean) { + return new RunQueue({ + ...testOptions, + // These tests drive every dequeue themselves (testDequeueFromMasterQueue + + // skipDequeueProcessing). The ONLY concurrency is the explicit consumer + // loops below, so the autonomous master-queue consumers and the background + // worker must stay off in every instance. + masterQueueConsumersDisabled: true, + workerOptions: { disabled: true }, + ckVirtualTimeScheduling: { + enabled: vtimeEnabled, + }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix, + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix, + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); +} + +function makeMessage(overrides: Partial = {}): InputPayload { + return { + runId: "r1", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, + ...overrides, + }; +} + +// The ckVtime/ckIndex member for a variant is the fully-qualified variant queue +// key (org:proj:env:queue:...:ck:), which is exactly what queueKey() produces. +function variantName(ck: string): string { + return testOptions.keys.queueKey(authenticatedEnvDev, "task/my-task", ck); +} + +vi.setConfig({ testTimeout: 120_000 }); + +describe("CK virtual-time concurrency and op-count budget", () => { + redisTest("two consumers, one base queue, no corruption", async ({ redisContainer }) => { + const keyPrefix = "rq15:"; + // one instance for enqueues, two more (same Redis, same key prefix) as consumers + const producer = createQueue(redisContainer, keyPrefix, true); + const consumerA = createQueue(redisContainer, keyPrefix, true); + const consumerB = createQueue(redisContainer, keyPrefix, true); + try { + const t0 = Date.now() - 100_000; + const cks = ["a", "b", "c", "d", "e", "f"]; + const perKey = 30; + + const enqueuedIds = new Set(); + for (const ck of cks) { + for (let i = 0; i < perKey; i++) { + const runId = `r-${ck}-${i}`; + enqueuedIds.add(runId); + await producer.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId, concurrencyKey: ck, timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + + // shared across both consumer loops: messageId -> times served + const serveCounts = new Map(); + const floorSamples: number[] = []; + let floorRewind: { consumer: string; prev: number; next: number } | undefined; + + const runConsumer = async (name: string, queue: RunQueue) => { + let prevFloor = 0; + let iterations = 0; + while (serveCounts.size < enqueuedIds.size) { + iterations++; + if (iterations > 600) { + throw new Error( + `consumer ${name}: iteration cap hit with ${serveCounts.size}/${enqueuedIds.size} unique messages served` + ); + } + + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 5); + + // record serves immediately, so the exactly-once bookkeeping covers + // messages currently held by the other consumer too + for (const m of messages) { + serveCounts.set(m.messageId, (serveCounts.get(m.messageId) ?? 0) + 1); + } + + if (messages.length === 0) { + // nothing servable right now (the other consumer holds the slots); + // yield so its hold can elapse + await sleep(2); + } else { + // short hold before acking, so the two loops genuinely overlap + await sleep(3); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + // sample the floor between iterations: it must never decrease + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + if (floor < prevFloor && !floorRewind) { + floorRewind = { consumer: name, prev: prevFloor, next: floor }; + } + prevFloor = floor; + floorSamples.push(floor); + } + }; + + await Promise.all([runConsumer("A", consumerA), runConsumer("B", consumerB)]); + + // exactly once: the union of served IDs equals the enqueued set, no duplicates + const duplicates = [...serveCounts.entries()].filter(([, count]) => count > 1); + expect(duplicates).toEqual([]); + expect(serveCounts.size).toBe(enqueuedIds.size); + expect(new Set(serveCounts.keys())).toEqual(enqueuedIds); + + // the floor never rewound in either consumer's sample sequence + expect(floorRewind).toBeUndefined(); + + // after drain: every variant was GC'd from ckVtime.. + expect(await consumerA.redis.zcard(ckVtimeKey)).toBe(0); + // ..and the floor sits at the max it ever reached + const finalFloor = Number((await consumerA.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(finalFloor).toBe(Math.max(finalFloor, ...floorSamples)); + } finally { + await producer.quit(); + await consumerA.quit(); + await consumerB.quit(); + } + }); + + redisTest("concurrent enqueue during dequeue cannot rewind a tag", async ({ redisContainer }) => { + const queue = createQueue(redisContainer, "rq16:", true); + try { + const t0 = Date.now() - 100_000; + + // hot backlog large enough that it never drains (so it is never GC'd and + // re-registered, keeping the ZSCORE comparison meaningful), plus a + // competitor key so hot is not the only candidate + for (let i = 0; i < 12; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-hot-${i}`, concurrencyKey: "hot", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + for (let i = 0; i < 30; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-cold-${i}`, + concurrencyKey: "cold", + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const hotVariant = variantName("hot"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(hotVariant); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // enqueue registered hot at the initial floor + let prevTag = Number(await queue.redis.zscore(ckVtimeKey, hotVariant)); + expect(prevTag).toBe(0); + + let extra = 0; + for (let round = 0; round < 12; round++) { + // enqueues on the hot key racing a dequeue batch: the enqueue script's + // ZADD NX registration must never rewind the tag the dequeue script is + // advancing (advance-only writes) + const [, messages] = await Promise.all([ + (async () => { + for (let j = 0; j < 2; j++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-hot-extra-${extra++}`, + concurrencyKey: "hot", + timestamp: t0 + 1000 + round, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + })(), + queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 3), + ]); + + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + + const tag = await queue.redis.zscore(ckVtimeKey, hotVariant); + // never drained, so never GC'd + expect(tag, `round ${round}: hot variant missing from ckVtime`).not.toBeNull(); + expect(Number(tag), `round ${round}: tag rewound`).toBeGreaterThanOrEqual(prevTag); + prevTag = Number(tag); + } + + // hot was actually served along the way (the invariant wasn't vacuous) + expect(prevTag).toBeGreaterThan(0); + } finally { + await queue.quit(); + } + }); + + redisTest("op-count budget: vtime dequeue overhead is bounded", async ({ redisContainer }) => { + const maxCount = 5; + const dequeueCalls = 50; + const cks = ["a", "b", "c", "d", "e", "f"]; + const perKey = 30; + + // second plain ioredis client (no key prefix) for CONFIG RESETSTAT / INFO. + // Redis command stats are server-wide, so each phase resets them after its + // enqueues and reads them right after its 50th dequeue call. + const statsClient = createRedisClient({ + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }); + + // Runs one phase: identical data under a fresh keyspace, then 50 identical + // dequeue calls (ack immediately, so env concurrency never gates a serve + // and both phases fully drain the same 180 messages inside the window). + const runPhase = async (keyPrefix: string, vtimeEnabled: boolean) => { + const queue = createQueue(redisContainer, keyPrefix, vtimeEnabled); + try { + const t0 = Date.now() - 100_000; + for (const ck of cks) { + for (let i = 0; i < perKey; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-${ck}-${i}`, + concurrencyKey: ck, + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + await statsClient.call("CONFIG", "RESETSTAT"); + + let served = 0; + for (let call = 0; call < dequeueCalls; call++) { + const messages = await queue.testDequeueFromMasterQueue( + shard, + authenticatedEnvDev.id, + maxCount + ); + served += messages.length; + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + const info = await statsClient.info("commandstats"); + return { served, totalCalls: totalCommandCalls(info) }; + } finally { + await queue.quit(); + } + }; + + try { + const off = await runPhase("rq17off:", false); + const on = await runPhase("rq17on:", true); + + // both phases did identical work: the full 180 messages served and acked + expect(off.served).toBe(cks.length * perKey); + expect(on.served).toBe(cks.length * perKey); + + // Per dequeue call the vtime path adds at worst: GET floor, ZRANGE min, + // ZRANGE window, SET floor, EXPIRE, the pass-2 ZRANGEBYSCORE, plus per + // serve one ZSCORE and one ZADD. + const budget = dequeueCalls * (6 + 2 * maxCount); + expect( + on.totalCalls, + `on_total ${on.totalCalls} exceeds off_total ${off.totalCalls} + budget ${budget}` + ).toBeLessThanOrEqual(off.totalCalls + budget); + } finally { + await statsClient.quit(); + } + }); +}); + +// Sums calls= across every cmdstat_ line of INFO commandstats. Includes +// commands executed from inside Lua scripts, which is exactly what we want: +// the vtime overhead lives in the dequeue script body. +function totalCommandCalls(info: string): number { + let total = 0; + for (const line of info.split("\n")) { + const match = line.match(/^cmdstat_[^:]+:calls=(\d+)/); + if (match) { + total += Number(match[1]); + } + } + return total; +} From 6ec116b6491976f83e549964ffb7013f95e3da1b Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:35:35 +0100 Subject: [PATCH 08/17] test(run-engine): default-off regression proof Task 8. Flag-off test: with ckVirtualTimeScheduling absent, a mixed enqueue/dequeue/nack/ack sequence creates zero *ckVtime* keys and serves in strict head-timestamp (age) order, i.e. today's behaviour. createQueue extended to accept null for an absent option. The whole run-queue suite (16 files, 146 tests) is green with the feature code present and the flag off, proving byte-identical behaviour. --- .../src/run-queue/tests/ckVtime.test.ts | 103 +++++++++++++++++- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts index 101508196a6..4050289986a 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -40,17 +40,23 @@ type VtimeOverrides = { stateTtlSeconds?: number; }; -function createQueue(redisContainer: any, vtime: VtimeOverrides = {}) { +// vtime: overrides merged into an enabled ckVirtualTimeScheduling option, or +// null to omit the option entirely (flag off, the production default). +function createQueue(redisContainer: any, vtime: VtimeOverrides | null = {}) { return new RunQueue({ ...testOptions, // These tests drive every op themselves (testDequeueFromMasterQueue + skipDequeueProcessing), // so the autonomous master-queue consumers and background worker must not race them. masterQueueConsumersDisabled: true, workerOptions: { disabled: true }, - ckVirtualTimeScheduling: { - enabled: true, - ...vtime, - }, + ...(vtime === null + ? {} + : { + ckVirtualTimeScheduling: { + enabled: true, + ...vtime, + }, + }), queueSelectionStrategy: new FairQueueSelectionStrategy({ redis: { keyPrefix: "runqueue:test:", @@ -866,4 +872,91 @@ describe("CK virtual-time (SFQ) dequeue", () => { await queue.quit(); } }); + + redisTest( + "flag off creates no vtime keys and matches head-timestamp order", + async ({ redisContainer }) => { + // ckVirtualTimeScheduling ABSENT: the off path calls the pre-existing + // command names (enqueueMessage*CkTracked, dequeueMessagesFromCkQueueTracked, + // nackMessageCkTracked) whose defineCommand script text this feature never + // edited, so the stronger same-script-SHA guarantee holds by construction. + // What a test CAN observe is asserted here: no vtime state is ever created, + // and the dequeue order is head-timestamp (age) order, matching the + // pre-existing ckIndex.test.ts expectation. + const queue = createQueue(redisContainer, null); + try { + const t0 = Date.now() - 100_000; + + // 3 variants with distinct head ages: old < mid < new, 3 messages each. + const heads: Record = { + old: t0, + mid: t0 + 10_000, + new: t0 + 20_000, + }; + for (const [ck, head] of Object.entries(heads)) { + for (let i = 0; i < 3; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: head + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // The off-path command serves at most one message per variant per call, + // visiting variants in ckIndex (head-timestamp) order: oldest head first. + const first = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 5); + expect(first.map((m) => m.message.concurrencyKey)).toEqual(["old", "mid", "new"]); + + // nack old's head (immediate retry), ack the rest + const nackedId = first[0]!.messageId; + await queue.nackMessage({ + orgId: authenticatedEnvDev.organization.id, + messageId: nackedId, + retryAt: Date.now(), + skipDequeueProcessing: true, + }); + for (const m of first.slice(1)) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + + // Two more batched calls drain the original heads in age order each time. + for (let call = 0; call < 2; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 5); + expect(messages.map((m) => m.message.concurrencyKey)).toEqual(["old", "mid", "new"]); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + // Only the nacked message remains; it is re-served. + const last = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 5); + expect(last.length).toBe(1); + expect(last[0]!.messageId).toBe(nackedId); + expect(last[0]!.message.concurrencyKey).toBe("old"); + + // After the whole mixed sequence (enqueues, batched dequeues, a nack, + // acks, one message still in flight so the keyspace is non-empty) no + // vtime state exists at all: no :ckVtime, no :ckVtimeFloor. ioredis + // prepends the keyPrefix to the KEYS pattern, so this scans exactly + // this test's keyspace. + const allKeys = await queue.redis.keys("*"); + expect(allKeys.length).toBeGreaterThan(0); + expect(allKeys.filter((k) => k.includes("ckVtime"))).toEqual([]); + + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, nackedId, { + skipDequeueProcessing: true, + }); + } finally { + await queue.quit(); + } + } + ); }); From a43f4fe7c77cc4c016c5acb46a22c30db967dd88 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:42:44 +0100 Subject: [PATCH 09/17] feat(run-engine,webapp): wire ckVirtualTimeScheduling env flag (code-dark) Task 9. Threads the ckVirtualTimeScheduling option from RunEngineOptions.queue into the RunQueue constructor, and exposes RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED (+ quantum, window multiplier, state TTL) env vars in the webapp. Off by default at both layers (env default '0' -> undefined -> today's dequeue path). No behaviour change until explicitly enabled. --- apps/webapp/app/env.server.ts | 7 +++++++ apps/webapp/app/v3/runEngine.server.ts | 9 +++++++++ internal-packages/run-engine/src/engine/index.ts | 1 + internal-packages/run-engine/src/engine/types.ts | 4 ++++ 4 files changed, 21 insertions(+) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2d28827f7d7..478b62697dd 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -942,6 +942,13 @@ const EnvironmentSchema = z RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false), RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000), + // Fair (virtual-time) ordering across concurrency-key variants of a base queue. + // Off by default; when off the run queue behaves exactly as before. + RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED: z.string().default("0"), + RUN_ENGINE_CK_VTIME_QUANTUM: z.coerce.number().default(1), + RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER: z.coerce.number().default(3), + RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS: z.coerce.number().default(86400), + /** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL * will use this as their TTL, and runs with a TTL larger than this will be clamped. */ RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(), diff --git a/apps/webapp/app/v3/runEngine.server.ts b/apps/webapp/app/v3/runEngine.server.ts index 4d9e263d6be..6c38b6e9492 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -106,6 +106,15 @@ function createRunEngine() { batchMaxSize: env.RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE, batchMaxWaitMs: env.RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS, }, + ckVirtualTimeScheduling: + env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED === "1" + ? { + enabled: true, + quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM, + scanWindowMultiplier: env.RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER, + stateTtlSeconds: env.RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS, + } + : undefined, }, runLock: { redis: { diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 3c1f4330a0f..1db8ec92611 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -234,6 +234,7 @@ export class RunEngine { workerItemsSuffix: "ttl-worker:{queue:ttl-expiration:}items", visibilityTimeoutMs: options.queue?.ttlSystem?.visibilityTimeoutMs ?? 30_000, }, + ckVirtualTimeScheduling: options.queue?.ckVirtualTimeScheduling, }); this.worker = new Worker({ diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index bb1d6eb2fa9..902d61ed485 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -16,6 +16,7 @@ import { } from "@trigger.dev/redis-worker"; import type { ControlPlaneResolver } from "./controlPlaneResolver.js"; import type { FairQueueSelectionStrategyOptions } from "../run-queue/fairQueueSelectionStrategy.js"; +import type { RunQueueOptions } from "../run-queue/index.js"; import type { MinimalAuthenticatedEnvironment } from "../shared/index.js"; import type { LockRetryConfig } from "./locking.js"; import type { workerCatalog } from "./workerCatalog.js"; @@ -123,6 +124,9 @@ export type RunEngineOptions = { /** Max time (ms) to wait for more items before flushing a batch (default: 5000) */ batchMaxWaitMs?: number; }; + /** Fair (virtual-time) ordering across concurrency-key variants of a base queue. + * Passed through to RunQueue; off by default (undefined = today's behaviour). */ + ckVirtualTimeScheduling?: RunQueueOptions["ckVirtualTimeScheduling"]; }; runLock: { redis: RedisOptions; From 4b5c9abcb651fab0c67258bee956b30ecbf1d2b4 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 12:05:13 +0100 Subject: [PATCH 10/17] chore(run-engine): ship note + comment/format cleanup Task 10 (final). Adds the .server-changes note (ships dark, off by default), fixes three stale/misleading test comments (keyProducer var naming; the un-seeded test 4 comment; the flag-off KEYS-scan comment now correctly credits redisTest flushall), and applies format. No production logic changed. --- .../2026-07-24-ck-fair-scheduling.md | 6 + .../src/run-queue/tests/ckVtime.test.ts | 20 +- .../run-queue/tests/ckVtimeFairness.test.ts | 252 +++++++++--------- .../src/run-queue/tests/keyProducer.test.ts | 10 +- 4 files changed, 151 insertions(+), 137 deletions(-) create mode 100644 .server-changes/2026-07-24-ck-fair-scheduling.md diff --git a/.server-changes/2026-07-24-ck-fair-scheduling.md b/.server-changes/2026-07-24-ck-fair-scheduling.md new file mode 100644 index 00000000000..32ffaf3261d --- /dev/null +++ b/.server-changes/2026-07-24-ck-fair-scheduling.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The run queue can now schedule runs across concurrency key variants fairly, so one tenant or key with a large backlog can't starve runs waiting on other keys. This is opt-in via a flag and off by default, so nothing changes unless it's enabled. diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts index 4050289986a..64252a5141d 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -127,7 +127,8 @@ describe("CK virtual-time (SFQ) dequeue", () => { const lightVariant = variantName("light"); const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(heavyVariant); - // Task 4 (enqueue registration) isn't done yet; seed both variants at tag 0. + // Explicit seed is redundant now that enqueue registers variants at the + // floor itself; kept as a belt-and-braces fixture. await queue.redis.zadd(ckVtimeKey, 0, heavyVariant, 0, lightVariant); const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); @@ -537,8 +538,9 @@ describe("CK virtual-time (SFQ) dequeue", () => { const t0 = Date.now() - 100_000; // Enqueue on a variant but do NOT register it in ckVtime (simulating an - // enqueue from old code / before Task 4). Two messages so the variant - // survives its first serve and we can observe it was registered. + // enqueue from old code that predates enqueue-time registration, e.g. + // during a rolling deploy). Two messages so the variant survives its + // first serve and we can observe it was registered. for (let i = 0; i < 2; i++) { await queue.enqueueMessage({ env: authenticatedEnvDev, @@ -897,7 +899,11 @@ describe("CK virtual-time (SFQ) dequeue", () => { for (let i = 0; i < 3; i++) { await queue.enqueueMessage({ env: authenticatedEnvDev, - message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: head + i }), + message: makeMessage({ + runId: `r-${ck}-${i}`, + concurrencyKey: ck, + timestamp: head + i, + }), workerQueue: authenticatedEnvDev.id, skipDequeueProcessing: true, }); @@ -944,9 +950,9 @@ describe("CK virtual-time (SFQ) dequeue", () => { // After the whole mixed sequence (enqueues, batched dequeues, a nack, // acks, one message still in flight so the keyspace is non-empty) no - // vtime state exists at all: no :ckVtime, no :ckVtimeFloor. ioredis - // prepends the keyPrefix to the KEYS pattern, so this scans exactly - // this test's keyspace. + // vtime state exists at all: no :ckVtime, no :ckVtimeFloor. The KEYS + // scan is safe here because redisTest runs flushall before each test, + // so the DB only holds this test's keys. const allKeys = await queue.redis.keys("*"); expect(allKeys.length).toBeGreaterThan(0); expect(allKeys.filter((k) => k.includes("ckVtime"))).toEqual([]); diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts index 8b1fde18c43..87d19abfa29 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts @@ -250,86 +250,88 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => { // The head-age starvation the spike measured appears on this path when env // capacity serializes the calls (limit 1): flag OFF then always picks the // globally oldest head, which is heavy for its whole backlog. - redisTest("ckSkew: light keys stop waiting behind the heavy backlog", async ({ - redisContainer, - }) => { - const t0 = Date.now() - 500_000; - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 120; i++) { - messages.push({ runId: `heavy-${i}`, ck: "heavy", timestamp: t0 }); - } - for (let i = 0; i < 10; i++) { - for (let k = 0; k < 4; k++) { - messages.push({ - runId: `light${k}-${i}`, - ck: `light${k}`, - timestamp: t0 + 10_000 + i * 4 + k, - }); + redisTest( + "ckSkew: light keys stop waiting behind the heavy backlog", + async ({ redisContainer }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 120; i++) { + messages.push({ runId: `heavy-${i}`, ck: "heavy", timestamp: t0 }); } + for (let i = 0; i < 10; i++) { + for (let k = 0; k < 4; k++) { + messages.push({ + runId: `light${k}-${i}`, + ck: `light${k}`, + timestamp: t0 + 10_000 + i * 4 + k, + }); + } + } + const scenario: Scenario = { + name: "ckSkew", + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + const isLight = (ck: string) => ck.startsWith("light"); + const onWait = meanWait(on, isLight); + const offWait = meanWait(off, isLight); + debugLog("ckSkew", { onWait, offWait, ratio: onWait / offWait }); + + // Heavy's wait may rise under the fair order; that is expected and not + // asserted down. + expect(onWait).toBeLessThanOrEqual(0.3 * offWait); } - const scenario: Scenario = { - name: "ckSkew", - messages, - envConcurrencyLimit: 1, - holdSteps: 3, - maxSteps: 1_000, - }; - - const on = await runScenario(redisContainer, scenario, true); - const off = await runScenario(redisContainer, scenario, false); - - assertConservation(scenario, on, off); - - const isLight = (ck: string) => ck.startsWith("light"); - const onWait = meanWait(on, isLight); - const offWait = meanWait(off, isLight); - debugLog("ckSkew", { onWait, offWait, ratio: onWait / offWait }); - - // Heavy's wait may rise under the fair order; that is expected and not - // asserted down. - expect(onWait).toBeLessThanOrEqual(0.3 * offWait); - }); + ); // ckTrickle (spike shape): one bulk key with a 120-message backlog on an old // shared head, two trickle keys with 15 messages each on later heads. Same // serialized contention regime as ckSkew, same assertion. - redisTest("ckTrickle: trickle keys stop waiting behind the bulk backlog", async ({ - redisContainer, - }) => { - const t0 = Date.now() - 500_000; - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 120; i++) { - messages.push({ runId: `bulk-${i}`, ck: "bulk", timestamp: t0 }); - } - for (let i = 0; i < 15; i++) { - for (let k = 0; k < 2; k++) { - messages.push({ - runId: `trickle${k}-${i}`, - ck: `trickle${k}`, - timestamp: t0 + 10_000 + i * 2 + k, - }); + redisTest( + "ckTrickle: trickle keys stop waiting behind the bulk backlog", + async ({ redisContainer }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 120; i++) { + messages.push({ runId: `bulk-${i}`, ck: "bulk", timestamp: t0 }); } - } - const scenario: Scenario = { - name: "ckTrickle", - messages, - envConcurrencyLimit: 1, - holdSteps: 3, - maxSteps: 1_000, - }; + for (let i = 0; i < 15; i++) { + for (let k = 0; k < 2; k++) { + messages.push({ + runId: `trickle${k}-${i}`, + ck: `trickle${k}`, + timestamp: t0 + 10_000 + i * 2 + k, + }); + } + } + const scenario: Scenario = { + name: "ckTrickle", + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }; - const on = await runScenario(redisContainer, scenario, true); - const off = await runScenario(redisContainer, scenario, false); + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); - assertConservation(scenario, on, off); + assertConservation(scenario, on, off); - const isTrickle = (ck: string) => ck.startsWith("trickle"); - const onWait = meanWait(on, isTrickle); - const offWait = meanWait(off, isTrickle); - debugLog("ckTrickle", { onWait, offWait, ratio: onWait / offWait }); + const isTrickle = (ck: string) => ck.startsWith("trickle"); + const onWait = meanWait(on, isTrickle); + const offWait = meanWait(off, isTrickle); + debugLog("ckTrickle", { onWait, offWait, ratio: onWait / offWait }); - expect(onWait).toBeLessThanOrEqual(0.3 * offWait); - }); + expect(onWait).toBeLessThanOrEqual(0.3 * offWait); + } + ); // ckSybil (spike shape, the case per-key caps cannot fix): 20 attacker keys // with 8 messages each, all on older heads, and 1 light key with 10 newer @@ -337,9 +339,7 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => { // properly: flag OFF walks the age order and only reaches the light key when // the attackers are nearly drained; flag ON serves the light key from the // floor on its first fair round. - redisTest("ckSybil: many attacker keys cannot starve a light key", async ({ - redisContainer, - }) => { + redisTest("ckSybil: many attacker keys cannot starve a light key", async ({ redisContainer }) => { const t0 = Date.now() - 500_000; const messages: ScenarioMessage[] = []; for (let i = 0; i < 8; i++) { @@ -397,72 +397,74 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => { // ckBalanced (spike shape, no-harm check): 4 symmetric keys with 25 messages // each. The fair order must not make the symmetric case worse. - redisTest("ckBalanced: fair order does not hurt the symmetric case", async ({ - redisContainer, - }) => { - const t0 = Date.now() - 500_000; - const cks = ["bal0", "bal1", "bal2", "bal3"]; - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 25; i++) { - for (let k = 0; k < cks.length; k++) { - messages.push({ - runId: `${cks[k]}-${i}`, - ck: cks[k]!, - timestamp: t0 + i * 4 + k, - }); + redisTest( + "ckBalanced: fair order does not hurt the symmetric case", + async ({ redisContainer }) => { + const t0 = Date.now() - 500_000; + const cks = ["bal0", "bal1", "bal2", "bal3"]; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 25; i++) { + for (let k = 0; k < cks.length; k++) { + messages.push({ + runId: `${cks[k]}-${i}`, + ck: cks[k]!, + timestamp: t0 + i * 4 + k, + }); + } } - } - const scenario: Scenario = { - name: "ckBalanced", - messages, - envConcurrencyLimit: 4, - holdSteps: 3, - maxSteps: 500, - }; + const scenario: Scenario = { + name: "ckBalanced", + messages, + envConcurrencyLimit: 4, + holdSteps: 3, + maxSteps: 500, + }; - const on = await runScenario(redisContainer, scenario, true); - const off = await runScenario(redisContainer, scenario, false); + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); - assertConservation(scenario, on, off); + assertConservation(scenario, on, off); - const maxPerKeyMeanWait = (result: ScenarioResult) => - Math.max(...cks.map((ck) => meanWait(result, (c) => c === ck))); + const maxPerKeyMeanWait = (result: ScenarioResult) => + Math.max(...cks.map((ck) => meanWait(result, (c) => c === ck))); - const onMax = maxPerKeyMeanWait(on); - const offMax = maxPerKeyMeanWait(off); - debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax }); + const onMax = maxPerKeyMeanWait(on); + const offMax = maxPerKeyMeanWait(off); + debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax }); - expect(onMax).toBeLessThanOrEqual(1.25 * offMax); - }); + expect(onMax).toBeLessThanOrEqual(1.25 * offMax); + } + ); // ckHeavyIdle (spike shape, work conservation): a single key with 60 // messages and nothing else contending. Any extra step to drain under the // fair order is a work-conservation bug, so the step counts must be exactly // equal. - redisTest("ckHeavyIdle: a lone key drains in exactly the same steps", async ({ - redisContainer, - }) => { - const t0 = Date.now() - 500_000; - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 60; i++) { - messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i }); - } - const scenario: Scenario = { - name: "ckHeavyIdle", - messages, - envConcurrencyLimit: 25, - holdSteps: 3, - maxSteps: 300, - }; + redisTest( + "ckHeavyIdle: a lone key drains in exactly the same steps", + async ({ redisContainer }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 60; i++) { + messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i }); + } + const scenario: Scenario = { + name: "ckHeavyIdle", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 300, + }; - const on = await runScenario(redisContainer, scenario, true); - const off = await runScenario(redisContainer, scenario, false); + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); - assertConservation(scenario, on, off); + assertConservation(scenario, on, off); - debugLog("ckHeavyIdle", { onDrainStep: on.drainStep, offDrainStep: off.drainStep }); + debugLog("ckHeavyIdle", { onDrainStep: on.drainStep, offDrainStep: off.drainStep }); - expect(on.drainStep).toBeGreaterThanOrEqual(0); - expect(on.drainStep).toBe(off.drainStep); - }); + expect(on.drainStep).toBeGreaterThanOrEqual(0); + expect(on.drainStep).toBe(off.drainStep); + } + ); }); diff --git a/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts index 8fcf5e079c3..1f61ed1cfc7 100644 --- a/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts @@ -434,17 +434,17 @@ describe("KeyProducer", () => { }); it("produces ckVtime keys from a CK variant queue name", () => { - const keys = new RunQueueFullKeyProducer(); + const keyProducer = new RunQueueFullKeyProducer(); const q = "{org:o1}:proj:p1:env:e1:queue:task/my-task:ck:tenant-a"; - expect(keys.ckVtimeKeyFromQueue(q)).toBe( + expect(keyProducer.ckVtimeKeyFromQueue(q)).toBe( "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtime" ); - expect(keys.ckVtimeFloorKeyFromQueue(q)).toBe( + expect(keyProducer.ckVtimeFloorKeyFromQueue(q)).toBe( "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtimeFloor" ); // ck wildcard and base-queue inputs normalise the same way - expect(keys.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe( - keys.ckVtimeKeyFromQueue(q) + expect(keyProducer.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe( + keyProducer.ckVtimeKeyFromQueue(q) ); }); }); From b5967ab09ee954c4c1ca1563d49ce20c2ef35c9e Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 13:53:29 +0100 Subject: [PATCH 11/17] fix(run-engine,webapp): address whole-branch adversarial review Three-model blind review (no Critical). Fixes: - Refresh ckVtimeFloor TTL on enqueue/nack, not just dequeue: a dequeue-quiescent but enqueue-active base queue could expire the floor key while ckVtime survived, making a new variant register at 0 and jump the backlog (fairness inversion). - tostring() the vtime tag advance so a fractional quantum/weight is not truncated to an integer by Redis's Lua-number ZADD conversion (silent tag freeze). - Validate the env config: enable flag now uses BoolEnv (so =true/1/yes work, not only '1'); quantum/windowMultiplier/stateTtlSeconds are int().positive() (a 0 TTL errored SET ... EX 0 and stopped all CK dequeues; a 0 multiplier caused a full ZRANGE scan). Constructor clamps as defense-in-depth. - Tests: floor-not-lost regression (H1), large-N (>window) sharding no-starvation, tightened ckBalanced no-harm bound, corrected op-count budget (EXISTS = 7 fixed). - Softened a dangling plan-doc path in a comment. Old Lua command bodies remain byte-identical (edits are in the ...Vtime... commands + env/wiring/tests only). --- apps/webapp/app/env.server.ts | 8 +- apps/webapp/app/v3/runEngine.server.ts | 2 +- .../run-engine/src/run-queue/index.ts | 25 +++-- .../src/run-queue/tests/ckVtime.test.ts | 93 +++++++++++++++++++ .../tests/ckVtimeConcurrency.test.ts | 8 +- .../run-queue/tests/ckVtimeFairness.test.ts | 63 ++++++++++++- 6 files changed, 183 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 478b62697dd..a364ddc0ecc 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -944,10 +944,10 @@ const EnvironmentSchema = z // Fair (virtual-time) ordering across concurrency-key variants of a base queue. // Off by default; when off the run queue behaves exactly as before. - RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED: z.string().default("0"), - RUN_ENGINE_CK_VTIME_QUANTUM: z.coerce.number().default(1), - RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER: z.coerce.number().default(3), - RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS: z.coerce.number().default(86400), + RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED: BoolEnv.default(false), + RUN_ENGINE_CK_VTIME_QUANTUM: z.coerce.number().int().positive().default(1), + RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER: z.coerce.number().int().positive().default(3), + RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS: z.coerce.number().int().positive().default(86400), /** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL * will use this as their TTL, and runs with a TTL larger than this will be clamped. */ diff --git a/apps/webapp/app/v3/runEngine.server.ts b/apps/webapp/app/v3/runEngine.server.ts index 6c38b6e9492..de7e619947f 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -107,7 +107,7 @@ function createRunEngine() { batchMaxWaitMs: env.RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS, }, ckVirtualTimeScheduling: - env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED === "1" + env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED ? { enabled: true, quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM, diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 946ba0ab103..70fe414bc85 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -121,8 +121,8 @@ export type RunQueueOptions = { /** * Fair (virtual-time / SFQ) ordering across concurrency-key variants of a * base queue. Off by default; when off, the exact pre-existing Lua commands - * run and no vtime keys are created. See docs/superpowers/plans/ - * 2026-07-23-ck-virtual-time-scheduling-plan.md. + * run and no vtime keys are created. See the CK virtual-time scheduling + * design for the ordering model. */ ckVirtualTimeScheduling?: { enabled: boolean; @@ -226,9 +226,19 @@ export class RunQueue { this.shardCount = options.shardCount ?? 2; this.counterTtlSeconds = options.counterTtlSeconds ?? 86400; this.#ckVtimeEnabled = options.ckVirtualTimeScheduling?.enabled ?? false; - this.#ckVtimeQuantum = options.ckVirtualTimeScheduling?.quantum ?? 1; - this.#ckVtimeWindowMultiplier = options.ckVirtualTimeScheduling?.scanWindowMultiplier ?? 3; - this.#ckVtimeStateTtl = options.ckVirtualTimeScheduling?.stateTtlSeconds ?? 86400; + // Defense-in-depth: clamp so a directly-constructed RunQueue can't get bad + // values that would freeze tags (quantum <= 0) or force an O(N) scan / + // EX 0 error (multiplier / ttl <= 0). + const resolvedQuantum = options.ckVirtualTimeScheduling?.quantum ?? 1; + this.#ckVtimeQuantum = resolvedQuantum > 0 ? resolvedQuantum : 1; + this.#ckVtimeWindowMultiplier = Math.max( + 1, + Math.floor(options.ckVirtualTimeScheduling?.scanWindowMultiplier ?? 3) + ); + this.#ckVtimeStateTtl = Math.max( + 1, + Math.floor(options.ckVirtualTimeScheduling?.stateTtlSeconds ?? 86400) + ); this.retryOptions = options.retryOptions ?? defaultRetrySettings; this.redis = createRedisClient(options.redis, { onError: (error) => { @@ -3918,6 +3928,7 @@ end local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName) redis.call('EXPIRE', ckVtimeKey, stateTtl) +redis.call('EXPIRE', ckVtimeFloorKey, stateTtl) -- Rebalance master queue with ck:* member local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') @@ -4045,6 +4056,7 @@ end local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName) redis.call('EXPIRE', ckVtimeKey, stateTtl) +redis.call('EXPIRE', ckVtimeFloorKey, stateTtl) -- Rebalance master queue with ck:* member local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') @@ -4826,7 +4838,7 @@ local function tryServe(ckQueueName) local weight = 1 local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor) if tag < floor then tag = floor end - redis.call('ZADD', ckVtimeKey, tag + (quantum / weight), ckQueueName) + redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName) end else redis.call('ZREM', fullQueueKey, messageId) @@ -5577,6 +5589,7 @@ end local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' redis.call('ZADD', ckVtimeKey, 'NX', vfloor, messageQueueName) redis.call('EXPIRE', ckVtimeKey, stateTtl) +redis.call('EXPIRE', ckVtimeFloorKey, stateTtl) -- Rebalance master queue with ck:* member local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts index 64252a5141d..98b0ac73d17 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts @@ -397,6 +397,99 @@ describe("CK virtual-time (SFQ) dequeue", () => { } ); + // H1 regression: the floor key must not be allowed to expire while ckVtime + // survives. Before the fix, only the dequeue command refreshed the floor + // key's TTL, so a dequeue-quiescent + enqueue-active base queue let the floor + // key expire underneath a live ckVtime; a brand-new variant then read a + // missing floor as 0 and jumped ahead of the whole established backlog. The + // enqueue/nack registration paths now refresh the floor key TTL too. + redisTest( + "enqueue refreshes the floor key TTL and a new variant registers at the current floor", + async ({ redisContainer }) => { + const stateTtlSeconds = 3600; + const queue = createQueue(redisContainer, { stateTtlSeconds }); + try { + const t0 = Date.now() - 100_000; + const cks = ["a", "b"]; + + for (const ck of cks) { + for (let i = 0; i < 25; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-${ck}-${i}`, + concurrencyKey: ck, + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(variantName("a")); + const ckVtimeFloorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(variantName("a")); + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + + // Drive tags and the floor above 0 with a run of serves. + for (let call = 0; call < 20; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + + const floor = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floor).toBeGreaterThan(10); + expect(await queue.redis.exists(ckVtimeKey)).toBe(1); + + // Simulate the floor key's TTL decaying toward expiry while dequeues are + // quiescent. Without the fix, only a dequeue would ever bump it back. + await queue.redis.pexpire(ckVtimeFloorKey, 2_000); + + // WITHOUT dequeuing, enqueue several more messages on an existing + // variant. The enqueue registration path must refresh the floor key TTL. + for (let i = 0; i < 5; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-a-more-${i}`, + concurrencyKey: "a", + timestamp: t0 + 500 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + // The floor key TTL was pushed back up to (about) stateTtl, well above + // the 2s decay we forced. + const floorPttl = await queue.redis.pttl(ckVtimeFloorKey); + expect(floorPttl).toBeGreaterThan(2_000); + expect(floorPttl).toBeLessThanOrEqual(stateTtlSeconds * 1000); + + // Enqueue-only activity does not move the floor value itself. + const floorAfter = Number((await queue.redis.get(ckVtimeFloorKey)) ?? "0"); + expect(floorAfter).toBe(floor); + + // A brand-new variant enqueued now registers at the CURRENT floor, so it + // cannot leapfrog the established backlog back to 0. + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-fresh", concurrencyKey: "fresh", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + const freshTag = Number(await queue.redis.zscore(ckVtimeKey, variantName("fresh"))); + expect(freshTag).toBe(floor); + } finally { + await queue.quit(); + } + } + ); + redisTest("no service, no advance", async ({ redisContainer }) => { const queue = createQueue(redisContainer); try { diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts index 6c0003caeb9..9edbd772839 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts @@ -346,10 +346,10 @@ describe("CK virtual-time concurrency and op-count budget", () => { expect(off.served).toBe(cks.length * perKey); expect(on.served).toBe(cks.length * perKey); - // Per dequeue call the vtime path adds at worst: GET floor, ZRANGE min, - // ZRANGE window, SET floor, EXPIRE, the pass-2 ZRANGEBYSCORE, plus per - // serve one ZSCORE and one ZADD. - const budget = dequeueCalls * (6 + 2 * maxCount); + // Per dequeue call the vtime path adds at worst 7 fixed ops: GET floor, + // ZRANGE min, ZRANGE window, the pass-2 ZRANGEBYSCORE, SET floor, + // EXISTS ckVtime, EXPIRE ckVtime — plus per serve one ZSCORE and one ZADD. + const budget = dequeueCalls * (7 + 2 * maxCount); expect( on.totalCalls, `on_total ${on.totalCalls} exceeds off_total ${off.totalCalls} + budget ${budget}` diff --git a/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts index 87d19abfa29..622b2cdd247 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts @@ -432,7 +432,68 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => { const offMax = maxPerKeyMeanWait(off); debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax }); - expect(onMax).toBeLessThanOrEqual(1.25 * offMax); + // Observed ratio is 1.0 (the fair order is neutral on the symmetric case), + // so allow only modest headroom rather than the original 1.25. + expect(onMax).toBeLessThanOrEqual(1.1 * offMax); + } + ); + + // ckManyKeys (sharding coverage): cardinality ABOVE the pass-1 fair window. + // The batched dequeue uses maxCount 10, so window = actualMaxCount * 3 = 30. + // With ~60 attacker keys (all on the same old head) plus 1 light key on a + // newer head, 61 variants sit above the 30-wide pass-1 ZRANGE window, so no + // single fair pass can even see every key. The property to hold is that this + // does NOT permanently starve the light key: as attackers advance their tags + // out of the bottom of the window, the light key (still at the floor) rises + // into it and gets served, and every message drains exactly once. A bounded + // first-serve delay is fine; permanent starvation or a stuck drain is not. + redisTest( + "ckManyKeys: light key is not starved when cardinality exceeds the fair window", + async ({ redisContainer }) => { + const t0 = Date.now() - 500_000; + const messages: ScenarioMessage[] = []; + const attackerCount = 60; + for (let i = 0; i < 8; i++) { + for (let k = 0; k < attackerCount; k++) { + const ck = `att${String(k).padStart(2, "0")}`; + // All attackers share the same old head timestamp (tied heads). + messages.push({ runId: `${ck}-${i}`, ck, timestamp: t0 }); + } + } + for (let i = 0; i < 10; i++) { + messages.push({ runId: `light-${i}`, ck: "light", timestamp: t0 + 50_000 + i }); + } + const scenario: Scenario = { + name: "ckManyKeys", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 1_000, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + // No loss and no double-serve in either run: the run terminates and every + // message (attackers + light) is served exactly once within maxSteps. + assertConservation(scenario, on, off); + + const isLight = (ck: string) => ck === "light"; + + // The light key IS eventually served (no permanent starvation) in both + // runs, and drains fully. + const onFirstServe = firstServeStep(on, isLight); + const offFirstServe = firstServeStep(off, isLight); + expect(on.drainStep).toBeGreaterThanOrEqual(0); + expect(off.drainStep).toBeGreaterThanOrEqual(0); + + debugLog("ckManyKeys", { + variants: attackerCount + 1, + onFirstServe, + offFirstServe, + onDrainStep: on.drainStep, + offDrainStep: off.drainStep, + }); } ); From 8cd78f6a12d4d6b7b2dadd76b7731f26a23ce1eb Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 13:54:26 +0100 Subject: [PATCH 12/17] docs(run-engine): record CK vtime known limitations for GA decision Captures the whole-branch review findings deliberately not code-fixed (bounded / self-healing / pre-existing): ckVtime tombstone drift on ack/TTL/DLQ/rollback paths and its 24h-TTL / key-delete mitigation; the pre-existing member-name tie-break among equal tags; future-scheduled variants occupying the pass-1 window under retry storms; and the rollout/rollback sequence. --- .../run-queue/CK_VTIME_KNOWN_LIMITATIONS.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md diff --git a/internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md b/internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md new file mode 100644 index 00000000000..43a6cd43308 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md @@ -0,0 +1,70 @@ +# CK virtual-time scheduling: known limitations (read before enabling) + +The feature ships behind `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` (off by default). +A three-model blind adversarial review found no Critical issues; the correctness +and safety fixes it surfaced are applied. The items below are the review findings +that were deliberately NOT code-fixed because they are bounded, self-healing, or +pre-existing. They are the checklist for the "enable in production" decision. + +## Bounded state drift on paths that don't GC `ckVtime` + +The vtime dequeue command GCs a drained variant from both `ckIndex` and `ckVtime`. +But `acknowledgeMessageCkTracked`, `expireTtlRuns`, `moveToDeadLetterQueueCkTracked`, +and the flag-off dequeue command do NOT remove a drained variant from `ckVtime` +(they were left byte-identical). Consequences, all bounded: + +- A low-tag tombstone (a variant emptied by ack/TTL/DLQ without a vtime serve) is + the minimum entry, so the very next vtime dequeue visits it first, finds the + queue empty, and GCs it: self-heals in ~1 call. It can pin the floor low for + that one call. +- A high-tag tombstone (a heavily-served variant whose remaining backlog is then + removed out-of-band) lingers until the floor climbs to its tag or the 24h state + TTL fires. Pure memory drift, does not affect fairness. +- Rollback (flag on -> off): variants drained by the old command leave inert + `ckVtime` entries. Old code never reads them; they expire within `stateTtl` + (default 24h) once the base queue stops receiving writes. To reclaim sooner, + delete the `*:ckVtime` / `*:ckVtimeFloor` keys after disabling. + +A full fix (vtime-aware ack/TTL/DLQ command variants) is deferred: it adds three +more command variants for a bounded, self-healing drift on a dark feature. + +## Tie-break among equal virtual-time tags is member-name order + +When variants tie at the same tag (a fresh batch at the floor: cold start, new +deploy, or a GC'd variant re-entering), pass 1's `ZRANGE ckVtime` falls back to +Redis's lexicographic member order, i.e. the fully-qualified queue name including +the client-chosen concurrency key. A lex-early name gets a first-serve head start +in a tie. This is PRE-EXISTING (the head-timestamp baseline ties the same way) and +bounded: tags diverge after the first serve, so it affects only first-serve order, +not long-run fairness. A future improvement is to tie-break by head age instead of +member name. Do not rank fairness on an untrusted string if that head start ever +matters at scale. + +## Future-scheduled / retry-backoff variants occupy pass-1 window slots + +Enqueue and nack register a variant in `ckVtime` even when its head message is +scheduled in the future (delayed run, nack backoff). Pass 1 selects by tag with no +readiness filter, so a burst of future-headed variants can fill the pass-1 window +(`maxCount * scanWindowMultiplier`, default 3x); actual serves then come from pass +2 (today's age order). Work conservation still holds (pass 2 is a superset), so +this is fairness degradation under a retry storm, not loss. Widen +`scanWindowMultiplier` if observed. + +## Minor operational notes + +- Idle-polling a CK queue whose only work is future-scheduled now does a couple of + extra Redis writes per poll (floor SET + EXPIRE) vs the old early-return. Bounded; + visible in Redis write metrics after enabling. +- `descriptorFromQueue` positional parsing mis-splits a concurrency key containing + a literal `:` (pre-existing; not introduced here). The vtime feature uses the + full queue key as the ZSET member, which is unaffected, but any code that parses + the member back into fields inherits the pre-existing limitation. + +## Rollout (from the plan) + +1. Deploy with the flag off (new command scripts registered, never called). +2. Enable on a staging cell; watch dequeue-latency spans and Redis op rates against + the op-count budget; run a sybil-shaped workload and confirm the light key's wait. +3. Enable in production; during the instance-rolling window behaviour interpolates + between age order and fair order (both endpoints safe). +4. Rollback = flip the env var off; stale vtime keys expire via TTL within 24h. From 3016b1b8e4f6fc16971d993be4045285c22ce2b6 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:05:37 +0100 Subject: [PATCH 13/17] docs(run-engine): plan + references for virtual-time CK fair scheduling Implementation and testing plan for the recommended run-queue multi-tenant fairness fix: score the concurrency-key dequeue by SFQ virtual time, layered under the concurrency caps. Keeps the three spike findings and the queueing-theory research as references; the throwaway spike harness/bench code is archived on the remote branch chore/fair-queueing-spike and is not carried onto main. The plan adds a parallel :ckVtime ZSET + floor (leaving ckIndex's timestamp domain intact and mixed-deploy-safe), a flag-selected two-pass dequeue command (vtime order then age-order fallback, so it never serves less than today), and a 19-test suite that exercises the real batched maxCount>1 path the spikes could not. --- ...6-07-23-ck-virtual-time-scheduling-plan.md | 829 ++++++++++++++++++ docs/superpowers/references/README.md | 43 + .../run-queue-fairness-base-queue-findings.md | 168 ++++ ...ue-fairness-caps-vs-scheduling-findings.md | 198 +++++ .../run-queue-fairness-ck-findings.md | 122 +++ .../references/run-queue-fairness-research.md | 133 +++ 6 files changed, 1493 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md create mode 100644 docs/superpowers/references/README.md create mode 100644 docs/superpowers/references/run-queue-fairness-base-queue-findings.md create mode 100644 docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md create mode 100644 docs/superpowers/references/run-queue-fairness-ck-findings.md create mode 100644 docs/superpowers/references/run-queue-fairness-research.md diff --git a/docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md b/docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md new file mode 100644 index 00000000000..2865c0ba1c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md @@ -0,0 +1,829 @@ +# Virtual-time (SFQ) scheduling for the concurrency-key dequeue + +Implementation and testing plan for the recommendation out of three fairness +spikes. The spike harness and benchmark code are archived on the remote branch +`chore/fair-queueing-spike` (throwaway, never merged); the findings and research +they produced are kept alongside this plan as references: +`docs/superpowers/references/run-queue-fairness-ck-findings.md`, +`.../run-queue-fairness-caps-vs-scheduling-findings.md`, and +`.../run-queue-fairness-research.md`. + +The recommendation: score the per-base-queue concurrency-key selection by +start-time fair queueing (SFQ) virtual time instead of head timestamp, inside the +real batched CK-dequeue Lua, layered UNDER the existing per-key concurrency gate +and the planned group caps. Caps bound occupancy; the fair order bounds wait under +contention (the Kubernetes-APF shape, and the Parekh-Gallager joint result). + +## Goal + +When many concurrency-key variants of one base queue are contending, the +dequeue order across variants follows SFQ virtual time (each variant advances +its own virtual clock by a quantum per serve; new variants join at a monotonic +floor). This fixes the #2617 starvation dynamic the spikes measured: a key +arriving behind a big backlog waits its fair turn instead of waiting for the +backlog to drain, and (unlike per-key caps) the fix survives a tenant sharding +its work across many keys, while staying work-conserving. + +Everything is behind a constructor feature flag. Flag off is byte-identical to +today: the exact same Lua scripts run and no new Redis keys are ever touched. + +## Architecture + +The design keeps `ckIndex` exactly as it is and adds a parallel virtual-time +ZSET. This is the load-bearing decision, so the reasoning up front: + +`ckIndex` scores are head-message timestamps, and three things depend on that +score domain staying timestamps: + +1. Time eligibility. `ZRANGEBYSCORE ckIndexKey -inf now` filters out variants + whose head message is scheduled in the future (delayed runs, nack backoff). + Virtual-time tags carry no wall-clock meaning, so they cannot express "not + available yet". +2. Master-queue rebalancing. Every CK Lua (enqueue, dequeue, ack, nack, the + sweeper) re-scores the `:ck:*` master-queue member from `ZRANGE ckIndexKey + 0 0 WITHSCORES`. The master queue is timestamp-ordered and compared against + `now`; writing virtual times there would corrupt the shard-level selection. +3. Every other writer. `enqueueMessageCkTracked`, `nackMessageCkTracked`, + `acknowledgeMessageCkTracked`, the concurrency sweeper (index.ts ~3733, + ~3847) all `ZADD ckIndexKey `. Rescoring only in the dequeue + Lua would leave a mixed score domain, and during a rolling deploy old + instances would keep writing timestamps regardless (the mixed-arity hazard + the caps plan warned about, in score-domain form). + +So: instead of changing `ckIndex`'s score domain, add per base queue + +- `{org:...}:...:queue::ckVtime`, a ZSET, member = the full CK-variant + queue name (the same member strings `ckIndex` holds), score = the variant's + next virtual start tag (the spike's `SfqCk.clock` value, i.e. start of last + serve + quantum). +- `{org:...}:...:queue::ckVtimeFloor`, a STRING holding the monotonic + floor (the CFS `min_vruntime` analogue from `disciplines.ts`). + +Both live under the same `{org:...}` hash tag as every other key of the base +queue, so cluster slotting is unchanged and one Lua script can touch all of +them atomically. + +The dequeue Lua (new command, flag-selected) runs two passes: + +- Pass 1 (fair order): take candidates from `ckVtime` by rank (lowest tag + first, `ZRANGE 0 W-1 WITHSCORES`), and for each run the existing + per-candidate logic unchanged: per-key concurrency gate, per-variant + time-eligibility check (`ZRANGEBYSCORE -inf now LIMIT 0 1`), TTL + branch, counters, `ckIndex` rebalance. On each successful serve, advance + that variant's tag (`ZADD ckVtimeKey max(tag, floor) + quantum/weight`) + before moving to the next candidate, so the state is correct per serve + within the batch. A skipped variant (at cap, or head in the future) keeps + its tag: no service, no advance, which is the SFQ rule. +- Pass 2 (fill + discovery): if pass 1 served fewer than `actualMaxCount`, + scan `ckIndex` in today's age order (`ZRANGEBYSCORE -inf now LIMIT 0 W`), + skip variants already attempted in pass 1, and serve the rest through the + same per-candidate logic, registering each served variant into `ckVtime`. + Pass 2 makes the new command a strict superset of today's: it can never + serve fewer messages than the current script would, so work conservation + and mixed-deploy discovery both hold by construction. + +Registration (how a variant gets INTO `ckVtime` before it is ever served): +every Lua that adds messages to a variant, i.e. the CK enqueue commands and +the CK nack command, gains a flag-selected variant that does +`ZADD ckVtimeKey NX ` after its existing `ckIndex` rebalance. +`NX` means registration can never rewind an advanced tag. This is what makes +the sybil case work: a brand-new light key is present in the vtime order at +the floor from its first enqueue, so it is reachable in pass 1 even when a +hundred attacker variants have older heads (which is exactly where today's +age-ordered `*3` window fails, per CAPS_FINDINGS). + +Closure argument for registration (state this as an invariant and test it): +a variant's queue becomes non-empty only via enqueue or nack, both of which +register. The sweeper and ack/release Luas only rebalance variants whose +queues are already non-empty, so they never need to register. The dequeue Lua +GCs a variant from BOTH `ckIndex` and `ckVtime` when its queue is empty, so +membership stays closed under all transitions. The one gap is old-code +enqueues during a rolling deploy, and pass 2 covers that (served via age +order, registered on serve). + +Batched-call semantics: the current Lua serves at most ONE message per variant +per call (`LIMIT 0, 1` per candidate, and ZSET members are unique in the +candidate list). The new command keeps that. Within one call the batch is +therefore one-serve-per-variant round robin over the `actualMaxCount` lowest +tags, and each serve's `ZADD` makes the NEXT call's order correct. This +deviates from pure SFQ within a single batch (pure SFQ could serve the same +far-behind variant several times in a row) but converges across calls, and +one-per-variant is itself a fair schedule. Preserving it also means zero +change to today's per-call throughput shape. + +Layering with caps: the per-key gate (`ckCurrentConcurrency < +queueConcurrencyLimit`) stays exactly where it is, ahead of the serve. The +planned Phase-1 `:groupConcurrency`/`:totalConcurrency` total cap and Phase-2 +`:ckLimits` per-key overrides slot into the same per-candidate position as +additional admission conditions when they land; virtual time only decides the +ORDER among candidates those gates admit. Nothing in this plan blocks or is +blocked by the caps work, and the fairQueue-level "queue at total" drop stays +untouched (the CK pick is below the `RunQueueSelectionStrategy` interface; +`fairQueueSelectionStrategy.ts` is not modified). + +Weights: concurrency keys carry no configured weight today, so every key gets +weight 1 (quantum advance of 1.0 per serve). The advance is written as +`quantum / weight` with `weight` a named local fixed at 1, so a future +per-key weight (e.g. a sparse `:ckWeights` HASH mirroring the Phase-2 +`:ckLimits` shape) is a one-line change at the marked site. Justification for +equal-weight first: the spikes only measured equal weights, no product surface +exists to set a weight, and SFQ's starvation fix does not depend on weights. + +State lifecycle (GC/TTL), since concurrency keys are client-chosen and +unbounded: + +- Per-variant GC: whenever the dequeue Lua finds a variant queue empty it + already `ZREM`s the variant from `ckIndex`; the new command also `ZREM`s it + from `ckVtime` at those sites. A GC'd key that returns re-registers at the + floor, which is standard SFQ flow re-entry (history is forgiven when a flow + drains; a drained flow was by definition not backlogged). +- Whole-key TTL: `ckVtime` and `ckVtimeFloor` get `EXPIRE ` + (default 86400, matching the `counterTtlSeconds` precedent) refreshed on + every write. An idle base queue's vtime state evaporates; on resumption + everyone re-enters at floor 0, which is a clean restart. If only the floor + key expires, tags in `ckVtime` still self-heal because every read applies + `max(tag, floor)` and the next dequeue re-advances the floor to the minimum + stored tag. +- Cardinality guard: tags are only created for variants that actually have + queued messages (registration happens on enqueue/nack, GC on empty), so + `ckVtime` cardinality is bounded by `ckIndex` cardinality plus transiently + stale entries awaiting scan-time GC or TTL expiry. + +Floor semantics: on each dequeue call, `floor = max(stored floor, score of +ckVtime rank 0)`, written back with the TTL. The floor never decreases (test +this), and a newly registered key's tag starts AT the floor, so it can never +be scheduled behind the accumulated backlog of long-running keys (the SFQ +property; this is the exact `SfqCk` logic from `disciplines.ts`, moved into +Lua with the Map replaced by the ZSET and the floor by the STRING). + +Numeric domain: tags are Redis doubles starting at 0 advancing by 1.0 per +serve; integer-exact to 2^53 serves per base queue, so precision is a +non-issue. + +The scan window: pass 1's window is `actualMaxCount * windowMultiplier` +(default multiplier 3, same as today, made configurable). The score domain of +the window changes meaning: today the window can hide the oldest ELIGIBLE +head behind at-cap variants with older heads; under vtime it can hide the +lowest ELIGIBLE tag behind at-cap or future-scheduled variants with lower +tags. Those clogging variants keep low tags while skipped (no serve, no +advance), so the failure shape is symmetric with today's, and it is the same +class of limitation CAPS_FINDINGS documents for the `*3` window rather than a +new one. We do not widen the default; we make the multiplier an option so an +operator can widen it if per-key caps plus heavy nack backoff ever clog a +window in practice, and pass 2 guarantees the call still finds work. + +## Tech stack + +- Redis Lua (ioredis `defineCommand`) in + `internal-packages/run-engine/src/run-queue/index.ts`, following the + existing tracked-command patterns. +- TypeScript for options plumbing and call-site selection. +- vitest + `@internal/testcontainers` (`redisTest`) for all tests. No mocks. +- Verification: `pnpm run typecheck --filter @internal/run-engine` and + `cd internal-packages/run-engine && pnpm run test --run`. + +## Global constraints + +- Flag off must be byte-identical: off-path call sites keep calling the + existing command names whose script text is not edited at all. New + behaviour lives only in NEW command names (`...Vtime...`), selected in TS. + This is why we add command variants instead of threading an `enableVtime` + ARGV through existing scripts: an ARGV-gated single script would still be a + new script body (new SHA, new arity risks) even when the flag is off. +- `ckIndex`, the master queue, `fairQueueSelectionStrategy.ts`, and all + ack/release/sweeper Luas keep their current score domain and text. +- The dead untracked `dequeueMessagesFromCkQueue` (index.ts ~3999-4141) is not + touched and gets no vtime variant. +- All vtime state mutations happen inside single Lua scripts (atomic; Redis + serialises scripts, which is the whole multi-consumer correctness story). +- No process-memory scheduling state anywhere. +- Do not import anything from `fairness-spike-ck/` or `fairness-spike/` into + production code or the new tests; those directories are throwaway (their + own headers say delete before merge). Port logic and scenario shapes by + copying, with attribution comments. +- Zod stays at the repo-pinned version; no new dependencies. +- Formatting/lint before commit: `pnpm run format && pnpm run lint:fix`. + +## File structure + +Modify: + +- `internal-packages/run-engine/src/run-queue/keyProducer.ts` + (two new key builders + constants) +- `internal-packages/run-engine/src/run-queue/types.ts` + (`RunQueueKeyProducer` interface additions) +- `internal-packages/run-engine/src/run-queue/index.ts` + (options field; four new `defineCommand`s; TS module augmentation for them; + flag switches at the enqueue/nack/dequeue call sites; span attributes) +- `internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts` + (key builder tests) +- `internal-packages/run-engine/src/engine/types.ts` + (`RunEngineOptions["queue"].ckVirtualTimeScheduling`) +- `internal-packages/run-engine/src/engine/index.ts` + (pass the option through to `new RunQueue({...})`, ~line 196) +- `apps/webapp/app/env.server.ts` + (`RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` and friends) +- `apps/webapp/app/v3/runEngine.server.ts` + (wire env vars into the engine options, ~line 61 `queue:` block) + +Create: + +- `internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts` + (Lua behaviour: ordering, floor, tag init, advance-within-batch, GC, TTL, + registration, pass-2 fill, flag-off keyspace purity) +- `internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts` + (ported scenarios at batched maxCount, wait/share/work-conservation/sybil + assertions) +- `internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts` + (multi-consumer correctness, op-count budget) +- `.server-changes/2026-XX-XX-ck-fair-scheduling.md` (at PR time; note it + ships dark) + +## Tasks + +### Task 1: key producer additions + +Files: `keyProducer.ts`, `types.ts`, `tests/keyProducer.test.ts`. + +Test first (append to `tests/keyProducer.test.ts`, matching its existing +style): + +```ts +it("produces ckVtime keys from a CK variant queue name", () => { + const keys = new RunQueueFullKeyProducer(); + const q = "{org:o1}:proj:p1:env:e1:queue:task/my-task:ck:tenant-a"; + expect(keys.ckVtimeKeyFromQueue(q)).toBe( + "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtime" + ); + expect(keys.ckVtimeFloorKeyFromQueue(q)).toBe( + "{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtimeFloor" + ); + // ck wildcard and base-queue inputs normalise the same way + expect(keys.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe( + keys.ckVtimeKeyFromQueue(q) + ); +}); +``` + +Implementation in `keyProducer.ts`: add to `constants` + +```ts +CK_VTIME_PART: "ckVtime", +CK_VTIME_FLOOR_PART: "ckVtimeFloor", +``` + +and the builders (next to `ckIndexKeyFromQueue`): + +```ts +ckVtimeKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_PART}`; +} + +ckVtimeFloorKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_FLOOR_PART}`; +} +``` + +Add both signatures to `RunQueueKeyProducer` in `types.ts`. + +Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/keyProducer.test.ts --run` +(new tests pass, existing pass), then +`pnpm run typecheck --filter @internal/run-engine` (clean). + +### Task 2: options plumbing in RunQueue + +File: `index.ts` (RunQueueOptions, ~line 60). + +```ts +/** + * Fair (virtual-time / SFQ) ordering across concurrency-key variants of a + * base queue. Off by default; when off, the exact pre-existing Lua commands + * run and no vtime keys are created. See docs/superpowers/plans/ + * 2026-07-23-ck-virtual-time-scheduling-plan.md. + */ +ckVirtualTimeScheduling?: { + enabled: boolean; + /** Virtual-time advance per serve (dimensionless). Default 1. */ + quantum?: number; + /** Pass-1 candidate window = actualMaxCount * this. Default 3. */ + scanWindowMultiplier?: number; + /** EXPIRE applied to ckVtime/ckVtimeFloor on every write. Default 86400. */ + stateTtlSeconds?: number; +}; +``` + +Store resolved values once in the constructor (private readonly fields +`#ckVtimeEnabled`, `#ckVtimeQuantum`, `#ckVtimeWindowMultiplier`, +`#ckVtimeStateTtl`) so call sites read fields, never re-derive. + +Verify: `pnpm run typecheck --filter @internal/run-engine`. + +### Task 3: the vtime dequeue Lua (the core change) + +File: `index.ts`. New command `dequeueMessagesFromCkQueueVtimeTracked`, +`numberOfKeys: 12` (the 10 keys of `dequeueMessagesFromCkQueueTracked` plus +`ckVtimeKey`, `ckVtimeFloorKey`), plus its entry in the ioredis module +augmentation (next to the existing declaration at ~line 5591, same parameter +list plus `ckVtimeKey: string, ckVtimeFloorKey: string` after +`lengthCounterKey` and `quantum: string, windowMultiplier: string, +stateTtlSeconds: string` after `maxCount`). + +Write the failing tests FIRST in `tests/ckVtime.test.ts`. Scaffold the file +from `tests/ckIndex.test.ts` (same `testOptions`, `authenticatedEnvDev`, +`createQueue`, `makeMessage` helpers), with `createQueue` extended to accept +`ckVirtualTimeScheduling` overrides. Tests to write in this task: + +1. "vtime order beats head-timestamp order": enqueue 30 messages on + `ck: heavy` with timestamps `t0 .. t0+29`, then 3 messages on `ck: light` + at `t0+1000`. Register both (enqueue registration is Task 4; until then + the test seeds `ckVtime` directly with + `queue.redis.zadd(ckVtimeKey, 0, heavyVariant, 0, lightVariant)`). + Dequeue with `maxCount: 10` repeatedly (acking between calls to free + concurrency). Assert light's 3 messages are all served within the first 3 + calls (age order alone would drain heavy first). Assert each call returns + at most one message per variant. +2. "tags advance per serve within one batched call": seed 5 variants at tag + 0, one message each; one dequeue call with `maxCount: 5`; assert all 5 + served and `ZSCORE ckVtime ` is `1` for each (advanced inside the one + call, not once per call). +3. "floor is monotonic and read-repairs": drive tags to ~20 by repeated + serve of two keys, assert `GET ckVtimeFloor` never decreased across calls + (sample after each call), and equals the min stored tag after the last. +4. "new key initialises at the floor, not zero and not behind the backlog": + after tags reach ~20, register a fresh variant with the enqueue path (or + direct ZADD NX at the current floor pre-Task-4), enqueue one message on + it, one dequeue call; assert the fresh variant is served in that first + call and its tag afterwards is `floor + quantum`, not `1`. +5. "no service, no advance": set the base queue concurrency limit to 1 via + `queue.updateQueueConcurrencyLimits`, occupy `ck: a`'s slot (dequeue one, + do not ack), then call dequeue; assert `ck: a` was skipped, its tag is + unchanged, and other variants were served. +6. "GC on empty variant": drain a variant completely; assert it is removed + from BOTH `ckIndex` and `ckVtime`. +7. "TTL is set and refreshed": after any dequeue, `PTTL ckVtime` and + `PTTL ckVtimeFloor` are in `(0, stateTtlSeconds * 1000]`. +8. "pass 2 fill serves unregistered variants and registers them": enqueue on + a variant, delete its `ckVtime` entry by hand (simulating an old-code + enqueue), dequeue; assert the message is served AND the variant now has a + `ckVtime` tag. +9. "future-scheduled variants are skipped without advance": nack a message + with a future score (or enqueue with future timestamp); dequeue; assert + the variant is not served and its tag is unchanged. + +The Lua. Full sketch (the per-candidate serve body is today's tracked body +verbatim; only the parts marked NEW differ): + +```lua +local ckIndexKey = KEYS[1] +local queueConcurrencyLimitKey = KEYS[2] +local envConcurrencyLimitKey = KEYS[3] +local envConcurrencyLimitBurstFactorKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local messageKeyPrefix = KEYS[6] +local envQueueKey = KEYS[7] +local masterQueueKey = KEYS[8] +local ttlQueueKey = KEYS[9] +local lengthCounterKey = KEYS[10] +local ckVtimeKey = KEYS[11] -- NEW +local ckVtimeFloorKey = KEYS[12] -- NEW + +local ckWildcardName = ARGV[1] +local currentTime = tonumber(ARGV[2]) +local defaultEnvConcurrencyLimit = ARGV[3] +local defaultEnvConcurrencyBurstFactor = ARGV[4] +local keyPrefix = ARGV[5] +local maxCount = tonumber(ARGV[6] or '1') +local quantum = tonumber(ARGV[7] or '1') -- NEW +local windowMultiplier = tonumber(ARGV[8] or '3') -- NEW +local stateTtl = tonumber(ARGV[9] or '86400') -- NEW + +local function decrLengthCounter() + if tonumber(redis.call('GET', lengthCounterKey) or '0') > 0 then + redis.call('DECR', lengthCounterKey) + end +end + +-- env gate: identical to dequeueMessagesFromCkQueueTracked +local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') +local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) +local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor) +if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then + return nil +end +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) +local envAvailableCapacity = envConcurrencyLimitWithBurstFactor - envCurrentConcurrency +local actualMaxCount = math.min(maxCount, envAvailableCapacity) +if actualMaxCount <= 0 then + return nil +end + +local window = actualMaxCount * windowMultiplier + +-- NEW: monotonic floor, advanced to the minimum stored tag +local floor = tonumber(redis.call('GET', ckVtimeFloorKey) or '0') +local minEntry = redis.call('ZRANGE', ckVtimeKey, 0, 0, 'WITHSCORES') +if #minEntry > 0 then + local minTag = tonumber(minEntry[2]) + if minTag > floor then + floor = minTag + end +end + +local results = {} +local dequeuedCount = 0 +local attempted = {} + +-- Per-candidate serve. Body between BEGIN/END COPY is today's tracked +-- per-candidate block, unmodified except the two NEW lines. +local function tryServe(ckQueueName) + attempted[ckQueueName] = true + local fullQueueKey = keyPrefix .. ckQueueName + local ckConcurrencyKey = fullQueueKey .. ':currentConcurrency' + local ckCurrentConcurrency = tonumber(redis.call('SCARD', ckConcurrencyKey) or '0') + if ckCurrentConcurrency >= queueConcurrencyLimit then + return + end + -- BEGIN COPY (from dequeueMessagesFromCkQueueTracked, lines ~4219-4273) + local messages = redis.call('ZRANGEBYSCORE', fullQueueKey, '-inf', tostring(currentTime), 'WITHSCORES', 'LIMIT', 0, 1) + if #messages >= 2 then + -- ... TTL-expired / normal-dequeue / stale-orphan branches verbatim ... + -- in the normal-dequeue branch, after dequeuedCount = dequeuedCount + 1: + -- NEW: advance this variant's virtual time (weight hook: fixed 1 today) + -- local weight = 1 + -- local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor) + -- if tag < floor then tag = floor end + -- redis.call('ZADD', ckVtimeKey, tag + (quantum / weight), ckQueueName) + -- rebalance ckIndex from the variant head, verbatim, plus: + -- NEW: if the variant queue is empty, also redis.call('ZREM', ckVtimeKey, ckQueueName) + else + -- empty-in-range branch verbatim, plus the same NEW ZREM when fully empty + end + -- END COPY +end + +-- Pass 1: fair order (lowest virtual start tag first) +local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1) +for _, ckQueueName in ipairs(vtimeCandidates) do + if dequeuedCount >= actualMaxCount then break end + tryServe(ckQueueName) +end + +-- Pass 2: fill + discovery in today's age order (work conservation, +-- mixed-deploy safety). Never runs when pass 1 filled the batch. +if dequeuedCount < actualMaxCount then + local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, window) + for _, ckQueueName in ipairs(ckQueues) do + if dequeuedCount >= actualMaxCount then break end + if not attempted[ckQueueName] then + tryServe(ckQueueName) + end + end +end + +-- NEW: persist floor and refresh TTLs +redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl) +if redis.call('EXISTS', ckVtimeKey) == 1 then + redis.call('EXPIRE', ckVtimeKey, stateTtl) +end + +-- master queue rebalance: verbatim from the tracked command (uses ckIndex, +-- which keeps its timestamp domain) +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +return results +``` + +Note the `tryServe` extraction is inside the NEW script only; the old script +is not refactored. When writing the real script, inline today's per-candidate +block into `tryServe` exactly (including `decrLengthCounter`, the TTL-member +removal, and both rebalance branches); the sketch elides it to keep the plan +readable, and the byte-identity constraint applies to the OLD script, which +is untouched. + +Call-site switch in `#callDequeueMessagesFromCkQueue` (~line 2206): + +```ts +const result = this.#ckVtimeEnabled + ? await this.redis.dequeueMessagesFromCkQueueVtimeTracked( + ckIndexKey, queueConcurrencyLimitKey, envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, envCurrentConcurrencyKey, + messageKeyPrefix, envQueueKey, masterQueueKey, ttlQueueKey, + lengthCounterKey, + this.keys.ckVtimeKeyFromQueue(ckWildcardQueue), + this.keys.ckVtimeFloorKeyFromQueue(ckWildcardQueue), + ckWildcardQueue, String(Date.now()), + String(this.options.defaultEnvConcurrency), + String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), + this.options.redis.keyPrefix ?? "", String(maxCount), + String(this.#ckVtimeQuantum), String(this.#ckVtimeWindowMultiplier), + String(this.#ckVtimeStateTtl) + ) + : await this.redis.dequeueMessagesFromCkQueueTracked(/* unchanged */); +``` + +Add span attributes on the vtime path: `ck_vtime_enabled: true` plus, from a +small extension of the return shape if desired later, keep it simple now and +only tag the flag. + +Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run` +(tests 1-3, 5-9 pass; 4 passes with the direct-ZADD seeding until Task 4), +then `pnpm run typecheck --filter @internal/run-engine`. + +### Task 4: enqueue registration + +File: `index.ts`. Two new commands, `enqueueMessageCkVtimeTracked` and +`enqueueMessageWithTtlCkVtimeTracked`, `numberOfKeys: 17` (the existing 15 +plus `ckVtimeKey` as KEYS[16] and `ckVtimeFloorKey` as KEYS[17]; the WithTtl +variant is existing-16 plus 2), ARGV extended with `stateTtl`. Script body = +existing tracked script verbatim, plus, in the SLOW PATH ONLY, immediately +after the `-- Rebalance CK index` block: + +```lua +-- Register this variant in the virtual-time index at the floor. NX means an +-- already-advanced tag is never rewound. +local vfloor = redis.call('GET', ckVtimeFloorKey) or '0' +redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName) +redis.call('EXPIRE', ckVtimeKey, stateTtl) +``` + +The fast path (direct-to-worker-queue when the variant is empty and capacity +is free) does NOT register or advance; see open decision 1. + +Tests first, in `tests/ckVtime.test.ts`: + +10. "enqueue registers the variant at the current floor with NX": drive the + floor to ~5 via serves, enqueue on a fresh key, assert + `ZSCORE ckVtime ` equals the floor; enqueue a second message on a + key whose tag is 9, assert the tag is still 9. +11. "test 4 now passes end-to-end without direct ZADD seeding" (remove the + seeding from test 4). +12. "fast path leaves vtime state untouched": empty variant, free capacity, + enqueue (fast path fires, returns 1); assert no `ckVtime` entry was + created for it. Then saturate capacity, enqueue again (slow path); + assert registration happened. + +Call-site switches at ~lines 1906 and 1941 pick the vtime variants when +`this.#ckVtimeEnabled`, passing the two extra keys and `stateTtl`. Add both +to the module augmentation. + +Verify: same test file command; plus +`pnpm run test ./src/run-queue/tests/enqueueMessage.test.ts --run` and +`./src/run-queue/tests/ckIndex.test.ts --run` still green (flag off). + +### Task 5: nack registration + +File: `index.ts`. New command `nackMessageCkVtimeTracked`, +`numberOfKeys: 13` (existing 11 plus the two vtime keys), ARGV plus +`stateTtl`. Body = existing verbatim plus the same NX-register block after +its `-- Rebalance CK index` section. Call-site switch at ~line 2584. + +Test first (in `tests/ckVtime.test.ts`): + +13. "nack re-registers a GC'd variant": enqueue one message on `ck: a`, + dequeue it (variant now GC'd from both indexes), nack it; assert the + variant is back in `ckIndex` AND in `ckVtime` at the floor, and a + subsequent dequeue serves it (respecting its future score if the nack + applied backoff: use a nack with an immediate retry score). + +Closure invariant test: + +14. "ckVtime membership tracks ckIndex membership": property-style loop of + ~200 random operations (enqueue on 1 of 8 keys, dequeue batch, ack or + nack a random in-flight message); after each step assert every member of + `ckIndex` is a member of `ckVtime` (the converse may transiently not + hold, which is fine; stale `ckVtime` entries GC on scan). + +Verify: `pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run` and +`./src/run-queue/tests/nack.test.ts --run` (flag off, untouched). + +### Task 6: fairness scenarios on the real batched path + +File: `tests/ckVtimeFairness.test.ts` (new). This closes the spike's fidelity +gap: the spike proved the ordering at `maxCount = 1` with driver-side +rescoring; these tests drive the REAL batched Lua (`maxCount = 10`) with the +state advanced inside the script. + +Harness design (deterministic, no wall-clock sleeps, no spike imports): a +step loop against one `RunQueue` on testcontainers Redis. + +- Enqueue with explicit `timestamp` values in `InputPayload` (all in the + past so everything is time-eligible; the backlog key gets one old shared + timestamp, other keys get strictly increasing later timestamps, mirroring + the ckScenarios head-age reasoning). +- Each step: call the dequeue path once with `maxCount: 10` (via the public + dequeue API used by `ckIndex.test.ts`), record `(step, variant, messageId)` + per served message, then ack each served message after a per-key logical + hold of H steps (keep a small in-flight list and ack entries whose + `servedAt + H <= step`), which is how the env concurrency contends. +- Wait metric per message = serve step minus a per-message logical arrival + step (arrival step derived from the enqueue order). All assertions are on + ratios between flag-on and flag-off runs of the SAME scenario and seed, so + they are stable in CI; use generous factors. + +Scenarios (ported shapes from `ckScenarios.ts` and +`capsFairness.bench.test.ts`, scaled down for CI): + +- ckSkew: heavy 120 backlog msgs (old shared head), 4 light keys x 10 msgs + (later heads). env limit 4, hold 3 steps. Assert: mean light-key wait with + flag ON <= 0.3 x flag OFF (spike measured ~1100 -> ~15, so 0.3 is very + loose); heavy key's wait may rise (do not assert it down). +- ckTrickle: bulk 120, two trickle keys x 15. Same assertion. +- ckSybil (the case caps cannot fix): 20 attacker keys x 8 msgs each, all + older heads, 1 light key x 10 newer. Assert: flag ON mean light wait + <= 0.7 x flag OFF (spike: 1765 -> 1009), AND light key's first serve + happens within the first 3 steps (reachability at the floor), AND + contention-window share: over the steps where >= 2 keys have queued + backlog, light's served fraction >= 0.5 x its fair share 1/21 (directional, + per the spike's confounding caveat; wait is the headline). +- ckBalanced (no-harm check): 4 symmetric keys x 25. Assert: max per-key + mean wait with flag ON <= 1.25 x flag OFF (fair order must not make the + symmetric case worse). +- ckHeavyIdle (work conservation): single key, 60 msgs. Assert: steps to + drain with flag ON == flag OFF exactly (nothing else contends, so any + extra step is a work-conservation bug). + +Also assert in every scenario: total served ON == total served OFF == total +enqueued (no loss, no double-serve; `messageId`s unique). + +Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtimeFairness.test.ts --run` +(all scenarios pass; target < 60s wall time total, scale message counts down +if needed before loosening assertions). + +### Task 7: multi-consumer / multi-shard correctness + +File: `tests/ckVtimeConcurrency.test.ts` (new). + +15. "two consumers, one base queue, no corruption": one `RunQueue` for + enqueues, two more instances (same Redis, same key prefix, flag on) each + running a dequeue loop with `maxCount: 5` concurrently + (`Promise.all` of two loops, acking with a short hold). 6 keys x 30 + messages. Assert: every message served exactly once across both + consumers (union of served IDs has no duplicates and equals the enqueued + set); after drain, `ckVtime` is empty and floor equals the max it ever + reached; sample the floor between iterations and assert it never + decreased. The correctness argument is that every mutation happens + inside one Lua script and Redis serialises scripts; this test is the + check that the scripts do not assume cross-call state. +16. "concurrent enqueue during dequeue cannot rewind a tag": interleave + enqueues on a hot key with dequeue batches; after each round assert + `ZSCORE ckVtime ` is non-decreasing (NX registration + advance-only + writes). + +17. "op-count budget": using a second plain Redis client, `CONFIG RESETSTAT`, + run 50 identical dequeue calls flag OFF, snapshot + `INFO commandstats` total calls; repeat flag ON with identical data. + Assert `on_total <= off_total + 50 * (6 + 2 * maxCount)` (per call the + vtime path adds at worst: GET floor, ZRANGE min, ZRANGE window, SET + floor, EXPIRE, the pass-2 ZRANGEBYSCORE, plus per serve one ZSCORE and + one ZADD). This pins the per-dequeue overhead the way the caps plan pins + the fairQueue snapshot cost. + +Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtimeConcurrency.test.ts --run`. + +### Task 8: default-off regression proof + +Location: `tests/ckVtime.test.ts` (final describe block). + +18. "flag off creates no vtime keys and matches today's order": with + `ckVirtualTimeScheduling` absent, run a mixed sequence (enqueues across + 3 keys with distinct head ages, batched dequeues, one nack, acks), then: + `KEYS *` contains no key matching `*ckVtime*`; the dequeue order equals + the head-timestamp order (re-assert the core expectation of + `ckIndex.test.ts` inside this sequence). The stronger guarantee (same + script text, same SHA) holds by construction: the off path calls the + same command names whose `defineCommand` strings this plan never edits; + say so in a comment rather than pretending a test can diff against an + old build. +19. Run the whole existing run-queue suite with the code in place and flag + off: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/ --run` + (excluding the `fairness-spike*` dirs if they are still present). All + green. + +### Task 9: engine and webapp wiring (code-dark rollout) + +Files: `engine/types.ts`, `engine/index.ts`, `apps/webapp/app/env.server.ts`, +`apps/webapp/app/v3/runEngine.server.ts`. + +- `engine/types.ts`, inside `queue:`: + `ckVirtualTimeScheduling?: RunQueueOptions["ckVirtualTimeScheduling"];` +- `engine/index.ts` (~line 196): pass + `ckVirtualTimeScheduling: options.queue?.ckVirtualTimeScheduling,`. +- `env.server.ts`: + `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED: z.string().default("0")`, + `RUN_ENGINE_CK_VTIME_QUANTUM: z.coerce.number().default(1)`, + `RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER: z.coerce.number().default(3)`, + `RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS: z.coerce.number().default(86400)` + (match the file's existing patterns for flag-style vars). +- `runEngine.server.ts` `queue:` block: + +```ts +ckVirtualTimeScheduling: + env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED === "1" + ? { + enabled: true, + quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM, + scanWindowMultiplier: env.RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER, + stateTtlSeconds: env.RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS, + } + : undefined, +``` + +Mixed-deploy analysis to record in the PR description (the analogue of the +caps plan's mixed-arity warning): old and new instances coexist safely +because ioredis registers scripts per process, so arity never mixes within a +script call; old-instance enqueues skip registration and old-instance +dequeues neither advance tags nor GC `ckVtime`. Consequences during overlap: +unregistered variants are served via pass 2 (age order, today's behaviour) +and get registered on serve; keys served by old instances gain a temporary +priority bias (tags lag), which the floor bounds and which disappears when +the rollout completes. No state leaks: `ckVtime` entries created during a +rollout that is then rolled BACK are ignored by the old script entirely and +expire via the state TTL. Turning the flag OFF after running ON is the same: +stale vtime keys are inert and expire within `stateTtlSeconds`. + +Verify: `pnpm run typecheck --filter @internal/run-engine` and +`pnpm run typecheck --filter webapp`. + +### Task 10: ship notes and cleanup + +- Add `.server-changes/2026-XX-XX-ck-fair-scheduling.md` per + `.server-changes/README.md` (user-facing wording; it ships dark, so the + note says the fair ordering exists behind a flag and changes nothing by + default). No changeset (no public package touched). +- `pnpm run format && pnpm run lint:fix` before committing. +- The `fairness-spike/` and `fairness-spike-ck/` directories say "delete + before any merge to main" in their own headers. Deleting them is a + separate commit/decision, not part of this implementation branch; do not + import from them (already a global constraint). + +## Rollout sequence (after merge) + +1. Deploy with the flag off (nothing changes; scripts for the new commands + are registered but never called). +2. Enable on a staging/test cell; watch dequeue latency spans and Redis op + rates against the Task-7 budget; run a manual sybil-shaped workload and + confirm the light key's wait. +3. Enable in production. During the instance-rolling window the behaviour + interpolates between age order and fair order per the mixed-deploy + analysis; both endpoints are safe. +4. Rollback at any point = flip the env var off; stale vtime keys expire via + TTL within 24h. + +## Open design decisions (flagged, with recommended defaults) + +1. Fast-path enqueue does not advance or register virtual time. + Recommended: keep it that way. The fast path fires only when the variant + queue is empty AND env and queue capacity are free, i.e. when there is no + contention, and fairness only exists under contention. Charging fast-path + serves would need vtime keys touched on the hot uncontended path for no + measurable benefit. Revisit only if a workload alternates fast-path and + queued serves on the same keys at saturation boundaries (the Task-6 + ckBalanced no-harm test would catch a regression shape here). +2. Stored tag semantics and quantum. Recommended: store the NEXT start tag + (start of last serve + quantum), quantum 1.0, matching `SfqCk` in + `disciplines.ts` exactly, since that is the vetted logic both spikes + measured. A cost-proportional quantum (e.g. by machine size) is possible + later via the same field. +3. Pass-1 window multiplier. Recommended: default 3 (today's), configurable. + The residual reachability limit (more than `window` at-cap or + future-scheduled low-tag variants hiding an eligible one) is the same + class as today's `*3` limit and pass 2 keeps the call work-conserving; + widening by default would raise per-call cost for a case not yet observed. +4. Registration sites. Recommended: enqueue and nack only, with pass 2 as + the safety net, per the closure argument (only enqueue and nack make a + variant queue non-empty). Adding registration to the sweeper/ack Luas + would touch more scripts for no covered transition. +5. State TTL default. Recommended: 86400s, matching `counterTtlSeconds`'s + precedent and rationale (periodic re-anchor bounds any drift, including + drift from rolling-deploy overlap). +6. Command variants vs ARGV-gated single script. Recommended: separate + `...Vtime...` commands. Byte-identity when off then holds by construction + instead of by test. +7. Equal weights. Recommended: yes, with the `quantum / weight` hook left in + place (weight fixed at 1, named local, comment pointing at a future + sparse `:ckWeights` HASH shaped like Phase-2's `:ckLimits`). No product + surface for weights exists today. +8. Discipline. Recommended: SFQ (stride is arithmetically the same thing + here; DRR would need a ring cursor in Redis and buys nothing per the + spike, where DRR and SFQ tracked each other within noise). Keep DRR as + the documented O(1) fallback if ZSET ops on `ckVtime` ever show up in + profiles, which the Task-7 op budget makes visible. + +## Verification summary + +```bash +pnpm run typecheck --filter @internal/run-engine +pnpm run typecheck --filter webapp +cd internal-packages/run-engine +pnpm run test ./src/run-queue/tests/keyProducer.test.ts --run +pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run +pnpm run test ./src/run-queue/tests/ckVtimeFairness.test.ts --run +pnpm run test ./src/run-queue/tests/ckVtimeConcurrency.test.ts --run +pnpm run test ./src/run-queue/ --run # full regression, flag off default +``` diff --git a/docs/superpowers/references/README.md b/docs/superpowers/references/README.md new file mode 100644 index 00000000000..e16ab19a9da --- /dev/null +++ b/docs/superpowers/references/README.md @@ -0,0 +1,43 @@ +# Run-queue multi-tenant fairness: spike references + +Reference material for the implementation plan +`docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md`. These are +the findings and the queueing-theory research produced by three throwaway spikes +on RunQueue tenant fairness (#2617). The spikes' harness, bench, and results code +was throwaway and is NOT on this branch; it is archived on the remote branch +`chore/fair-queueing-spike` (never merged, delete-before-anything). Any +`internal-packages/.../fairness-spike*` paths mentioned inside these documents +refer to that archived code. + +## The documents + +- `run-queue-fairness-research.md` — queueing-theory grounding: SFQ/WFQ and DRR + delay bounds, the Parekh-Gallager result that a worst-case per-flow delay bound + needs BOTH an admission regulator and a scheduler, why CoDel is an AQM and not a + fairness scheduler, and how production systems (Kubernetes APF, YARN, SQL Server + Resource Governor, SQS fair queues) layer caps under a fair order. +- `run-queue-fairness-base-queue-findings.md` — spike 1, base-queue grain: ranked + SFQ / stride / DRR / CoDel against the production age-order baseline. SFQ and + stride fix starvation and are seed-stable; CoDel is a no-op on a fair base and + harmful on an unfair one. +- `run-queue-fairness-ck-findings.md` — spike 2, the real concurrency-key seam: + drove the production `dequeueMessagesFromCkQueueTracked` Lua via `ckIndex` + rescoring. Per-key fairness lives below the selection-strategy interface, in the + CK dequeue scoring; virtual-time ordering fixes it there. Documents the + `maxCount = 1` fidelity limit that the implementation plan's tests must close. +- `run-queue-fairness-caps-vs-scheduling-findings.md` — spike 3, the + reconciliation with the plan of record (which ships concurrency caps): caps and + scheduling are orthogonal knobs. A per-key cap fixes wait when one key floods + but gives no relief once a tenant shards across many keys (the sybil split), and + it is not work-conserving; a total cap is a cross-task knob, not a cross-key + one; fair scheduling fixes every case and stays work-conserving. Ship the caps + first, add the fair order as the general fix, layer them. + +## Why the plan follows from these + +The recommended fix (score `ckIndex` by SFQ virtual time, inside the batched CK +dequeue, layered under the caps) is the one mechanism the spikes found that +survives key-sharding and stays work-conserving, and the research says the caps +the plan of record ships cannot bound wait on their own. The plan turns that into +a flag-gated, mixed-deploy-safe engine change with a test suite that exercises the +real batched path the spikes could not. diff --git a/docs/superpowers/references/run-queue-fairness-base-queue-findings.md b/docs/superpowers/references/run-queue-fairness-base-queue-findings.md new file mode 100644 index 00000000000..e386aba8d5c --- /dev/null +++ b/docs/superpowers/references/run-queue-fairness-base-queue-findings.md @@ -0,0 +1,168 @@ +# Fair-queueing spike: findings + +This is a throwaway spike. It ships nothing and should be deleted before any +merge to main; it exists to inform a design decision, not to become code. + +Read the caveats section before quoting any single number. Two of them matter up +front: (1) the candidate selectors are handed tenant identity (parsed from the +queue name) and the exact per-tenant weights, and the fairness target is defined +by those same groups and weights, so the candidate win over the tenant-blind +baseline is closer to definitional than discovered. (2) The harness anchors +message scores far in the past, which flattens all queue ages, so the baseline's +production age bias is not exercised here; the baseline measured is closer to a +uniform-random shuffle than the real one. + +Bottom line: at the base-queue grain, virtual-time ordering (SFQ) and stride give +tight, seed-stable proportional fairness, honour weights, and cut a starved +tenant's wait hard. DRR lands within noise of them (its small shortfall is a +measurement artifact of the harness, not an intrinsic property). The baseline is +fair on average but seed-variant and has no weight concept. The CoDel wrapper is +not worth shipping as built: it is a forced no-op under bulk arrival and it +actively hurt fairness on the one trickle-arrival workload that could exercise it. +The biggest single result is architectural: per-concurrency-key fairness (the +actual #2617 grain) cannot be expressed through the `RunQueueSelectionStrategy` +interface at all; it lives below that interface, in the CK-dequeue Lua. + +Every number comes from the real `RunQueue` against a testcontainers Redis, one +selector per run, real enqueue/dequeue/ack and real concurrency gating. Each +scenario runs over 3 seeds; tables show the mean and min..max spread. Per-tenant +detail (first seed) is in `results/*.json`. + +## Grain, and why it is not the concurrency key + +A tenant is the fairness group; a tenant owns one or more base queues. The +adversarial scenario gives one tenant 30 queues and the light tenants one each, +which is how the #2617 starvation shows up at the base-queue grain: an ordering +blind to tenant identity lets the many-queue tenant win most of the selection +chances. + +The concurrency-key grain #2617 asks for is not reachable through the strategy +interface. `FairQueueSelectionStrategy` reads the master-queue members verbatim, +and CK runs enqueue a single CK-wildcard entry per base queue. The per-CK pick +runs later inside `dequeueMessagesFromCkQueueTracked`, where `ckIndexKey` is a +ZSET of CK-queues scored by head timestamp and the Lua serves them oldest-first. +That age ordering is the unfairness. Fixing it means changing that Lua or the +`ckIndex` scoring, not the selection strategy. That is the follow-on spike. + +## How fairness is measured (and its limits) + +Because the sim drains every run, final throughput share is fixed by the workload +and cannot tell selectors apart. Two measures do: + +- contention share: a tenant's share of dequeues at instants when at least two + tenants have arrived, unserved work, over its expected weighted share. + `contWorstS/W` is the least-served contender; 1.0 is fair, near 0 means starved + while others had work. Getting this right took two corrections a review caught: + it must only count a tenant once its runs have actually arrived (else poisson + arrival looks like starvation), and the virtual-time floor must be monotonic + (else a returning idle tenant monopolises and skews the window). Even so, when + tenants have very different volumes (trickleStale: 30 runs vs 300) the + low-volume tenant can legitimately be over- or under-represented in the window, + so read this metric together with wait, not alone. +- wait: dequeue time minus enqueue time, per tenant, in the JSON. This is the + clean anti-staleness signal. (Note: `worstWaitP99` in the JSON is NOT an + anti-staleness win signal; it is dominated by the highest-volume tenant, which + a fair selector deliberately delays, so a fairer selector scores worse on it. + Use per-tenant wait.) + +## Results + +`contWorstS/W` mean over 3 seeds (min..max). Higher is fairer. + +| scenario | baseline | sfq | drr | stride | codel-sfq | codel-baseline | +| --------------- | ------------------- | ----- | ------------------- | ------ | --------- | -------------- | +| balanced | 0.889 (0.774..0.954)| 0.985 | 0.954 (0.923..0.970)| 0.985 | 0.985 | 0.889 | +| adversarialSkew | 0.288 (0.261..0.310)| 1.000 | 0.978 (0.968..0.984)| 1.000 | 1.000 | 0.288 | +| weighted | 0.703 (0.679..0.719)| 1.000 | 0.990 (0.977..1.000)| 1.000 | 1.000 | 0.703 | +| burst | 0.978 (0.966..0.992)| 0.992 | 0.958 (0.941..0.975)| 0.992 | 0.992 | 0.978 | +| longHold | 0.828 (0.800..0.842)| 0.981 | 0.981 | 0.981 | 0.981 | 0.828 | +| trickleStale | 0.208 (0.179..0.235)| 0.804 (0.769..0.826)| 0.776 (0.769..0.783)| 0.804 | 0.366 | 0.195 | + +Per-tenant mean wait (seed-a, logical ms), the anti-staleness signal: + +| scenario / selector | low-volume tenant wait | heavy tenant wait | +| ------------------------ | ---------------------- | ----------------- | +| adversarialSkew baseline | 1380 | 805 | +| adversarialSkew sfq | 319 | 1324 | +| trickleStale baseline | 1359 | 1234 | +| trickleStale sfq | 19 | 1353 | +| trickleStale codel-sfq | 213 | 1315 | + +Reading these: the fair selectors cut the light tenant's wait (skew 1380 to 319, +trickle 1359 to 19) by making the heavy tenant wait its fair turn. The heavy +tenant is not punished, it stops jumping the queue. CoDel undoes part of the +trickle win (19 back up to 213). + +## Verdict per mechanism + +- SFQ (start-time virtual time, the start-tag form of WFQ): the strongest result. + Perfect contention fairness under skew and weighting, seed-stable (zero variance + across seeds), and the largest cut to the starved tenant's wait. The floor is + now monotonic (a review found the earlier version let a returning idle tenant + monopolise; fixed). Recommended as the leaf ordering. +- Stride: identical to SFQ to the decimal on every scenario. The spike does not + separate them. Stride carries slightly less state. +- DRR: within noise of SFQ. It trails by a couple of points on balanced (0.954) + and burst (0.958) and matches SFQ elsewhere. That small shortfall is a + measurement artifact, not an intrinsic property: the driver drains a whole + capacity batch from a single strategy snapshot and only advances DRR's deficit + after the batch (via `onServiced`), so DRR's current-winner group, whose queues + it fronts together, grabs several slots before its deficit updates and its + deficit runs negative. Served one-at-a-time DRR is exactly fair (see + `drr.test.ts`). Note the earlier claim that "virtual-time sorts an over-served + group's queues to the back and so avoids this" was wrong: at a tie all of a + group's queues share one clock, so SFQ fronts them together too; the schemes + only separate after their state advances. DRR is O(1) and composes weight + trivially, so it is a fine choice if per-op cost matters, subject to that + caveat. +- CoDel wrapper: do not ship as built. Under bulk arrival it is a forced no-op: + all of a queue's runs share one enqueue timestamp, so every tenant's sojourn is + identical and they all cross the target together, so hoisting everyone collapses + to the base order (this is why codel-sfq equals sfq and codel-baseline equals + baseline to the decimal on those scenarios; it is one workload shape confirming + a null result, not five independent tests). On trickleStale, the one scenario + where sojourns diverge, the sojourn-hoist overshoots: it drops SFQ from 0.804 to + 0.366 and pushes the trickle tenant's wait from 19 back to 213. A staleness + monitor may still help on top of an unfair base or behind a hard concurrency + wall, but that needs a different construction and this spike does not support it. +- Baseline (`FairQueueSelectionStrategy`): fair on average on the easy scenarios + but seed-variant (balanced 0.774..0.954), no weight concept (weighted 0.703), + and it starves a light tenant under queue-count skew (0.288) and under trickle + arrival (0.208, trickle wait 1359). Remember its age bias is not exercised here + (see caveats), so this is a floor on its unfairness, not the production picture. + +## Caveats + +- Grain is base queues, not concurrency keys. The disciplines are grain-agnostic + so the ranking should carry over, but the #2617 gap itself needs the CK-Lua + spike. adversarialSkew is a proxy for that gap, not a measurement of it. +- Definitional advantage: candidates get tenant identity and exact weights the + real interface does not carry; the baseline structurally cannot. +- Baseline age bias inert: scores are anchored ~600s in the past, so all queue + ages are near-equal and the baseline degenerates to near-uniform selection. The + production age bias (which would give a heavy tenant's older heads more weight, + i.e. make skew worse) is not measured. +- Selection-only seam: the driver feeds serviced descriptors back via an + `onServiced` hook; production would advance selector state inside the ack/dequeue + Lua. The spike proves ordering logic, not that wiring. +- Cost was not rigorously measured. `selectionRounds` is roughly equal across + selectors (646..729) but is not comparable between them (a candidate reads all + queues per call; the baseline short-circuits at capacity), and there is no load + benchmark. DRR's "O(1)" advantage is a theory claim, not a spike measurement. +- Scenario quality varies. balanced best shows the baseline's variance; + adversarialSkew and weighted carry the clear separation; longHold and burst + barely separate the candidates; trickleStale's contention number only became + meaningful after two metric fixes and should be read with wait. Per-tenant p99 + equals max for the small (20 to 30 run) tenants, so "p99" there is just the max. +- Single Redis shard; single sequential consumer (not the multi-consumer, + Redis-hash-state design the spec sketched); simulated holds on a logical clock. + Three seeds shows the baseline's variance and the virtual-time schemes' + stability but is not a statistical study. + +## Recommended direction + +Use virtual-time (SFQ, or stride) for leaf ordering and compose weight with it. +DRR is an acceptable O(1) fallback given the batch caveat. Do not adopt the CoDel +wrapper as built. Then run the follow-on spike against the CK-dequeue Lua / +`ckIndex` scoring, because that is where per-tenant fairness actually has to land +in the current design. diff --git a/docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md b/docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md new file mode 100644 index 00000000000..b1daf94ee84 --- /dev/null +++ b/docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md @@ -0,0 +1,198 @@ +# Caps vs scheduling: reconciliation findings + +Throwaway spike. Ships nothing; delete before any merge to main. Relative ranking +on a small simulation, not a statistical or load study: read the verdicts as "what +this harness supports", not proofs. Went through a blind two-model adversarial +review (a third stalled); the review's fixes are folded in below. + +Bottom line: the plan-of-record's concurrency CAPS and the earlier spike's fair +SCHEDULING are different knobs, and the data on the real CK-dequeue Lua lines up +with the queueing theory (see `RESEARCH.md`). A per-key cap cuts a starved key's +wait when ONE key floods, because Trigger's CK dequeue is oldest-eligible-first; +it gives no wait improvement once a tenant shards its backlog across many +concurrency keys, and it is not work-conserving. Fair scheduling (SFQ/DRR) is the +only knob here that improves the starved key on every scenario including the +sharded one, and it stays work-conserving. A total (per-task) cap is a +cross-task knob; inside one task it only lowers the ceiling and is not a fairness +lever at all. The mechanisms are complementary, and production systems that need +fairness under saturation layer them (Kubernetes APF: seats + fair queueing). + +## How the mechanisms were modelled (fidelity) + +- Per-key cap (Phase 2): the REAL Lua gate. `updateQueueConcurrencyLimits` sets + the base queue's concurrencyLimit; the CK-dequeue Lua caps each ck variant's + in-flight at it and skips an at-limit variant (oldest-eligible-first, true age + order, no rescore). Uniform across variants: Phase 2's per-key HGET override + would cap only the heavy key, but a light key never approaches the cap so the + effect is equivalent here. (So "just lower the existing per-queue concurrency + limit" already IS a per-key cap; Phase 2 makes it per-key-specific.) +- Total cap (Phase 1): driver-side. The real Lua has no group gate yet, so the + driver refuses to admit while total in-flight across all variants of the base + queue (= `:groupConcurrency` SCARD in one base queue) is at the cap. +- Ordering disciplines (baseline age order, SFQ, DRR) are unchanged from the CK + scheduling spike, driven through the same real Lua at `maxCount = 1`. +- Two fidelity limits matter for reading the numbers, both driver-independent: + - `maxCount = 1` (same as the CK spike): production dequeues in batches, so a + real per-key/total gate lives inside the batched Lua. + - The `*3` scan window: the real CK Lua reads `ZRANGEBYSCORE ckIndexKey -inf now + LIMIT 0, actualMaxCount*3` (`index.ts:4041/4193`). At `maxCount = 1` that is + the 3 oldest-scored variants per call. So a per-key cap frees the light key + only when the light head lands inside that 3-wide window after the at-cap + variants ahead of it. With one heavy key it does; with many old-headed + attacker variants it never does. This window governs the skew-works / + sharded-fails split, and it scales with `maxCount` in production (batches), + not fixed at 3, so the sharded result's exact severity would differ on the + real batched path (direction not established). + +## Results + +env=4, per-key cap=2, total cap=2, 3 seeds. Columns: `lightWait` = the starved +key's mean wait (logical ms, the headline where it is not confounded); +`worstWait` = the largest per-group mean wait (i.e. the busiest key, which a fair +discipline deliberately makes wait its turn, so higher here is often correct); +`makespan` = logical time of the last dequeue (a work-conservation signal ONLY on +ckHeavyIdle, arrival-confounded elsewhere); `contWorstS/W` = worst contention +share over weight (directional; volume-confounded and, for per-key cap on sybil, +seed-noisy). + +| scenario | treatment | lightWait | worstWait | makespan | contWorstS/W | +| ----------- | ------------------ | --------- | --------- | -------- | ------------ | +| ckSkew | baseline | 1098 | 1261 | 2083 | 0.187 | +| ckSkew | perKeyCap | 20 | 1555 | 3038 | 0.814 | +| ckSkew | totalCap | 2840 | 2974 | 3947 | 0.213 | +| ckSkew | total+perKey | 2840 | 2974 | 3947 | 0.213 | +| ckSkew | sfq | 14 | 1069 | 2083 | 0.723 | +| ckSkew | drr | 17 | 1067 | 2083 | 0.608 | +| ckSkew | perKeyCap+sfq | 7 | 1628 | 3114 | 0.800 | +| ckSkew | total+perKey+sfq | 52 | 2363 | 3939 | 0.803 | +| ckTrickle | baseline | 1107 | 1134 | 1940 | 0.279 | +| ckTrickle | perKeyCap | 19 | 1555 | 3038 | 0.922 | +| ckTrickle | totalCap | 2852 | 2861 | 3905 | 0.279 | +| ckTrickle | total+perKey | 2852 | 2861 | 3905 | 0.279 | +| ckTrickle | sfq | 17 | 1070 | 1942 | 0.909 | +| ckTrickle | drr | 23 | 1069 | 1941 | 0.790 | +| ckTrickle | perKeyCap+sfq | 8 | 1606 | 3097 | 0.658 | +| ckTrickle | total+perKey+sfq | 106 | 2337 | 3911 | 0.883 | +| ckSybil | baseline | 1765 | 1876 | 2070 | 0.000 | +| ckSybil | perKeyCap | 1776 | 1869 | 2128 | 0.403 | +| ckSybil | totalCap | 3793 | 3801 | 4147 | 0.000 | +| ckSybil | total+perKey | 3793 | 3801 | 4147 | 0.000 | +| ckSybil | sfq | 1009 | 1019 | 2065 | 1.000 | +| ckSybil | drr | 1061 | 1068 | 2055 | 0.994 | +| ckSybil | perKeyCap+sfq | 1010 | 1019 | 2072 | 1.000 | +| ckSybil | total+perKey+sfq | 2292 | 2292 | 4146 | 1.000 | +| ckHeavyIdle | baseline | 633 | 633 | 1240 | 1.000 | +| ckHeavyIdle | perKeyCap | 1291 | 1291 | 2507 | 1.000 | +| ckHeavyIdle | totalCap | 1291 | 1291 | 2507 | 1.000 | +| ckHeavyIdle | total+perKey | 1291 | 1291 | 2507 | 1.000 | +| ckHeavyIdle | sfq | 633 | 633 | 1240 | 1.000 | +| ckHeavyIdle | drr | 633 | 633 | 1240 | 1.000 | +| ckHeavyIdle | perKeyCap+sfq | 1291 | 1291 | 2507 | 1.000 | +| ckHeavyIdle | total+perKey+sfq | 1291 | 1291 | 2507 | 1.000 | + +(ckHeavyIdle is a single key, so "lightWait" is the heavy key's own wait and the +contention metric is degenerate at 1.0; makespan is the signal there. ckSybil is +20 attacker keys plus one light key.) + +## What each mechanism does, at the cross-key grain + +- Per-key cap (Phase 2). SUPPORTED for the single-heavy case; NOT a wait fix once + the tenant shards; not work-conserving. + - Single heavy key (ckSkew/ckTrickle): cuts the light key's wait like a + scheduler (1098 to 20, 1107 to 19) because capping the one heavy key frees + slots and the CK Lua serves the light head as the next eligible one. This + depends on the light head being reachable inside the Lua's 3-wide scan window; + with one at-cap variant ahead of it, it is. + - Sharded / sybil (ckSybil, 20 attacker keys): NO wait improvement (baseline + 1765, perKeyCap 1776; the difference is within the per-seed spread, baseline + 1681..1926, perKeyCap 1672..1861). Contention share nudges up (0.000 to a + mean 0.403) but that mean hides a ~10x seed swing (0.07..0.71), so it is not a + dependable improvement. The reason is structural: the sum of per-key caps is + unbounded relative to the queue when keys are client-chosen, and the 3-wide + scan window is always full of older attacker heads, so the light head is never + reached. Concurrency keys are client-chosen, so this is cheap to trigger. + - Not work-conserving: throttles the capped key even with the env idle + (ckHeavyIdle makespan 1240 to 2507, 2x, the cleanest single result in the + spike; ckSkew 2083 to 3038, though that scenario's makespan is partly arrival- + confounded). +- Total cap (Phase 1) at the cross-key grain: NOT a fairness lever, and the + in-task comparison is capacity-confounded. `totalCap=2` caps the whole task's + aggregate at half of env=4, so it simply halves throughput: light's wait rises + (ckSkew 1098 to 2840) for the same reason heavy's does (both now share half the + server), which is Little's-Law throughput loss, not a fairness effect. It is the + wrong knob for cross-key starvation, measured on a lower ceiling; do not read + the "worse" numbers as "total caps harm fairness." +- Total cap (Phase 1) at the cross-TASK grain: this IS its job, and it works. + Measured in a separate multi-base-queue bench (`crossTaskCaps.bench.test.ts`): + two keyless tasks share one env, a heavy task floods it, and capping the heavy + task (its per-queue concurrency limit, the real native gate, which for a keyless + task equals its total cap) cuts the light TASK's wait from 475 to 2 under the + production `FairQueueSelectionStrategy`. So the total cap protects a light task + from a heavy task, the reservation-isolation role the research describes. It is + still not work-conserving (makespan 2039 to 3039), and SFQ at the task grain + protects the light task too (wait 14) while staying work-conserving (2039). The + fidelity note: this models a KEYLESS task, so the per-queue limit is the total; + a task WITH concurrency keys needs the group SET to sum across variants (the + unbuilt Phase-1 gate). +- Combined total + per-key (the shipped Phase-1+2 config): in this toy the total + cap (2) is below a single per-key cap's reach, so it dominates and the per-key + cap is non-binding (`total+perKey` equals `totalCap` to the digit). This toy + therefore does not exercise the combined config's real regime (total >> per-key, + cross-task). What it does show: adding a fair order on top (`total+perKey+sfq`) + restores fair share within the throttled aggregate (contWorstS/W 0.80..1.0) but + still pays the total cap's throughput loss (makespan ~3900+). +- Scheduling (SFQ/DRR): the only knob that improves the starved key on every + scenario, and work-conserving (makespan stays at the baseline optimum + 2083/1240). On the sharded case SFQ takes the light key from fully starved to + its full fair share (contWorstS/W 0.000 to 1.000, seed-stable) and roughly + halves its wait (1765 to 1009); the residual wait is real saturation shared + fairly across 21 keys, not starvation. SFQ and DRR track each other within + noise, as in the CK spike. +- Layered per-key cap + SFQ: best light-key wait on the single-heavy case (7, 8) + and it carries the cap's occupancy bound, at the cap's makespan cost (matches or + slightly exceeds perKeyCap makespan: ckSkew 3038 to 3114). On the sharded case + the cap adds nothing and SFQ does all the work (1010, same as SFQ alone). This + is the Kubernetes-APF shape; APF avoids the work-conservation cost by making the + cap ELASTIC (borrow/lend seats), which a static cap cannot. + +## Reconciliation with the earlier spike and the plan of record + +Measured here: caps and scheduling fix different things and can be layered +(the per-key-cap+SFQ and total+perKey+sfq rows). Fair scheduling is the only +mechanism in this harness that improves the starved key on the sharded case and +stays work-conserving, which is what the earlier spike recommended (score +`ckIndex` by virtual time). + +Interpretation, NOT measured by this benchmark (it measures wait/makespan/share on +a simulation, not engineering cost or rollout risk): shipping the caps first still +reads as defensible. A per-key cap is bounded, operator-controlled, self-healing +(a Redis SET), and it fully fixes the common single-heavy-key case, which is a +smaller engine change than reworking the dequeue scoring. Its limits are real +(no help once a tenant shards its keys, not work-conserving), which is the case +for treating automatic fair scheduling as a later phase rather than never. + +The layered end state matches Kubernetes APF, SQL Server Resource Governor, YARN, +and the Parekh-Gallager result that a worst-case delay bound needs BOTH an +admission regulator AND a scheduler: keep the caps for isolation and entitlements, +add a fair dequeue order for the contended region when saturation and key-sharding +make caps alone insufficient. Not either/or. + +## Caveats + +- Relative ranking on a simulation; single shard, single base queue, single + sequential consumer; simulated holds on a logical clock; 3 seeds; equal weights. + The verdict words ("supported", "not a wait fix") are relative to this harness. +- `maxCount = 1` and the `*3` scan window (see fidelity section); the total cap is + driver-modelled, not the real (unbuilt) group gate. +- Per-key cap is modelled uniformly (real per-queue gate); a per-key-specific + Phase-2 override is equivalent here only because the light key never approaches + the cap. +- makespan is the last dequeue, not completion, and is arrival-confounded on the + poisson scenarios; trust it only on ckHeavyIdle. +- Contention share is volume-confounded for low-volume keys, and for the per-key + cap on the sharded case it is seed-noisy (0.07..0.71); wait is the trustworthy + signal, share is directional. +- Cross-task isolation (the total cap's real purpose) is now measured in + `crossTaskCaps.bench.test.ts` for KEYLESS tasks (per-queue limit = total cap). + A task with concurrency keys needs the unbuilt group-SET gate to sum across + variants; that batched, keyed path is still not exercised. diff --git a/docs/superpowers/references/run-queue-fairness-ck-findings.md b/docs/superpowers/references/run-queue-fairness-ck-findings.md new file mode 100644 index 00000000000..a0e806fd627 --- /dev/null +++ b/docs/superpowers/references/run-queue-fairness-ck-findings.md @@ -0,0 +1,122 @@ +# Per-concurrency-key fairness spike: findings + +Throwaway spike. Ships nothing; delete before any merge to main. + +Bottom line: the base-queue spike's direction carries over to the real seam. At +the concurrency-key grain the production baseline (serve the oldest-head CK +first) starves keys that arrive behind a big backlog, and virtual-time (SFQ) and +stride fix it: they cut the starved key's wait from ~1300ms to ~20ms by making +the backlog key wait its turn. DRR does the same. A CoDel wrapper on the baseline +makes it worse. This was measured by driving the real +`dequeueMessagesFromCkQueueTracked` Lua and only rewriting `ckIndex` scores to +express each discipline. That is enough to say the ordering fix is worth a design +spike, but NOT that a production implementation is proven (see the fidelity +caveat: the spike serves one key per Lua call, and production dequeues in +batches). + +## What was driven, and the two things to know before reading numbers + +Runs enqueue across many concurrency keys under one base queue via the real +`RunQueue`. The per-CK pick is `ZRANGEBYSCORE ckIndexKey -inf now` in the CK Lua +(lowest score first, score = head timestamp). Candidates rewrite those scores +each round to encode discipline order; the baseline leaves them (production age +order). Enqueue, dequeue, concurrency gating and ack all run through the real +code. + +Two caveats a review forced, both load-bearing: + +1. Lead with wait, not contention share. The contention-share metric is + volume-confounded for low-volume keys (a key with 15 runs cannot take a third + of a long window even when served instantly), so on these scenarios it lands + around 0.7 to 0.9 for a discipline that has in fact eliminated the starvation. + The per-key wait is the clean signal. +2. The scenarios must give keys genuinely different head ages. An earlier version + enqueued every run at one timestamp; with tied `ckIndex` scores the real Lua + falls back to a lexicographic member-name tie-break, so the "baseline starves + the heavy key's rivals" result was actually "Redis sorts by name" and the + heavy key only won because "heavy" sorts before "light". Fixed: the backlog + key fires at once (persistently old head) and the other keys arrive via + poisson (distinct, later heads), so the baseline now exercises real age order. + +## Results + +`contWorstS/W` mean over 3 seeds (min..max), and the worst-served key's mean wait +(seed-a, logical ms). Read the wait column as the headline. + +| scenario | discipline | contWorstS/W | worst-key wait | backlog-key wait | +| ---------- | ---------- | ------------------- | -------------- | ---------------- | +| ckSkew | baseline | 0.187 (0.186..0.188)| 1321 | 872 | +| ckSkew | sfq | 0.723 (0.655..0.769)| 16 | 1150 | +| ckSkew | drr | 0.608 (0.556..0.648)| 21 | 1149 | +| ckTrickle | baseline | 0.279 (0.254..0.291)| 1339 | 872 | +| ckTrickle | sfq | 0.909 (0.891..0.918)| 24 | 1237 | +| ckTrickle | drr | 0.790 (0.769..0.818)| 30 | 1236 | + +Full matrix (contWorstS/W mean over 3 seeds): + +| scenario | baseline | sfq | drr | stride | codel(sfq) | codel(baseline) | +| ---------- | ------------------- | ------------------- | ------------------- | ------ | ---------- | ------------------- | +| ckSkew | 0.187 | 0.723 | 0.608 | 0.723 | 0.723 | 0.104 (0.000..0.157)| +| ckBalanced | 0.515 (0.444..0.600)| 0.611 (0.462..0.800)| 0.730 (0.615..0.909)| 0.611 | 0.611 | 0.464 | +| ckTrickle | 0.279 | 0.909 | 0.790 | 0.909 | 0.909 | 0.018 (0.000..0.055)| + +## Verdict per discipline (at the concurrency-key grain) + +- SFQ / stride: fix the starvation. Contention share improves (skew 0.187 to + 0.723, trickle 0.279 to 0.909) and the starved key's wait collapses (skew 1321 + to 16, trickle 1339 to 24) because the backlog key now waits its turn (its wait + rises 872 to ~1150 to 1237). Identical to each other on every scenario. + Recommended discipline for the fix. +- DRR: fixes the wait just as well (skew 21, trickle 30) and its contention share + tracks SFQ within noise (sometimes a little lower, sometimes higher, e.g. + balanced 0.730 vs 0.611). Fine. +- CoDel(sfq): no harm, matches SFQ to the decimal. Adds nothing on top of a fair + base. +- CoDel(baseline): harmful. Hoisting stale keys on top of the age-order baseline + drove ckSkew to 0.104 (below baseline's 0.187) and ckTrickle to 0.018 (below + 0.279). A staleness monitor is not a substitute for a fair base. +- Baseline (production age order): starves keys that queue behind a backlog + (ckSkew 0.187, worst key waits 1321ms; ckTrickle 0.279, 1339ms). It is roughly + fair when keys are symmetric (ckBalanced 0.515, though seed-variant 0.444 to + 0.600). This is the #2617 dynamic at the seam where it lives. + +## Fidelity caveat (the reason this is not "proven for production") + +The driver dequeues one key per Lua call (`maxCount = 1`) and rescores `ckIndex` +before each call. Production dequeues in batches (`maxCount` default 10). Inside a +batched CK-dequeue call the Lua re-scores each served key back to its head +timestamp as it goes, so a once-per-round rescore would only steer the FIRST pick +of a batch; the rest would follow head-timestamp order again. So this spike +demonstrates the ordering fix only in a one-key-per-call regime, which is not how +production dequeues. A real fix has to advance per-key discipline state inside the +Lua on every serve (and hold that state in Redis, not process memory). This spike +does not exercise that batch path, so the correct claim is "the ordering fix is +worth a design spike", not "a production fix is viable". + +## Other caveats + +- Contention share is volume-confounded (see above); the wait column is the + trustworthy signal, and the contention numbers should be read as directional. +- The DRR contention-share gap is NOT the base-queue spike's batch-drain artifact + (that harness batched; this one serves one key per call and advances DRR's + deficit every serve). The cause of DRR's slightly lower share here is not + established; its wait result is as good as SFQ's. +- Per-CK concurrency gating never binds in these runs (no per-CK limit is set, so + it collapses to the env limit), so the spike says nothing about the per-CK + concurrency-limit-multiplication half of #2617, which is out of scope. +- A rescore discipline advances its floor/ring state on the final no-op drain + round of an instant (order() is called before the empty dequeue). It is + self-correcting and does not corrupt the event-based metrics, but it is a minor + infidelity to a production per-serve advance. +- Equal weights only; single shard, single base queue, single sequential + consumer; simulated holds on a logical clock; 3 seeds. + +## Recommended direction + +Score `ckIndex` by a fair discipline (SFQ/stride virtual time, or DRR) instead of +by head timestamp. Both spikes agree on the discipline and this one shows the +ordering fix works through the real dequeue path at `maxCount = 1`. The design +spike past this needs to: advance per-key virtual-time state inside the batched +CK-dequeue Lua (the `maxCount > 1` path this spike did not exercise), hold that +state in Redis for the multi-consumer case, and address the per-CK +concurrency-limit multiplication that is the other half of #2617. diff --git a/docs/superpowers/references/run-queue-fairness-research.md b/docs/superpowers/references/run-queue-fairness-research.md new file mode 100644 index 00000000000..4539c2a4125 --- /dev/null +++ b/docs/superpowers/references/run-queue-fairness-research.md @@ -0,0 +1,133 @@ +# Queue-fairness research (grounding for the caps-vs-scheduling reconciliation) + +Throwaway spike notes. Five Fable research passes, distilled. Citations kept so +the findings write-up and report can point at real sources. This grounds the +central claim: occupancy caps and fair scheduling are orthogonal knobs, and the +plan-of-record ships the cap knob while the earlier spike measured the scheduler +knob. + +## The orthogonality result (theory) + +Caps bound occupancy, not wait. A tenant capped at C in-flight with mean service +time S has long-run throughput <= C/S (Little's Law, L = lambda*W). That is an +upper bound on the capped tenant's share; it reserves no lower bound for anyone +else and says nothing about any tenant's waiting time (Little relates averages in +a stable system, not tails, and if the capped tenant's arrival rate exceeds C/S +its queue never stabilises so the law does not even apply to it). + +Scheduling bounds wait, not occupancy. WFQ/PGPS tracks GPS within one max job +(Parekh-Gallager finish-time bound L_max/r); SFQ gives a starved flow's head item +a hard wait bound of "one max-size job from every other active tenant" (Goyal-Vin +SFQ Theorem 2), with no server-rate assumption. But all of family A is +work-conserving: a lone backlogged tenant takes 100% of the server. Nothing in a +scheduler limits how many slots a tenant holds. + +Parekh-Gallager is the canonical joint statement: a worst-case per-flow delay +bound is the product of arrival regulation (leaky/token bucket = the admission +knob) AND a scheduling discipline (GPS/WFQ = the order knob). Neither alone yields +the bound. Cruz network-calculus caveat: plain FIFO does get a delay bound IF every +input is burstiness-constrained (arrival regulator on ingress) and aggregate rho < +C, but a concurrency cap is not an ingress regulator (it bounds in-flight, not +queue admission, and queue depth stays unbounded), so under adversarial arrival +FIFO wait is unbounded and the orthogonality holds without qualification. + +Sources: Little 1961 (Oper. Res. 9:383-387); Parekh & Gallager 1993/1994 (GPS, +IEEE/ACM ToN); Goyal, Vin & Cheng, Start-time Fair Queueing (SIGCOMM'96 / ToN'97); +Shreedhar & Varghese, DRR (SIGCOMM'95); Waldspurger & Weihl, Stride Scheduling +(MIT TM-528, 1995); Cruz, A Calculus for Network Delay (IEEE T-IT 1991); Kingman's +formula; Harchol-Balter, Performance Modeling and Design of Computer Systems (CUP +2013). + +## When a cap alone DOES cut a starved tenant's wait (the load-bearing condition) + +A per-tenant concurrency cap on heavy tenant H cuts light tenant L's wait to +near-zero iff BOTH: + +1. Slot availability: sum of caps of all backlogged tenants other than L is < N + (the binding aggregate limit), so freed slots exist that capped tenants can + never occupy; and +2. Eligibility-aware serve order: the dequeue selects the oldest ELIGIBLE item, + skipping items whose tenant is at cap, so L's head is reachable without + draining H's older items first. + +If (2) fails (single global age-ordered list with a head-blocking consumer), the +cap does NOT help L and with strict head-blocking makes L's wait WORSE: H's +backlog drains at k slots instead of N while the freed N-k slots sit idle +(head-of-line blocking; Parekh-Gallager's FCFS-gives-no-isolation remark). + +Trigger's CK dequeue is oldest-ELIGIBLE-first: the CK Lua gates each variant at +its per-key concurrencyLimit and skips a variant that is at its limit, moving to +the next-oldest eligible. So condition (2) holds structurally. That is WHY per-key +caps can work for wait here, and would not work on a head-blocking FIFO. + +## Where caps fail even with eligibility-aware order (the sybil split) + +Concurrency keys are client-chosen. A heavy tenant spreads its backlog across many +CK variants, each under its own per-key cap, all "eligible". The binding +constraint becomes the base-queue total cap (or env limit); oldest-first then +serves the adversary's older backlog across its many keys before a newcomer's +head. Per-key caps bound nothing in aggregate, because sum of per-key caps is +unbounded relative to the queue cap when keys are dynamic. The light tenant's wait +scales with the adversary's total queued backlog, which no per-key cap regulates. +Within a base queue, the fix under adversarial arrival is an order change +(round-robin / fair queueing across keys), not another cap. + +## Known static-cap failure modes (practice) + +2DFQ (Mace et al., SIGCOMM 2016): "Rate limiters, typically implemented as token +buckets, are not designed to provide fairness at short time intervals ... they can +either underutilize the system or concurrent bursts can overload it without +providing any further fairness guarantees." Their desirable-properties section +requires the scheduler be work-conserving, which "precludes the use of ad-hoc +throttling mechanisms to control misbehaving tenants." Pisces (OSDI 2012) and DRF +(NSDI 2011) both exist because static slot partitioning under/over-utilises under +skewed demand. Netflix concurrency-limits: static limits (Limit = RPS * latency, +i.e. Little's Law) "quickly go out of date"; hence adaptive. + +The price of caps when they DO give order-independent wait bounds: they degenerate +into a static partition (sum of caps <= K), which is non-work-conserving +(utilisation ceiling of sum-of-caps even when one tenant could use all K) and +needs bounded, pre-known tenant cardinality. + +## Production precedent: caps and scheduling are LAYERED, not either/or + +- Kubernetes API Priority & Fairness (the closest analog): total server + concurrency split into per-priority-level "seats" (a cap), THEN shuffle-sharded + fair queueing decides dispatch order within a level. Kubernetes hit exactly the + cap-alone failure with max-inflight before APF, and the fix ADDED fair queueing + on top of the existing cap rather than replacing it. (K8s docs; KEP-1040.) +- SQL Server Resource Governor: pool MIN/MAX/CAP percent (caps + reservation) + + workload-group IMPORTANCE biasing the scheduler's order. Same shape. +- YARN Fair/Capacity scheduler: minShare floor + maxResources cap + weighted + fair-share ordering, with preemption to reclaim the floor. +- Mesos/DRF, Borg: quota/admission caps layered with fair-share or priority order. +- Amazon SQS fair queues: fairness metric is DWELL TIME, fixed by reprioritising + delivery ORDER when a tenant's in-flight share is disproportionate, and + explicitly does NOT rate-limit per tenant. Order is the dwell-time knob; + occupancy limits alone were not it. +- Envoy / gRPC / Postgres connection caps: caps ALONE, and they claim overload + PROTECTION, not fairness. AWS token-bucket quotas claim "fairness" only in the + weaker selective-throttling sense, and rely on an elastic, rarely-saturated + fleet. + +Condition for caps-alone to suffice in practice: sum of caps comfortably below (or +elastically kept below) real capacity, and shed/rejected work acceptable, i.e. the +system never holds a contended backlog it must drain in some order. The moment you +hold a queue of admitted-but-waiting work at saturation, the serve order IS the +fairness policy. + +## CoDel (confirms the prior spike's "CoDel disproven" verdict) + +CoDel is an AQM: it bounds standing-queue delay by DROPPING packets when the +minimum sojourn over a window stays above target (5ms/100ms defaults; RFC 8289). +It is not a scheduler and gives no inter-flow fairness (RFC 7567: queue management +and scheduling are complementary, not substitutes). FQ-CoDel gets all its fairness +from a DRR scheduler; CoDel is only the per-flow AQM inside each sub-queue (RFC +8290). In a durable run queue that can never drop work, CoDel's actuator is gone: +reordering conserves total queue and total work, so hoisting one item's sojourn +down pushes others' up. Hoisting the stalest item to the front of an already +age-ordered queue re-applies the age bias the base already has, so it is a no-op +at best and a dominant-tenant amplifier at worst (a tenant that dumps a big +backlog owns the entire stale set). Facebook's server-side CoDel adaptation ("Fail +at Scale", ACM Queue 2015) keeps the drop (stale requests expire) and reorders +adaptive-LIFO (newest first), the opposite of stalest-first hoisting. From c9a0d1c63c5ef6d98ed56ad80bdeb90de01fceb4 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 16:32:13 +0100 Subject: [PATCH 14/17] docs(run-engine): add fairness explainer diagrams Two diagrams for the PR/design: the problem-and-fix (oldest-first starvation vs virtual-time turns) and the four-step mechanism with guardrails. --- .../diagrams/fairness-how-it-works.png | Bin 0 -> 176541 bytes .../diagrams/fairness-problem-and-fix.png | Bin 0 -> 88028 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/references/diagrams/fairness-how-it-works.png create mode 100644 docs/superpowers/references/diagrams/fairness-problem-and-fix.png diff --git a/docs/superpowers/references/diagrams/fairness-how-it-works.png b/docs/superpowers/references/diagrams/fairness-how-it-works.png new file mode 100644 index 0000000000000000000000000000000000000000..98e33bb88a65c01c76f0c9327dd12739fc71cf96 GIT binary patch literal 176541 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 `;{11jB&N*7k;W#DT}tb>myvguSaaFnl5D8 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�Y9^%>IxAG1|fz)x3TH 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⩔{tKI$YzV`QAUOTJ3c~0UR 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

GoMy=LVq)g{rrWc*3{% zKB>W`FbC|PRp&FQ=ECK(2amkqEg6XvZ3f`E}y z`t%znWK2n&k5--gVSnv0tpPC|918V!q$$Lk{D1*Jb3B3D!C>gA{la;at3|K5(90g} zrk(Jb=9F+%fla@l#W375ZzwZJEO@Q<@qC=-gr1jaU+>Alw`(x)brsse+Nxp4F0*>D zz?12Q9#vmxDdwL{H3Q33>Mvr@5|6JXHgSw0Eeur{MTU-a{-bngvbc;K97fEkmE(f! zU9chGyV$rnpo2VfDOzp3C5EeMIgC68zFWo9LhdDP$!Lw6gFe6Z^7h<=pflR-E=S{m zkm&4}*^m9<8$Y$IeNz>22jIK)I!4gH8w`gqs!wjnfLp2_vdf+lM| zDXEUzqB$gVlqtL3FU%>^lgJt&6k0-|GSx5l%PXTszUiV`Fu=bXg_!?+O)NAJqXep8 zx@s-yiz>j05d*KQrW9UB?7B37KG`Ua=68+dY#BE6v=eN{D1C^b*6balo60IU9e_`4 zgc4z}7U$`y0MP-l^^3hS?Kg<)IpX~MS}Fzw28|KCqlj`vcw6%mE-v9prLJy|ZTlf1 z#aI>zW$@iDXy!-NCeZz~Y;oYJp?SqkZUiV3!r_7fR!8dSwzCX8x9eQJF`N5Mfp)Km z7a6CA9p-=UCo7$=}2@=s{KdJd)k< zRywJQEFUY7SBsPtX>ol9@%3FZ30^aL2tEdcwz1f^+TqqV5BZhz#0#us0p>r}aTD++ zmXCI5XtqhG{M%#hy2y*SuEYG)S*Fz$fO)91;5Z|>kUpn(ir35cAhu7k(aPQ>{IJ%U zq^j~c<)&85FsMoIeYingoK0|(YK~gudM2dwx>|(iy<8#UTMcJ6IHIj)`ucF7fm_Ow zIV>j7(gHoLtklE>P7qF zw0k*5+lZn0x@tX%JRzS>Z50C;skc4-eSq6kUnJUSe?Ojm6~L;Mb~u|dbg=&%8Lrt6 z^>I{=XsDs>3JgN>z|*vuSt(RKutvHDo6GE`R{Mn~95L&+D2PI8=wzlYBJx2zzeVLn zV+W*B_P2a`hm%qNoc&a*`6o}>GfK$yF#AZjR6|M2a@=8Y+g^;*l=KnM=cCXm3X


pC^J{FJ-SO70BXX{bKXS<2d)FvIy5U?@VIBunz%VakfZww;{ql9^o#RRvIZa~2Dn zI>NKaA%zB9>6FiHBrHRwX=3<1XeN+yHK|GJ;=2NE0zIk4%-9w;uP1XBX@eo}jk&CU zFK>WdhePJoH|GL;D92HF(-=-wGf8L3T959vX-{G4P^LZb)MBDKFsNeVkjRuQ zo=S*&2y)rK&HwIxGeXc#Z>|Y!n>bW&5-v73;AidfNOQWEdeQ zRpPlOmH!Mk2ZMBuViGk*^q=ascoCdoFgvG_kmXv_9!YkcpGJZo=8A%^n&^ke7P#ID zMku3z1m{rw*{}X4ZdtE8dnI#{Wff}HVKvsrg)CG8_020Y4l_41Ixl)ZOmMm=U~9N% zTh3R_ZQf^iIPog-ufd4W{``=8m+^pF|(s6Q1Cgyv;b_zrs zz4K!8+#tBU!*%9E4a1#S=gDflhS<52lO6{N@kj&x^G#$zvy{<7`pxQnWHUXGh{vz; zOgR$pAZa8MKZBpcUXELT$MMcja)4_df51k99>cS<<4M%vFEw#e7pGCD4y7Kf08=Ux z#?^Ev(ZIF*fCV~UZisDmvHQf&<2AKS%OI6Ap1}Gk&~-X3&ESx5{)xJ`C{n?FjGmgz zVBvg*a?|8&(|(|wLuFuU>vZ*0*Z(_PwEuoxWE(j1M{%#-9!>A$0HC&UqYRRX$$a2N zIG@Mm0E~%Qq%7Ql)^3u`F<^l0Hh07Q)k5bqX|A8QPa{>rLSx(g2kSr z_BVT_#d@@cFXGjhcupE&cc7rn^vNGGKYq2+_Rb;vz9)Sd8ZZ`YQkjFJ{@qC2p^OD2 zWs__dcB8}yK8yvogfys_G6*R)0(U0`?gPHi&v#01W-9qMDk*wZ6EGPCfH0Y%FlNx+ z3b)$q-JDdRBCF@cf$X+pTrT+v2JLS1RIwCH$v*`~X(&s! zRHN{7Gu%fu@azE`Nz$g)LOlD7kGZrGynr((I~RuJR?EW*G^$M$)2<|B74BYQEHLHU z8{ZJvclzw?xf9aB@bu(d)?MgMRmMF&yW=cQ7hgYxv+g?c?>}f0X0c@S!rp7^oHX9< zk-wSvB~W=zXI_}K4W`jg#-d3z7$)Z(9>eKt+3|wcd}=D$jv}JAboyJau3grkx>t*0 zO%$V4VS>g79TQ!U0h@NX1|G=U=TKeoOu|upuGr;)%)We*p2r%Z2ZEzfI4F(^y|x_XUl%A@X}+ zhbOdGmd)lvCVM5)$h}*z;47s&o|=QkQk_7Dqt_bX8g`fPFu0fYg#NwyD|5vlcO*}NmqD~9UlRV4}MKlz#W%p}X? zcZlR4u2WPF(wzje)!WC@$knb>OR{dJ-$Jf)n0Oa{h5hO!4|R)~;;j_GRyw-?m)!+c zq448x`u*Db?F3>xu^JFW_;5a?dvwiO@-UJ%{CwcgSt@|33wikx6iDO>ReHE9>TX$Y>PeSeg}Un&3mX^+?= zsj)ClvMc|b{>gVwL~u%kL~`65k3CG6dtaKFURwV0`z1t2B;ToG)`3tp5?!K0(x398 zL_Z^)f$s8qZ6b=8BenJS*KIo83IX<;H*Zi&MgA9=Jy%OIlu=7#TIQcCgMR|!6YYP! z^fIwl@1=}X*#o55E$9EWdhN4wusGI}e0nb>rIGDI@`>z|(cfk-0ZQh6tWB$b50tRq z!xm7IYW|zcBX@NT^wO+0R!*mrf%^LY3~8&G@gG?CL)iYq0(!1DG>+FWy)=t84}JBw z$^Azm;~pD{iT6(f6xjm+#5ws>{JzYs(Ro3}gv%@skUZR(qZBB&{_pZQU)iH~jVHu?W*kO+$8Jd@B^oQ}DnbAVahYoHSU zUtE8NxBEU#GZV~C>`A-oo?o_U{;Tz_SydZGOE)qRYIOer-96Uxe{(|+msgT_phXfC zJmLDHE2xgo?DFLx{ZzEUTvvynVzmB{(Q_Z?Np!)MC?L!8Uf*zfV z^t)vLwU`T1Q5lsRs(3i6Yi82%nu_Xv;TLj(?0=#D8{Ia3mOaawFH}X(I%XuP^N|01 z9i~7a>za}a>De%pv1}QtnX37-11`NN$r86Bk0JIVBRzxR=qkr@dTH&uO9Zv}Q(JYP zbIUAno0KvGS76x({M)#Pe+T68Ij>v0!le=}A~;iVgQfoK*Uz=Lbv|qEQv`ai)p-m$ zY(!0@a6Nw!LEa1N?tXb8`JMiE#ce`^x5x{Ptdr~krX-nHRJL#0LfQ-MoRP*8N9H!S zNg5GW@_7>dS>vd2NC9v>Yt4FUs4}6AwP|oOy^0^0psX^R-jZN$6ER3#r(0j3#WJTf z8#!zC;4B~T&r{7wesG{K>5q8l8x-@VqNPWf*AqpaMpN892^u1sMyrY3$TfbKwUElrhTuOr5?JY@)dr zarw!zN*YQjJO*u+gYiFtVOwe0zcY*x2W6|C@JQ%Zo9Edf_V@47dWrM%z(tdS{+Bzh zd^>e~O7~5;8s$S?3Eho}g`tiEY`6QrJfw@g+CQ|dauEBcjZ0sjG&s&^q87*bV&$YS z^!M`_M8Qi9-H+?mC@3f@!sjW3WIBD_&)K#9J}z1Q^R2oec(ZPR1!XZlJ|5})zEk1` zS&6Ir@ziz|(B51$U~dIm2jlz$EneC#0$Z-fTPdZy;RfEn*QBjNdX4o7XI4LdQ`HRw z+21Db-&uJ6_tXEA7~{V^dFjS~>+b*B&i~Tr{{umuDRP)$>QBPX+m%GR~X&pvC+KuwcwjF|o=Zw*Xq$wv zjZAZH}kiK}ifgpUH^rI)MyqfXGcxW>> z2j|t=M+YqgRTa{(W#>C$JIOYq=yQ{CS3&6JCT&j;KiI4so=27J0zap|?ubWcdOc3BB@2;@ncdt7Hy$5){Z}eL}?R!=taoY+S zD2%?#`$T@dQ`@up@gvRp98{`r7hnu!_7U3o(8XjE5ADo*2WcBZaU|EE8oP^wmv|M) zzf+tPrz*J-W^sh<`#+pw{BIMxw=H`gq~3Cg;uSIziee3-%v75uHksU{UZ%73*dAv{i+~Tr`VY5h@zc{iE2FYd8W*dqN>Kijc*au>(0dj)Nl24LD8cz$D(qQ=1ccW8s4ZH zz6kmBn_Z4PAbS3CF}Fokkr<<%F&5iShBDD3p6~GWq4hNkaSG$I0hv!3x!wGi%jy-a zM*n%p^;-^@_TLBbQC)g*d>ucBq3-z_NO27b{P24@e@&r&N=Cz2R8usFb%s>ShriJZ z1?(9|vU6N{Up)n0xsT4Zlo!aDm`K`TD)beI-C$A`Y1_i<&6Dpgwp$ zQ8_5iH|hQCtOFY)E8*j?>ml$&j?#E7USOmWe8}?1+Xo4I!|V~hUi@vR%9h=yqp7N+ z4_>gAoS{OyJ{Z2JJEL_y0GkI+Np)7ptj408mL{7MI6{|jx-RZ(11xFWw47UkL4ot{ zRS1lD<5(mTR`HsJxuj~uD5*SS#l+MolEL@&M8<@sr{PtmJ?bk- z9kHH__?RXV+=`r>s=9C}PkB_p<6%sK7!x^g7;6-Q(4FObZb(hu70tO%*IH9hb&{^; zcoquJ{fd&74bYjA1%kQdWSTF zQiZ9lSwaCg&kNj3#KF)aW|!pjCq?w>a{tmz%svgz@7e3TL(Rh;$gRV~2r!0=InTyG zHQ)aFI%UJkY@`^5S>lbAKqb6!S{wP~pN-Cfn7`VRUx#>X;Pc(amhLe6VVVymy8!-$ zoI;yo2ayE_Aw6Ht`sY#L!9ek%Uf)giaJf5W=nH3SY|TZT&g+@O_6x)A2o47DN`f3( zY>}$1ay2;M`ADw1Gof^?gh}ug8Gh2AGHC2!zXZd6Vk~|8Vsx)W34QxEM3IulBu;i> zMW*I9HIGmI**AZeq-J(=-;0Zsqx3gS4BOdfxrLQ&PHz_?7af6KIJ7m>V5NV?&>UIQ zL{^wF%0BHpl5rR0b2N%J(#qGx`5iZ|fTq~Fh!JSDG69ju0}!|z$B)WG_znYkUG#=x zlZ_4|!!x#m%`hL$hc?2s{2IT(#4jl1%RZffiy~=hZ7W1i!MY=sdwK99{ZLd%P|!D+ z^ll>hU>?ci>9W!hW-^yLLfWK%^V%$%lH!=)q4GnW@-dIinTEi?QkGI1gPpi~ho?2_ zhN?6j-i9{{7K@sXX8MH^Z=hGMBg$hsLCYy45K3$m;GiMjuPW~$x$bfp9W@jQVE1XN zJjPG-UkuhH#7|-U%uFsH=nyA=`wMaMA9X+}(QZw240K1tyb9Z8 z?r8KY`>TRs<*~FkTE_HRXD^Sf=O0;^_;XOJeM8a<2L^(k|0>%V0#}AJFcd+$VU>sL z%t}y16L=b}7yOIiP?XnOTsM`i`0AC;w(B{kLQrE!1{cEXD5x;s zKKUBhZ6Dad`M|c+8DT!0Etp$?#M+M^nhp(%tY5t^cp!3dAz`TapxGI%*&A38Q@h-|vpmsYmT(zfh&#~oQv2J;42T4P3T zIX*FgN$7ZX6whV>9p97P=tt)d zfJ}f|=sSpZN&F9}P5)E~-F7kqhnpuEw+z>8V}CMz!Br$m)y8UPFzM7_gfT{85Y=d~ zT3yw)0O!vV!f@}zOGQry-XU$1_BrcN6hb8@BguuA^yBY-%k3Hkfpp-ACCO8-9JjS= z2koDyfFjQB4=@<0KWr_a6=kM2E=$`G-!GE*9lcUm`eQJ!QCtnAt@VlnYAdg-7DHL% zNnAzptwG>pnxYNLyRMw>SO)=tDDfgV#?ID5rB!ag8WRewjUCHxfOi zGkRi!<|Lti8ba;1OHO7V5$yI8##HHTygwrnLove+$U6WsYjtBtnvrh`E@S(~sPToR zoh9{4+R!{yLEOu)Al;(kkFRph&NIBvOvUKjownLfDiEeh2CWi=cZWV7Shi?oK%TiJ z-7x+yRvFLH0>7$vZdIuCZRls^D@AeFX73xvbup%KJ9^tz?@wB3tfyKSMQpU}rKrDr?=D0dm<@YbCM=y-yr9?GGFL zxxzaR^v++Jjm$OrD?RK}zE4%u=ld-2Zn}3&5SThXLod|QjZiavL87L=s{(PRa30FC{u!nj|4bRfzc87Xhigj0BG zCSE###d|Yg47Hs(0Ser&%J09pUV_ZYqM2iJBJ!aZuQf(2FOOR}Aq9dKY7=eUD<@7_ z6`R4w>2G#KjNdwhP{j*x`9Xy{a|;t&oCk`N?87+Tw$_*VJI30LVf}-)s$I5_{B>KF z`b~3p3xbjYxi8`d!~NWYrrZuHy2`v3xU6KOh2Knk`Jv1+nA+4`jh~G%)Z6=dOf!TP zIsk3J6E`It9whNBCr2E`>Uyxpp8$z&JIHGkn5+%{U4}A5XF8AI)qcv*(=fA)tE`ot%2d1hn#>Ek}HntPJ7nq4Oww2bMk1iyXs;-ZZ5viDeTJ*3Y2#I=g-|HPG-r;nIS z^F56F@v_Ftr!m{i2LEYCsfEG^^RUqhuDQoRD*!TA&=5Cb>hzp_s#Jy+-|hfW-8Ue| zWGUKaw2gr{Gz z-MSr`_aagb0v^zBh%f&3wk>f)u%Bx|(W$yr(PFn#}RN=oXH|Cd4~vaDJF zjO`h#Y{mHLy|Sa4kl(u7h*&WrFMWKFVk(P5f!3|QHTlSw!OyjYUpm)Fde&R-GA_j- zu64Y}bVOiPgDu#njWCrCwiq16;la>TiHFHo0C@~^DAB1#)&XtLwkkq=1s=M&m=;&nSxdJU37_4H4vG6Irk>NDgz$?Lvg9{67xw_y5e@HzmtEgG$ zeUtgEq7unNd8cOH%M1IbfCOK2` zNu8m){Dee)iH=V7`rDg_oITGPA2*)d+DlvH&T|DEjvF+)o%OwOl1xM-nv z{uHlTVS^}O3^xIU@0&4Br`iUdFTrE77ZJJ}w+go0O|^85GwNIKZr=J-s1?d(#~Y)j z$8>YV{E+WHuhCs1z8IAc(q5`;LN{W~ojAG+DMTgWprhA9)qp`0gZ!5?j zp+flmOrH)&*!gp$M@ZdBcoI7+&5ZJ>a^YH`1MuN&RGr-)^R+#%z(^MQe}RHAXD+Nc z&!pzp;!uyX5ot-Oz{$N`4cRbX~lR{+70F#doo( zha1nXfSRfgtCDr<5QXbrO|i+@SE>QITHp^ZlONY#eDOv_Y$W^Q|5Hn#3NYLq0Y z%%J%pWhg?M8R^?XL%1_Oe?b_Lfsph8$-M|IrX^@hr?!cA=N$HsVn|BOv$X^Pt0nv* zKN+Ol$^WfT-=+>~mFFWBKhP8AqMdR?*LC~k%)K^|@%0US12>M1)0;LYu}Bla6l#d#A58#@nSQ+;ODw8 zfZ1JyB2-$SmE0TvHKhg8v{zz;za-nr3SHyhb=Tq?mJkQ&;J9fIbAc5?kJI+t%YL+d zku7rZn@@70FOV_7iTdy;~@a!s^cj6^FN(zRrX4_{z>=n24s8=ZRsM=Zey0qH`#CwfZxx2GO>KL_FN?9%f-#HB2<< zn5=V}VDL3$88UT`ZiX~zRG2(A*xihR>VMxq!~!?pfX)%1F_)>0FSe+IbcmGi7dHt2+%GqOUY3UbXgCEu^BzqOd<^&M;MIv98(>@Fklg;UH-atnRCYcPmg$N4+1V zRLFTCPH|SuQ9$25M|=B)K68i|b;5zSvQ{c^{lL?XEiy6H`SYDSd>N61${^^6RuWk{ z$1!a1@_~v1BG$pZ8ERSf8(lJma<%0?e~csLvsv+2eJt)?4J!>gXszf7Ovp*h7-Qt+ zb{9gg!=i$8SgV$q&kyb~`O0->3BG#GZ%x!^45dr**6mdaz?-?SI&rIXXVrGyiPsYG z2>VsBDnE;Q09r7EqqDAdFmC_+t$a%3Yb=Ckm$h;?}uEU0N-#~3eNsPI7$UfjdG-PH2(H*gaeVm;B4oZ z^FWmSqL|#7|MMrTD~g{lWg#UD5d8>IYhR^+RK)9-x@$)2w$W`g%{0XiN}VjS^I`g> ziBt?p9q|9+cPm@McsHo7Ay9GY8Li6ji8?uvh~(Y*Zeg z`r9y}u(4(PD^7--F-cS2u^13%5PZ-(R$`1^T6c3JeHt^Ri~Okz+iz6_*-v*!4wz+| z(I#kg4;Qi7OW=gXXBN3Cri$lzP5etf96H`|G+xuI8jG5|)mE2Zxvu*4w;I~ve?GM7 z{z~Ct*>j|XCXUwq>EM29s*-}*r>C8yM%L!%g0QCVrHAK5Eh#fMI7 zkkBNTzXc=7Nt!V_XZn7Q3c23Cs;URmVdMn}*vS-O@*7yFTM^d&Y`^bqJ+T;oO+~gFJcMlDA8KqyhVMdVY z(qTYBMav-~xAbg~mDd$^eC;1XJZ8|oMY$Dg@;W5}%RW1G$(68^Yho+oP=$7G?Y43B z(s+=ebvEj4ti~-(P1W}Ymf^ZTHRPwxHw#Jh$({#cV^&4{(SSuvIhU9noq+~ll((PW zD{Et0F<-2|g;%!IV{XQy+w1WcZCee`_(aUL8e}a$wqaDH0SlY0UJKI-lf(Y?3r%;$ zkYF0&4!tsPbU_#@%`1b$Sd+>*XVttwcvT~%)hgCv_pa&q;9<`Qzc)VjkY+MHE!LBw zZ$)Fh)k}j=0XOzuu`FVdS9HmMfXn0p-{e3%{a0ExisqT7+dq%Z)DKP_(>2e!u3(vb z-LTeWHIo?Ev+J<80_deOXH9}ZVl9=jop0M?#jBwq(4&i~jKo}^GkT||TKP%z4gcLT zF?yZ*bX}X_A7`TU1y`I2FM&rA%iLK|69$i@s*(D1iivmdz_|);v%ImnHi0u$dRCSP z@pnJULAKg`TPAKNnEH8bWw&ojm8HV^2;C!NL^dt~zNg!vM{COwxEGzi2@{sFbuX%E zs_SNWpv(I{!+VL3jFC9olyKr^BOJgxPczW)o9K3qC)}*!r*y4xiprhxjw@g|Wl7R~ zh6+cFDoXxW z>xxRMx_)Oht(i}mqSm-#0?&k5w9mCE$nLS6$FojE97upOr43hZMda3SvY$r1+{k~O z{aDNoCQzrxOYvFkOBNHaHOCxyahVA?6saak7XW~7fC_TJv?HH@U}rrSs>O6a$Ccu& zTWWC)N^$<;$k!z;QjzaJHMAZ}=bp5ZtUEy{JK66sNh~tZcW|I*ppAZK)mdW(H_(4H zLGBaB?Z+e0{w-4H*ex`nRP!|=_+m=r0@Y>6yAsQPdu`+9_Xsf5H1W&ABsXXAmIOy2 zgA+XDRf!05kt&z67vr2#-ks^6Qsb)H+62 zT9%)cjn=cF^LCi$PL9N#E)tZOh@4lcl7gzzqsK%Y?rTr&+8gR}VjwCF)Ha(-nA+)>CwP~Z7qW<=v*rzo2l)Dkn=gOY_ zy7&&eXZR|Jf@9vN*rgqYUTG6MF8iGM4HhimLcDM#q_=l7qrweuE-Rx>r`X!Pm*5rlVUZchB%+dD1~zQunTJJ?8h1l1k5=hB*l)&7U(_l)i~{IYhly zWN+MiBG&8EC49R|Nf5{w5I+5u=fa(R;V8FNRD?L`@4gdrSt)A|C-Vdzoe|d2G>-ah z%>Q(-da?!Yc@97b%d(!$=6tlHixFWBfeu+TK$$E){d9D z=7MpYY6QAFq{I0ZDj8_#w4XNDYsJVtOS{S1FC(8a97+EuyY5oUVDgpNn|9)yTGWYWIE%TKj*mV;CP*t8H6<0^8_fpb42k`?-ievZpuaB zP8suJ@gDZVSga(NAkXwaC~$Ehl;>k! z_=ijy%uE-Ro_w}Y$_qCuI@VMq45!kd12!maLO@P3@1c2Wcr)I88|6r0W+hgKk#w6? zk8dI^Bt)wIimQJBXa9C(4Whqa@U;3Ou1mgn7Jueltrqm%U_>3*mnSmRulp&sVasR! zYqCxvpMl3xMW0K%gr=jmt*2p^``fYW0)d{F+@t=O92r@ze!3Akp%Gl&RwhTp-A6Ld zHyf{w(~t@4Nni{=yqYKhBtZJDO>fWS7kZeuWjkzBkaNtXXEo3Dus^%B_u+3NkNZlI{x?JmO!X@ j)DTSkaQ(M{x^l^5SA{F#ZE@}IJ}AhlzOH^{`r-cohj7i) literal 0 HcmV?d00001 From 08c1516690f3a0f81556ed81d84e41782360e1d6 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 16:46:10 +0100 Subject: [PATCH 15/17] fix(run-engine): move design docs out of the Mintlify docs/ tree; format check-broken-links parses every .md under docs/ as MDX and can't parse the plain- markdown plan (code/angle brackets), failing CI. These are engine design docs, not user documentation, so relocate docs/superpowers/{plans,references} (plan, findings, research, diagrams) to internal-packages/run-engine/design/ and fix the internal path references. Also run oxfmt on runEngine.server.ts (missed after the review fix wave), fixing code-quality. --- apps/webapp/app/v3/runEngine.server.ts | 17 ++++++++--------- ...26-07-23-ck-virtual-time-scheduling-plan.md | 4 ++-- .../run-engine/design}/references/README.md | 2 +- .../diagrams/fairness-how-it-works.png | Bin .../diagrams/fairness-problem-and-fix.png | Bin .../run-queue-fairness-base-queue-findings.md | 0 ...eue-fairness-caps-vs-scheduling-findings.md | 0 .../run-queue-fairness-ck-findings.md | 0 .../references/run-queue-fairness-research.md | 0 9 files changed, 11 insertions(+), 12 deletions(-) rename {docs/superpowers => internal-packages/run-engine/design}/plans/2026-07-23-ck-virtual-time-scheduling-plan.md (99%) rename {docs/superpowers => internal-packages/run-engine/design}/references/README.md (96%) rename {docs/superpowers => internal-packages/run-engine/design}/references/diagrams/fairness-how-it-works.png (100%) rename {docs/superpowers => internal-packages/run-engine/design}/references/diagrams/fairness-problem-and-fix.png (100%) rename {docs/superpowers => internal-packages/run-engine/design}/references/run-queue-fairness-base-queue-findings.md (100%) rename {docs/superpowers => internal-packages/run-engine/design}/references/run-queue-fairness-caps-vs-scheduling-findings.md (100%) rename {docs/superpowers => internal-packages/run-engine/design}/references/run-queue-fairness-ck-findings.md (100%) rename {docs/superpowers => internal-packages/run-engine/design}/references/run-queue-fairness-research.md (100%) diff --git a/apps/webapp/app/v3/runEngine.server.ts b/apps/webapp/app/v3/runEngine.server.ts index de7e619947f..9c47f10451c 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -106,15 +106,14 @@ function createRunEngine() { batchMaxSize: env.RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE, batchMaxWaitMs: env.RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS, }, - ckVirtualTimeScheduling: - env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED - ? { - enabled: true, - quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM, - scanWindowMultiplier: env.RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER, - stateTtlSeconds: env.RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS, - } - : undefined, + ckVirtualTimeScheduling: env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED + ? { + enabled: true, + quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM, + scanWindowMultiplier: env.RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER, + stateTtlSeconds: env.RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS, + } + : undefined, }, runLock: { redis: { diff --git a/docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md b/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md similarity index 99% rename from docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md rename to internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md index 2865c0ba1c3..32ae6b2d6ba 100644 --- a/docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md +++ b/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md @@ -4,7 +4,7 @@ Implementation and testing plan for the recommendation out of three fairness spikes. The spike harness and benchmark code are archived on the remote branch `chore/fair-queueing-spike` (throwaway, never merged); the findings and research they produced are kept alongside this plan as references: -`docs/superpowers/references/run-queue-fairness-ck-findings.md`, +`internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md`, `.../run-queue-fairness-caps-vs-scheduling-findings.md`, and `.../run-queue-fairness-research.md`. @@ -298,7 +298,7 @@ File: `index.ts` (RunQueueOptions, ~line 60). /** * Fair (virtual-time / SFQ) ordering across concurrency-key variants of a * base queue. Off by default; when off, the exact pre-existing Lua commands - * run and no vtime keys are created. See docs/superpowers/plans/ + * run and no vtime keys are created. See internal-packages/run-engine/design/plans/ * 2026-07-23-ck-virtual-time-scheduling-plan.md. */ ckVirtualTimeScheduling?: { diff --git a/docs/superpowers/references/README.md b/internal-packages/run-engine/design/references/README.md similarity index 96% rename from docs/superpowers/references/README.md rename to internal-packages/run-engine/design/references/README.md index e16ab19a9da..2cd163408cc 100644 --- a/docs/superpowers/references/README.md +++ b/internal-packages/run-engine/design/references/README.md @@ -1,7 +1,7 @@ # Run-queue multi-tenant fairness: spike references Reference material for the implementation plan -`docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md`. These are +`internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md`. These are the findings and the queueing-theory research produced by three throwaway spikes on RunQueue tenant fairness (#2617). The spikes' harness, bench, and results code was throwaway and is NOT on this branch; it is archived on the remote branch diff --git a/docs/superpowers/references/diagrams/fairness-how-it-works.png b/internal-packages/run-engine/design/references/diagrams/fairness-how-it-works.png similarity index 100% rename from docs/superpowers/references/diagrams/fairness-how-it-works.png rename to internal-packages/run-engine/design/references/diagrams/fairness-how-it-works.png diff --git a/docs/superpowers/references/diagrams/fairness-problem-and-fix.png b/internal-packages/run-engine/design/references/diagrams/fairness-problem-and-fix.png similarity index 100% rename from docs/superpowers/references/diagrams/fairness-problem-and-fix.png rename to internal-packages/run-engine/design/references/diagrams/fairness-problem-and-fix.png diff --git a/docs/superpowers/references/run-queue-fairness-base-queue-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md similarity index 100% rename from docs/superpowers/references/run-queue-fairness-base-queue-findings.md rename to internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md diff --git a/docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md similarity index 100% rename from docs/superpowers/references/run-queue-fairness-caps-vs-scheduling-findings.md rename to internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md diff --git a/docs/superpowers/references/run-queue-fairness-ck-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md similarity index 100% rename from docs/superpowers/references/run-queue-fairness-ck-findings.md rename to internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md diff --git a/docs/superpowers/references/run-queue-fairness-research.md b/internal-packages/run-engine/design/references/run-queue-fairness-research.md similarity index 100% rename from docs/superpowers/references/run-queue-fairness-research.md rename to internal-packages/run-engine/design/references/run-queue-fairness-research.md From cf524165e75a17ab8e050339f50e27896c2d2868 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 17:39:17 +0100 Subject: [PATCH 16/17] docs(run-engine): tighten CK fairness server-changes note --- .server-changes/2026-07-24-ck-fair-scheduling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.server-changes/2026-07-24-ck-fair-scheduling.md b/.server-changes/2026-07-24-ck-fair-scheduling.md index 32ffaf3261d..d9e04336819 100644 --- a/.server-changes/2026-07-24-ck-fair-scheduling.md +++ b/.server-changes/2026-07-24-ck-fair-scheduling.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -The run queue can now schedule runs across concurrency key variants fairly, so one tenant or key with a large backlog can't starve runs waiting on other keys. This is opt-in via a flag and off by default, so nothing changes unless it's enabled. +The run queue can now serve concurrency keys fairly, so one key with a large backlog no longer starves runs waiting on other keys. It is opt-in and off by default. From 065ef01f63e633877bdf83ed5e3b07035b7a32ce Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Sun, 26 Jul 2026 01:53:30 +0100 Subject: [PATCH 17/17] docs(run-engine): fix stale references in the CK fairness design docs Reframe the retained findings/research headers so they no longer say 'delete before merge' (the spike harness is archived and ships nothing; the findings ship as a design reference), and replace the placeholder .server-changes/2026-XX-XX filename with the real dated file. Addresses CodeRabbit notes on the design docs. --- .../plans/2026-07-23-ck-virtual-time-scheduling-plan.md | 4 ++-- .../references/run-queue-fairness-base-queue-findings.md | 4 ++-- .../run-queue-fairness-caps-vs-scheduling-findings.md | 2 +- .../design/references/run-queue-fairness-ck-findings.md | 2 +- .../design/references/run-queue-fairness-research.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md b/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md index 32ae6b2d6ba..26a8311d45e 100644 --- a/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md +++ b/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md @@ -236,7 +236,7 @@ Create: assertions) - `internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts` (multi-consumer correctness, op-count budget) -- `.server-changes/2026-XX-XX-ck-fair-scheduling.md` (at PR time; note it +- `.server-changes/2026-07-24-ck-fair-scheduling.md` (at PR time; note it ships dark) ## Tasks @@ -752,7 +752,7 @@ Verify: `pnpm run typecheck --filter @internal/run-engine` and ### Task 10: ship notes and cleanup -- Add `.server-changes/2026-XX-XX-ck-fair-scheduling.md` per +- Add `.server-changes/2026-07-24-ck-fair-scheduling.md` per `.server-changes/README.md` (user-facing wording; it ships dark, so the note says the fair ordering exists behind a flag and changes nothing by default). No changeset (no public package touched). diff --git a/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md index e386aba8d5c..c360f78328f 100644 --- a/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md +++ b/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md @@ -1,7 +1,7 @@ # Fair-queueing spike: findings -This is a throwaway spike. It ships nothing and should be deleted before any -merge to main; it exists to inform a design decision, not to become code. +Findings from a throwaway spike whose harness is archived and ships nothing; these +findings are retained here as a design reference that informed the change, not as code. Read the caveats section before quoting any single number. Two of them matter up front: (1) the candidate selectors are handed tenant identity (parsed from the diff --git a/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md index b1daf94ee84..f89257709d3 100644 --- a/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md +++ b/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md @@ -1,6 +1,6 @@ # Caps vs scheduling: reconciliation findings -Throwaway spike. Ships nothing; delete before any merge to main. Relative ranking +Findings from a throwaway spike whose harness is archived (`chore/fair-queueing-spike`) and ships nothing; these findings are retained here as a design reference. Relative ranking on a small simulation, not a statistical or load study: read the verdicts as "what this harness supports", not proofs. Went through a blind two-model adversarial review (a third stalled); the review's fixes are folded in below. diff --git a/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md b/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md index a0e806fd627..04f615c9023 100644 --- a/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md +++ b/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md @@ -1,6 +1,6 @@ # Per-concurrency-key fairness spike: findings -Throwaway spike. Ships nothing; delete before any merge to main. +Findings from a throwaway spike whose harness is archived (`chore/fair-queueing-spike`) and ships nothing; these findings are retained here as a design reference. Bottom line: the base-queue spike's direction carries over to the real seam. At the concurrency-key grain the production baseline (serve the oldest-head CK diff --git a/internal-packages/run-engine/design/references/run-queue-fairness-research.md b/internal-packages/run-engine/design/references/run-queue-fairness-research.md index 4539c2a4125..afb831de568 100644 --- a/internal-packages/run-engine/design/references/run-queue-fairness-research.md +++ b/internal-packages/run-engine/design/references/run-queue-fairness-research.md @@ -1,6 +1,6 @@ # Queue-fairness research (grounding for the caps-vs-scheduling reconciliation) -Throwaway spike notes. Five Fable research passes, distilled. Citations kept so +Research notes distilled from a throwaway spike, retained here as a design reference. Five Fable research passes, distilled. Citations kept so the findings write-up and report can point at real sources. This grounds the central claim: occupancy caps and fair scheduling are orthogonal knobs, and the plan-of-record ships the cap knob while the earlier spike measured the scheduler