From fd97986638660c6a0adb1055f7f1e878bb0459c4 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:34:35 +0100 Subject: [PATCH 01/26] 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 0609b3d719b..c00ee47aba4 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; @@ -315,6 +317,14 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return `${this.baseQueueKeyFromQueue(queue)}:${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}`; + } + // indexOf instead of /:ck:.+$/ (queue names are user-controlled; polynomial regex). // Only strips when at least one character follows ":ck:", matching the old semantics. baseQueueKeyFromQueue(queue: string): string { 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 8a7d3c93ec5..b82153bc99b 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -132,6 +132,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 52b7fc15ad617f3abb2d7933b1878b53ffa0d0c9 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:38:53 +0100 Subject: [PATCH 02/26] 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 58225cc5051..d37bc2644f8 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -214,6 +214,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 { @@ -298,10 +313,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 ce2c812329c94f6297fa2cc4911dbb61e44609f3 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 01:00:18 +0100 Subject: [PATCH 03/26] 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 | 299 ++++++++- .../src/run-queue/tests/ckVtime.test.ts | 586 ++++++++++++++++++ 2 files changed, 863 insertions(+), 22 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 d37bc2644f8..7e0e48c1131 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -2512,28 +2512,61 @@ export class RunQueue { const metricsGaugeArg = this.#queueMetricsGaugeArg(); - const reply = await this.redis.dequeueMessagesFromCkQueueTracked( - //keys - ckIndexKey, - queueConcurrencyLimitKey, - envConcurrencyLimitKey, - envConcurrencyLimitBurstFactorKey, - envCurrentConcurrencyKey, - messageKeyPrefix, - envQueueKey, - masterQueueKey, - ttlQueueKey, - lengthCounterKey, - runningCounterKey, - //args - ckWildcardQueue, - String(Date.now()), - String(this.options.defaultEnvConcurrency), - String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), - this.options.redis.keyPrefix ?? "", - String(maxCount), - metricsGaugeArg - ); + if (this.#ckVtimeEnabled) { + span.setAttribute("ck_vtime_enabled", true); + } + + const reply = this.#ckVtimeEnabled + ? await this.redis.dequeueMessagesFromCkQueueVtimeTracked( + //keys + ckIndexKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + envCurrentConcurrencyKey, + messageKeyPrefix, + envQueueKey, + masterQueueKey, + ttlQueueKey, + lengthCounterKey, + runningCounterKey, + 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), + // Must stay last: the gauge fragment reads ARGV[#ARGV]. + metricsGaugeArg + ) + : await this.redis.dequeueMessagesFromCkQueueTracked( + //keys + ckIndexKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + envConcurrencyLimitBurstFactorKey, + envCurrentConcurrencyKey, + messageKeyPrefix, + envQueueKey, + masterQueueKey, + ttlQueueKey, + lengthCounterKey, + runningCounterKey, + //args + ckWildcardQueue, + String(Date.now()), + String(this.options.defaultEnvConcurrency), + String(this.options.defaultEnvConcurrencyBurstFactor ?? 1), + this.options.redis.keyPrefix ?? "", + String(maxCount), + metricsGaugeArg + ); // Reply is [flatMessages|null, gauge|null]; the CK aggregate gauge rides here. const gauge = reply?.[1] ?? null; @@ -4626,6 +4659,201 @@ else redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) end +return __qmret(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: 13, + 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 runningCounterKey = KEYS[11] +local ckVtimeKey = KEYS[12] +local ckVtimeFloorKey = KEYS[13] + +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') +${QUEUE_METRICS_GAUGE_PRELUDE} +${QUEUE_METRICS_CK_DEQUEUE_GAUGE_LUA} + +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 __qmret(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 __qmret(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 __qmret(results) `, }); @@ -5962,6 +6190,33 @@ declare module "@internal/redis" { callback?: Callback<[string[] | null, number[] | null]> ): Result<[string[] | null, number[] | null], Context>; + dequeueMessagesFromCkQueueVtimeTracked( + ckIndexKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + envCurrentConcurrencyKey: string, + messageKeyPrefix: string, + envQueueKey: string, + masterQueueKey: string, + ttlQueueKey: string, + lengthCounterKey: string, + runningCounterKey: string, + ckVtimeKey: string, + ckVtimeFloorKey: string, + ckWildcardName: string, + currentTime: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + keyPrefix: string, + maxCount: string, + quantum: string, + windowMultiplier: string, + stateTtlSeconds: string, + metricsEnabled: string, + callback?: Callback<[string[] | null, number[] | null]> + ): Result<[string[] | null, number[] | null], Context>; + 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 a5403a8876dd1ddfdb0f2473013ee84f621033e9 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 01:47:22 +0100 Subject: [PATCH 04/26] 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 | 555 +++++++++++++++--- .../src/run-queue/tests/ckVtime.test.ts | 116 +++- 2 files changed, 600 insertions(+), 71 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 7e0e48c1131..52c31f60fc4 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -2194,74 +2194,151 @@ 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), - metricsGaugeArg - ); + 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), + // Must stay last: the gauge fragment reads ARGV[#ARGV]. + metricsGaugeArg + ) + : 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), + metricsGaugeArg + ); } 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), - metricsGaugeArg - ); + 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), + // Must stay last: the gauge fragment reads ARGV[#ARGV]. + metricsGaugeArg + ) + : 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), + metricsGaugeArg + ); } } else if (ttlInfo) { // Use the TTL-aware enqueue that atomically adds to both queues @@ -4019,6 +4096,277 @@ redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) ${QUEUE_METRICS_CK_ENQUEUE_GAUGE_LUA} +return __qmret(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] + +${QUEUE_METRICS_GAUGE_PRELUDE} + +-- 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. +${QUEUE_METRICS_CK_ENQUEUE_FASTPATH_GAUGE_LUA} + return __qmret(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) + +${QUEUE_METRICS_CK_ENQUEUE_GAUGE_LUA} + +return __qmret(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] + +${QUEUE_METRICS_GAUGE_PRELUDE} + +-- 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) +${QUEUE_METRICS_CK_ENQUEUE_FASTPATH_GAUGE_LUA} + return __qmret(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) + +${QUEUE_METRICS_CK_ENQUEUE_GAUGE_LUA} + return __qmret(0) `, }); @@ -6168,6 +6516,79 @@ declare module "@internal/redis" { callback?: Callback<[number, number[] | null]> ): Result<[number, number[] | null], Context>; + 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, + metricsEnabled: string, + callback?: Callback<[number, number[] | null]> + ): Result<[number, number[] | null], Context>; + + 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, + metricsEnabled: string, + callback?: Callback<[number, number[] | null]> + ): Result<[number, number[] | null], Context>; + 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 2c062f9a46aae130fb568f720fc654b0dbd2e601 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 03:19:04 +0100 Subject: [PATCH 05/26] 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 52c31f60fc4..c63366fbf69 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -3007,28 +3007,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 @@ -5797,6 +5825,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) `, @@ -6688,6 +6819,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 56f2e74f7f1263e4a8ceab37c1b04e6c3303432f Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 10:52:07 +0100 Subject: [PATCH 06/26] 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 ee40188ebb7a768a6c9ea62a664949ba78745071 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:22:07 +0100 Subject: [PATCH 07/26] 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 bc032a9aadc9125477bef1564baa7c1034f9c494 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:35:35 +0100 Subject: [PATCH 08/26] 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 3be57824e68687d5f1dde1904802ed9b0082c486 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 11:42:44 +0100 Subject: [PATCH 09/26] 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 | 5 ++++- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index b371d5994e9..dbcdda56d0d 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -992,6 +992,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 85986933290..f78fe6cf2a7 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -108,6 +108,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 07de80bed88..901aaaaffa5 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -235,6 +235,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 f37ec7df50a..4360f889039 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -16,7 +16,7 @@ import { } from "@trigger.dev/redis-worker"; import type { ControlPlaneResolver } from "./controlPlaneResolver.js"; import type { FairQueueSelectionStrategyOptions } from "../run-queue/fairQueueSelectionStrategy.js"; -import type { RunQueueMetricsEmitter } from "../run-queue/index.js"; +import type { RunQueueMetricsEmitter, 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"; @@ -126,6 +126,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 2c18a3332fe13a6950722539a7a2309fb65c4521 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 12:05:13 +0100 Subject: [PATCH 10/26] 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 896b75fab70b8800b7b363c1aef7543f4fe7d8b0 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 13:53:29 +0100 Subject: [PATCH 11/26] 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 dbcdda56d0d..2e529f89b1a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -994,10 +994,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 f78fe6cf2a7..e00b25c4fdf 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -109,7 +109,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 c63366fbf69..498d494bf50 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -217,8 +217,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; @@ -322,9 +322,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) => { @@ -4245,6 +4255,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') @@ -4377,6 +4388,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') @@ -5168,7 +5180,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) @@ -5919,6 +5931,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 eada071af7deb849584465f629db53ae1564bc9c Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 13:54:26 +0100 Subject: [PATCH 12/26] 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 c0ad264fa7e645d2943b10de0a4fdf1af18d41b5 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Thu, 23 Jul 2026 19:05:37 +0100 Subject: [PATCH 13/26] 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 d49b9caf9824685f4c26b2b792fbf6329c277ad0 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 16:32:13 +0100 Subject: [PATCH 14/26] 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 z#pvAHMMmZE83#VR6cm5;-5BwKxIXDa(wBvPA==4$TXlZ8$#;v7#tzcg?&*kmmC2>Y3#raeZ0ZpBaUvEj${Tv3r zoZoE=r*qjMY(6S@(sI2FH1&mjoiehN$%)&6>VPnni>HYF<-GNDEx59O=?wW8H5Ic5 z&kmuF5UZK}wI8*6=x;b}*oa#ve5{1ta~F)ZosJaL2MD z1Xck09Z!1C-P#q;Vp{z)ZZjmKtBT}4bcKsq##PnJM6sE*q*eKTM5jm-TEbWraK3le zDJ|P)?YzxuEl#}(frRqNCI@}emqK9MZISTw*7y!rSH4v<2>!9XXK(SxLeF*s1X;jR zv|2>RV=6pup^KRMq-(j)YMygVqFU19tRNuItnL_T#h60ye!!TJ{Wgh)My4ca- zqa*37m+p&+2k3|`RfFRagzs}a)(&&~SFPoN{`cYqh zsxkq^hKI0Z%2)m*3x!cN&pCNnzqNI4M!`E72)|C1b!Me`k>hRm!5P8zp13yS0}Zlc zbiCc^%Gn-jFG10yg64moQ90cL1BAyivfKpJGNy+%UN+L_C4F1gN?`m{OvxOknNg;* z!+%lnb2Q%V?1CNHD(a&&Ee9{`y*k|{Hk9FYli$T#ck1g2ylnYTCka{wn(y%?vQsDY zqb9iVy(AiNLPAQQpdWf_MeH-9YP>|+_eMg|4D7?Q%f)XYglp(GeA2L4ABU5f`)@PSD-C zJr86r&Y0qBSqA^2Q^OT1sZ91lm+x*_r*(fEbF@hStUht_wbj_;ORWbQIX*C2(c`0? zQ*FL&)ablnQFBO1yFR!1%V)Dd_d!tB*B7Znc+loAuI)R6kVaB~6^#GAXoB5u8-bG+ z26O{!flh(VxKHhFEG_q};AzvmUfk9vECFJnfR7jLSYvqB``LYkmRq^bJPl+9+|PR^ z+vzm+7wSGn%N7XVBOx9{z+ z{fZ&z=*KyJt7OjIriH8QQOox}Po@XAF~?XrlM=GfaQ2cScZ@^QF&$7!UDFz&Nt|_j zb8i+__;CNC1g&`}8X1SF#mEi^B0n(n3(w~b?D9NoOzZ{TT7%L>|5#JO@f#)|k?b~gRwrv=i1i^((K|Wl zdrc|s`P?LY1o)s2PtFh|2D-8-IL^RIL{`Ui{_setEav#Ks<`yA`E+h{Ekb8kxV!`OF2NyYO&CkqY_mv%g@IVfpEy zrj+e#f37Cpkls-B$Yehfcx~{iqI)4adZ2%np&+&H4iSw>A)Aw)X8wMgTQEqfX4Msl zhfC}UAEds7y$JDgv(e-pb%(W`_eSXQTfbvrQBsDI#mr22f6BPl;}P>ZV#yW{j*uO|@Boy|tx30)4lf7J&VA{^brWKe`!mX>+N zoMNi--qbjDP%bYn^7uG!oJK$a&uUzbm((LvWkjDT($Lv8wG5=p; zhc{SRdf@JXKjcmDtlwTP2oV^W6BR&bSlEH79G(tC2@;b@1#lZcySR+=;_E<~%13Pa zqOo8aj|B4h!at*Y(~3*>h`PC{+|Hq&pNMmCoOUZ_JR}Gk?#`nU0t9GPIJJJVhUi>) zkVtV}m@z9;BjFF^sN}%^)LxDcDL{RL>bt)9#xAf<#Zvl4d!Uh(*(z-37Xr)Fv>3#;ejGTYT1bwdk7$2w5O45$85dnq zd)JhXaVa~ONR%`*CoP_=Gm6pv&^q*NlH|RtSxICoWSgDe<^`KuNd! zvuQw+s(;opL0nOO{ue=?;2&k69VY_=gXx>tHVHq!@M?lJSHyF4IVcu^+=n{cJ8-0D z7Se_w1Wk^V^iy?KrQuU=f8s0(VXIjwl`95Q5{jRf$XHq%&{rEKKX-jvJ=psCF9Kh{ z96kW#F7K{ydqkgBdl=*$$=;m#U?J#NE|4br`Zh-=#Dah_zi@cEMn0wM=B#nI!QI_( z8I~dYgnyI;rPY!$FOK9zA(6Faindw{+Xe)}v_qy)N+ZhX zeRTOrB+pGuC3EG};SuMkK zWzLh#0*Jug$&-QZ9&>fK-f%z4;|oOWsL&VuEVd+huo{LfrE&6d2cA@7LOayAtDkIj z{$MyGarg%xkmZOyY}`70Fcqc|c7@=7mtu?|Fxy2^yIT{?v+(;#(mJt(Z zsxr~+wQUBu^bkTHW>MQ-x3wQ+LF6`hHm9+()%?84>lBH4%&UUPB! zG{E@zN(0ZIPsv$X>+*6TK~q+VDMO-VY4H=J3jTp&|9D6tGL~Z+LrxozUpZZe-*1h5w~&=>B34P@+%r10!Sqm`>28U%D~@ehV?aP!|<=@mcP z7~2ipVXbo9xy{65E$cZT{7tnmsa@(^xsT1BQHnOdlf-`xm0x}E0C z2w~VjJ*5Geb5%gtmM}0vFG-&=_~;=#I)i9D2js#QH}u_*IB4QVWi|N2Qcq!;exv+W-iM739)|8>Qqs;F`t>dDwIChnL~vgi2%`DtKlHIg*a{mnYpNbaD%v2CH^;v>R^rTa zUwAbfPBFdYp_pB7zmGcwL!;(ZsrYAt=1snkrB7C|aIZ+nQ}oi7k+bC-<5~o-!oQ>+ zZM2K1GF{NR5I=CrUVl17XVBp8^7nkk0RQgh?!s?eGcU)p-lYF@V6ez`qz21TXqJhZ za?YBr*3|d?`>Uf!)#N$scE&TD3|KtY{|VH z8s|#ZGTZ!YuJ0&T@XdR7%>vKy7A!0bYF*&Y1XnEL{` zIDD{mlD}_)|ERG#SY^7-QHg+-7LnB-5X~xV*1M9(&aaL7%mWJ`&n}L*=5;)E82L0n z$o^h`^dkH+KNw91H_d&@ji8~r@$Fc#99#~DxBZzC?U!Tq; z?xann19uJ%-tbvpWQ$)n+(E!CTN?mD5Xhrp1Rz;Xb5J0~-sC6S{*2@I9h$|aaVE+N zV93Pv1qt|1(rlwdC+)8sk2k`?FNdXO%rRbGT7Z_ZP+0Mom&HZ6<4txhVX#ixK~-`= zKbosqJ10?Fdip2tv)MGl#t?+rxvrB%_0K=T1>#R%$%Lnk?_}+WoFLO{OS=hGGm8tP zH#?va+!W`XZx+!Tix@aIJIFlDTdz<>$k5&`f)9W6`uF4Emi#NH7Z45>DxE1lH9^Bc$!4Lhrv%nJ*PR95BaV^@fS#T#*$`R zyZsevKcvlpdEEA%B?ckVg^AFoys6n!bVwMV(~J1Rd!<4Fvt8lg>SjI~;qD|12Bctcc%iS z4KHP(?!lyzv8gWaxQX7CdN#DCRpI|XRb57rQl?(egJfetG*Hu(i?bIVpPUG)khrMt zG`ZA2tsN(T8U(GDmPFrCH@qOt8tqTl$?>qLQuXp|tgE3c&`mDT<39RPjKV$ei4L0G zONdOrA1+roL8>-0(RxSl;(`8bljq&@XOnF7@baJUc#$DOV~|mR#;v>cVdmI| zfKtHY>!bgL4{Jrh7BCp_B^LP~2pel_5O}T0w%p2v3tfCQq|ngS)iwKa^1`|6yP{a| z-R4wxw*TEbB@B!sb70;#EhHt`E6ng_;h_zMjFIq-kzSdBNRm0utZI%!mec3hkpG9e zw~UJ83HnBf0129)!67&V2yVdx1PJa9!DU(8H9>+0cXuZ^iv@Rghv06Dvv4Q&f1Z0k z-*evc&KHKAnU?D6>gwvM`jIoXd?yck)qCoWh@!W&zX1p?CG6dsyY>MF=Z#|06!6OWcyCOoD>F&T|WE5N=`swOS0FFzbzJ}I8Ztti-91MjK`kHJ!^CRWgOJ~FNkb{ zYJg#hWC2M1b20(O%PNq1?lNRgqQ=!WTzlUf*>3ZEUh&kNSYeyf)mSOMVW}Zqh)s}+y6nQdj*cTxf8d^@`;@lvs1Orxz zI_h;`V|E5O_%8S%BEs8>&-L~9n@E3H#J4jR=%C}a&%TKvn79R5v4j?e4aoYYs zUOy8!qqxBRdfyWC_|O7l$%(Y&W2N91JRP%q`66E{PsrZd4Ao3Bp;HPrUk z5>$^8200!Uc;1OR)TE=L(uLogelZFa@Uh_%kLX7@58mBTe{;@wGf zK{zNLTQ6NXy?VMWFKzZ6A6N9ei{Q#TqtgLxdJ9l4A$lRZGNu@LfB&J8O zPiFSMs!;^&+awULV-6U1}_eGcC~wsD?;YSV8qL0Q?xu}XY8WlPfbN#rhZL|@yKdjUa@vLH{x^0@m= z4iT^j;8TG9Wys{y*w#Ga5$g5bErwBTN^FS;mxj7K0M1ec`G9^3&Ftm`!zjfC!u3$A z#8DjTn=GtfT#jDc*Lw}Rfz>rMFhnq-9;#m;fk2?`0=*~XWj1S#O}F=b233`nwUx+F zk`4{yY)=az{5P(34c}vdppV2VR{peyslBoR&vj-M0HI#T<<2@p&MWqnYg8(Ror%`9 zQTNdToa!E=_$?<>xJvpLwSmEyIzR*Aq*!M(?gqKqdNR-_#bhoKc!tj`rP`;3@Dr&XNE_rYvr1bl{XD;EBy$?*bg)RQhyoH{@!r0x=Hp zejA+q5@*`IZ{H#Zedl&?JX~sLP_jrIP0wmv6f3Qp#WVO+wM?zKb$Uj$EwEj)?#0A{ z44_T}XPN>HR}uymuyQkcr&0=ho=Scnx8YTkrf17WtNmaGLu^T56~^ZPR98=sA-8R4 zkjcj0rrx5Q7&#K5jV|=-tz>YiM6l^tHrYGk&<~Bt?-7#Z)NdDRZ=-JUow+{o>1}9& zOW53}K~Mv8X}hCBBi@}}RVwx>Z<^!7o&)kW)(RsQqw@yRcR-?LL{OZvO-E(m%WTSG zAi3GKyW!6H#OI2+zVCJymE$$yYqz3r%&+tZDWht<##V}`+`kVWJH?{t_BtckWyExR zUo9~{j?kzTc&1Gy%Fj=m$wk@nUI&KS&x*^9$g`udbh7xEs`z<)dFJ0>Kj`h_v(CtG z8?pg!RI&05<9dqRK$^d!{=}9<43g#-KokD#SzMi=s?LM-c#U^Et^JeiW$)F`@&I?0 zh?(gg#zD174nwW=Seu9iH%az^GuhM+U#;Qb3Rf%BrK~a3J^9a5>jfT!esCpXj0L^s zL*{GoxX#HLZYob^8_$;~2mGPfYCqw`E;rV@4L7T!<85~&M!K50UnzlBJ`6wEflU0Q z#vKBpqAYq>MMSorbD!-)^FZ?n$RR}Suv`Bt=gXt5RnK#c*b8GTtEFW`bXMF~eCAJg zUSM{G5^-J}0_O!R$N*TqCQKf{J=Za2a9?g(T}Zd?1~=Saw%H*J#{#a_+OB06JSCJG zp;lI<^BkZTuhP7XF9tqLJqxE_at^5eXox6dkh!rZmcspwr?jE0)R$;>Z4qI74Zb|%6coq_-EG%;}AmvTY;CCgsUq; z(c81#$$78=CtbGN4sdW|N8;0L94gknh(zUqV*j|Ozn{Ehs=MO{xrvce+l#H=3i@_L z#k%N)I1Jb|m<+|s=yEu@K+ubq>w15zy>X9L9;-w*f#YbF+g~ShH1EQnawPU~%${Eh zZlrk4G}bjxLiD(V=!2X2a-QkUJ!#c>6x)RV#N1F$2F$ngaGX6c{gl9k9H9WKv*LAV zebyIGltRWgdY)?kv_b&0}0pHuEKi)+ZnrOwik37nyLWO`wX{wrScms)U%2M1K=yAO)Pci)RDP~Oqy)|~!w(6Zq35K;c!kq2 z29d()(xAz#dhMo%A%cZX&t>;p7~mbRvI_V$sjGkI?Hp0P-#_cGOF88L!$jjS6xgI< zjCF{xd9+4W-b{|?e#8lcx_-XHJHtK8ZAw|}c&?w_&R4VY(uyp!bd#?+p4WWo+P9`lk=qKH6;+b1H7tYstlr?+7-=-`DecZ^04oqIsb<#K3=b|wa$gPo zHl0c3ad%!3RqqO$maKFnG8phP*dyMZ5JC8i=&jNgj-cIB=q5%|AL;Tv{hN1l!$Pvz z#y8?ZAdcmzx!QIYIy-#Z^UZ^aKCt8HvfZe`C+n;x||qAD(K zjATq)nrvk}xp+AE!2y?Etg5OCh^!(WbLZ!W_3G}o0ZYeG3XjLq!fa_NV;o~180I1_ zxl4JV4bA)f>+l09R{*wO1h{@hpOdD-P(qB|$Y`(c@$8I;L#lQxh@1}Z;7Gs+1Ax0n z3xKbr#awi}v!w%ZW@e|jDn%)P3V%B7wpcPcUk0Q9R$jKta7ju$w?_Sf)i!o^@GfOS zlV5Ojd5s(p6I($qL!u}81JFpKu<@@9R@T^$;uG**LDNl0hJe(|EK^~JpU!t6nu%9U zyqU#$O!)hsN`P?fTv&zOFiuy9dMmj<{A=o`-uM7u#S7NdogwkuW!T{6I~OU0uHAxU z(O7}OL`nJps1~_DdI|=!*=4H7poSC$hDZHH*4JB-PbsIgF@={ZUJJ*NN)s7fE^z60 zH&nFHvW^MS=I0M@$QFP44C{QFkiyQZ~T?P5nBw&Dz*#m_Ew@qXchUcRYO zl{}J)-LlE=mR8ZpZMrIk|wssQbE0D z&qZ8&FsU}i;MKcZ7ek$>yA-Gp;ro?VYvl1s?oI_CYhq%5}N$P3=PU72J zyvOaAsuDQ~#cEMv0Z?Ic$JG3Afe{?n3p>uM5RVGB&q^vHt04MN)XFL(FlQ_h zkC7i= z&%bq0WV`2gtaFnU}f?>4L`5|>#9ECs_ zLWhYNmF9Ujx@41!=^L*&8pa%0rhwh@?o=BcL6dwZzuj z8CPc$ed0CC0~|zF>hscWIBr-CFe_h)w2!0iHEZ3iw;{ytv<9%+GoC?o%_n1&pTa@# zny0_+jE=)7Q5g&Xg?e)no=k`9kzcB)qWbM^Y`Qonw>dSysmb#x9hxSxtldjmXSKAy z2$hX{49Ew|)~rF@@DX2&=`sO4sqMN{HLj!f%Vn!8t;8jZH!lch^<+i`%M8a>i5tE8 zMqXU3V*^{v7;wcFMh5jkQnRkO>`GF+F(8UzH)mA8wvfHV3B3J!*CB{W(J!h=@EIFV z=1xnJrf!`t!F{J}Lppa823|+76kc|5RS|>gcmcZfZ?jW>DXH z%f9vd zv8ZF_EOCxONHsf=r6R0|G|htSpnGD4yb;1p|D zZ@@~MfHCpN^1+PU!{h*!9hP} z+&nwswo#n;aHW-Pel4mbIGvVbQKaVXm}+3b(iK1lR;tI+c6(DLfcb=_%vd{aRO3w*PT;$D3ueTl z!|hqC&%+2_0EXaf+HrNi%a70ID^4`Wax?kEJukn6tNEpQL5w#;5GBTYHcow!C4dM9 zBR^4WY&thLx9=$_;o(l`@o35!N}KEJ^4IY2E-vs?xw+6fYg=1;M_YRb8?%!x5Zq#1 zMTK4V*Z`Cs&&X&44egUE?iLwYYgZRuolO=SHD_&pzN^AZ{FA2$R|Ia#_Prh1>)GoM zmLQPn?mF*C9x1u*P3Q&UVW41i(FrS?dKj4c&fhs<2Rl*FX9Lg}Fx*q*Q|D#fW=0IXMP| zY4mG?PNG_l$E^l|g>IL2B)_qnC9R_3INZa064$+Pzz)zMwwIe|ks~Uu$Ezqry(*?M zU)pThf58*e@vbIV+4&aK97Qcn0V=7hiw8n{tB6$P&!N0X)f$uio)82$Z!5{10wr@cXi#vz z3u})L&gZsLEBgmex6R=fnn)5%dSJ4J+Leq54D`n%iZs!ECb5j;6YlcNK*E3ZF*LxD zvEFYmMVlFRS@#vYt93cS7NjkH*u^nZ1XlqBs zhwt&FJ2lbHMAj>5qzK?I_;3LaGUh;K;uWvk=&uslB^&GO_a4V@Ya7*B$$;YCCMczu zs*li9O8$Z>-aZWi;3ks`y|#B36+3FMx@h)^-&q!43UHiN$7d}kbIZDBQu`w`;qrG4 z02vAFP1I+()82cm?(V*3qL;eAY_WULqG4j%VyuDAc4i+m-nNP>E7xWxBp{*_DdY15 zpIuzM1}bIPcwI5T(4Y?Ba~H{JCg_JL0aZh`Rbzy;`2;iQZ-{EKfNp*9HzkolLyoCq zy64L+Hr5+T^|`OVo`kKABcM{iEv5-)%mDtJs7!Vb6JKjUGR`+27j`O7x(-9@oHFG` zw72FT!+Dqf7#EAyl2emccQ_ZDJwU7Zl^UTa69AvowNq5Q*$_<^9XYQ{%J7*x5w!z5 zVMP)9G{3xY{DvWX^b*5;Yb(w30}oPox90UO3johFg5-{g=_T%_z?vh$%tQJFPEb`j zU(P%U^M-deDRbFI3OL*MY&-yk^RsWApOE`4x^NiY{8SA?&H?*26FnZ`^+h9r>;NvS zgM%N$U9P=`FE1ls02yefPHOgw4(yX1vWj zoA1Ws#L)RJ7e3#@8jLg>&AuSqR~oiH91@EyKYO$df8$c;b+(bm>vU%bHWBCX1?*Qt z2b!_(&ai+oO{@8RpK_~Snik|254@qJ1cpKTqnJ$rq#{VCzJJn3kX|pO&1#^jY&!t& zlHpnN&DheHg6ZH7PHzP*8yx$5fAc9O>hVc)yz|9e_5OY`a8U5h!EbIDoZeBX+rY7> zd9wR(597?quZ!2=WL-liTv=O_P8%7=s(kVYPH^4^TV2w!7=YpxMasb&?vOSV{l(4x z`(vk(iLJ~CGv^NFFEC8~_>F>)#`t>sV?-&9+C8rV*>rd2nIeg^D|V;Ay#;B60%zdn z5PRiX-oP{xZbMh^w1VeiztO$6J*{5aQjp)?NW(WB&Z<(>bR+<<=Jw5d8$m^!>(HR6 z9P6r%5=_j1pJPBOFz^VyBZtcC{zIM|3Jt7_r);jlDA2697Df|yd+5MU(QGz7-qi5< zwY&)R^#wK1Qn(S&sA5?}DkEa4R#eeM0_XZ}p(f*N#z0$_4;pW3>L8V;c!Yy~+= zlmw)gi`ScY<2EJt4cxC^B>?%zKbT}O8PCSA0!Y!nb9vc&xno9+rdlDEX(^i1*{vH% zhIWmM8GcTn1d#dKl@}qD473a?N|#(X#~-^tEI4ns%-{f6)Pn7Ni#2Ziq^inyvB{8w zHm~nvZ^nGKNrIf*&fT`d=JOW#1t)we8-RAf2_lxSpHVqq^3n}5Dl22rMk7gY2;Mfo z!yJeJnn0A7SwUB_?Jr!CI}Liene|TFFP^?S6iDj(-Sordw)P`vq=SeIW_t^xHg~K| zXjR^*4)G<*{=*1SEw{S*pX~V=+r2YS!QlWpu4o4H?60oIV911~D5sua-C@3dgY#28_lZ#F?|^94=52gktsT==8| z5(T(4I+YD2n5MyWWsBcTVq$68W2(wGCuF35J(B*xdR|_0x=oLv`RrL0^;2N1fNKPS z@Xb@GZC%YKL$zJ?U_6#SeXtVJvSU;V2J%B_%e>+HZ{K;jjtE&WAQfOrp;P9)yav4JGr zX*Fh1VfLeZ=`qUKFRGLcX`dmnD9b4kdL#3R@`)bF970E0s9nSCR-Pi#AkmRNqmeQ+ zDbpLWP5A?1_=y{#u~c2PYIqww%L0!&;S?RxpBNJRTYzLS~n z|Bl26qnL6yt^ABMAz=^FuUQJP(x7=EF+E{%9al#AP5QU6@V`Ho7)q+?5MlACxrn$- z0e`uM#uI(68HO(R@+cc z_Fs9ySU6_Go2uvbmSB^utu-q!m^>!T;y-@b%AMR9uP{%df*xe4aB)4G7yd_rWD)tm zLwjQ#BcsXk+JGX-9STdd|LOyB^JW%nNe`CKN#{7);naNnH#y6TD-^O*dgDdSqeNit_}0^^PZ6tQeWBz zlH2yRLK~7Q-RpSf^{D@TVjA;PL0c$7MNvsZM>|MPfs4x-(ohGX5j`4lDB|IT7JL--6b{f>KkVusIVd5+9!y z5_=AN%mC^JHqgw|)qHDfTcw%#`B}g#Qu%>Cpdj^6nmRoKLkxl&i3K^ol+eqsbb4K?E{Aq|F)7pyOQKqe} z3U@)YOjFHDytceq!uEMz9|{ZeL?23h<3e3D?VoooV^9!@hzO(U#Bp1D)1 z8SoqaNFt(sA5l;@Hewt~RDbCKaBx2gXJ_ZRXXfb%39*pe5-^b1#8k|EzPwX?U&gPB z@$gaNid7vAg^Q{9!R1%ZxL=ngkr3I(rt>9nx622u2m6I>siT%#v_BIWE=deeiY?NH zs-KH8dVM@(;1?eBuZtrz{Y5ARWf})bLSpP+`JgwR?;0W&Bqcd0j$<11uXKFMzf82^Y1 zB#UwzOm>vk(~RP;PuJ4=Q}+)QK>gDR3kzyEvNTxK|NlHN$9<2I?7Z=6RMPfkefpm- zyitJ-$$?dhTT^YEN&i+HYrp6}@&lfb;H_eXp)}6CjkErQjn{u0RpXcb;`MIiDnJj5 z-eg-2?#jPK4U*BkkNHiJ)U4$6WT7FdKM(YRpCQTT$dZ(4f+3~XF6$_^9Dlo^`Cjg; zMif;@lb&^y2VTE~EyCXi1fp_Z5u65FW|x-QXvxI;B>vSMoc;p!^W!@vd{>|At5p%E zZ&Cl6iz`HiA0eb~w8k7WN&a_`EP=d)_%_zoj%!-Ol0U$g-Q~1xb zA2>6}h`fMZh}RAAIy~jByVYm626cPjeb2{oHBA+T!tq_`U*a_dO75)x)H?rGpRv7I z-27gDg!skz_COLTl!2^Ep znZ~8Q2uDTB<#ryH(m8ROz@Lx(846d_rVK5`Vum+Uv9FXN45fv>g?4s!7uI%%$~nkx z4g`KyjFQ0GI45c(TQBsx$KFfb@~bNct8^sT*fEJ1AfMc-e2BAp&Wps%vCK~;fWQYN z=~Ahxp*SE%l^P1~Zt`Qopc{W_pbrLb$!q({<#8tW(iVpz)6*lfQFc=D2#{1~wO!a{ z>u8K~u>A;*{zC;gDnr8w&Jo^s9o_0XYOGyyI;jcTBLtB49EsmLIiP&JOu*LVCX<>gsQB6v;MG5)H;+JGdXe+k2| zvA3~#SQz=l|MYaf68?WZDS5Gflw+&V^GG`(L(YJgDCxg%O7u*qg&&&}(7$;@SCC&7 zfzqV8ymr1^-Z=9AQrRNrl$BZKiWx8g zVf%LsP03YFjHD0N#uNaHk+O>M-=769ZS3Uxynr>()uX=L7|=CiHM}BZ{)~%S_03#2 zrAA7*F_QkEy{qJ3Dy8!lXq$g&W0AyiBp0c7IP&=WHZcEq0gJCbOg_c|(GPp$Pu^0% zN~3~?rc9<;A0w{(t9(_Zf4ZzD?Y_kq;i93!VlJ8qf_$exx`Fqhs$!;`l98VZp_;ml zoSKeMlfj=F>;(`J0}_2sZRr^mO$UX@Q2#XuQEnoJhE2^5Tg|Ur1DoximP`C&X{ppW zJ@Y@k6|NZXAJ4)4<080!B>rDprvEll|Fc{ApFPz7?5zHeye9E`(xlr($EZdFe)oVk z8a*8#KDU1Af%ELGtqWb{>PiUyFz5E$YYlBkE|;#RN`8?mAiU@LM0T=)=+?sI&y!Zf zbksgWf7JZ+CMY)c3HLSawO^;}7hpb9;Ofxm!#&vT<>+clspzpp>p6fjYYBlf8KF zlq7M*#c{>`f}^sM#%OyM4;~AoA;UZXgW&Va(_cKiy(o$556fe*m2zO0#lE-zdWD`7 z)u6DWC(H5-&a~__s82Yhw9qu8Wb4_6F%t4ej z0S2K*pBgbhkTZ_IbSV1^oHrT;nAe57Xgy2eYh`B+9$=i?*f>R)yzo_w3qZJO;6b*h z`7BJ3HyQ&=jKxbyBs*(OlQl_Zpv5M7?;m38cX~1;8?Z6X&o~t4kDwCZFBC!M!y@)e zsVbL0HD|aOn8oCFv7H%h04`0tktTa9A!gkxNNf)Jn2tGfQ)rWO`|7 zUeI}wfW5?lK91noZ)_mS7#XWB`^0)3onJaSm1k+Y0Jhxn+R8IKy9gWoPgX+m3HQ#9 zRw_0-`OS|c5fOdD1m#RE`uh5v?jjm5YL{!fsCQ8|GI{r9Wn#j+YxWyCG9RRIwcpwZ z#w&uaI?N;Qs}H+Ls@bdSi}=;)Vk~rJFNJ)3qOeM$?d;@AlJ>aKg+>osf`3)98R zlDG!@_YI1e!uV}hHolx_+T!m9x?TA$U^wh#MXYWHW^k;8T2fTs z=f%Clxc%J1_Q3ud8yU)bQGY9U(V6J6aVD?Rjqw7UBlmS2AieQ4ZCUXh8D$9-C5nyH znUfuWuwozeR@Ny-I6_5@!YlqFH(Pd>WLHm|Vf`XA%$s2(PSyRc|9j6vNu$}|G#>>e zo8z|9+`eoxBHJ*=QwB4<*AHY+n*g; zQYMgp1mU6Noi^mTv%&r7@EOO$*PbN7X+12>_5}`ya91{8dqu7bW|Z!)?0ocbB&Vrz zy^WL&cRy=yvAjDBuXM>ov3TVttqob@QtZd5caE&R-kF3}(AHi+ET$hc!(~o?zllap> z-2{Fvps-uzO^MuDZHXHPp#W{MxVz&a6n$Eo({RLYS_|)I1`dZ5_c>%<1yw#-k`J~P zxJGXf89u2RG{}8ZMnc{U;h3l_5Pgz)vB+noCN-xuzGW3AWA#-VyIFR)H%ct}=^*XySk?-rBY&r=6#fh*PvD2oYR2S%lmo2D!%`Wu@U;B=>O75*F@8LxEly zbf4c=I6Q_M$r*|@Un{)ja&)lI)&`_O_-n6t`wjn}EbW2&jW3)ynPL>uOX`+tBzZ)x zA1=gOsxI>snv^;EE*$oIK__1Po}E32;-Yle9P`70eWMO?&c5yoZ=qY7DzLh;L$%J1 zdmKIvdxZuA1!-89tS)YqdUp%US&a8A_L z%Q(+L^!5tcb?decZ>ISYdJ|CK7FXh+gq`UUC17TIyYj^uUR?04iiyrW~a1MRm74XmcAbaFUu4+IZU}MrMBg|_@mvpuPU-YTS;d>0a&cr}Hy1Ji-af7{I19Aw#q{KE#jt6oiZy z`U5a5crE4qP>EI`276u9QG0y@l+<4Mmbxo zX8rcmR2E0abiXs{q?fnr)4VDgmTRYSRqqO{>Rs;Vw;CK*KEuJ$$7rZUmzCV;?{4L& z%#1s~giG(Xsj5BN+lh_Ww60M~%B4k9vEv&PyBp9<81}mLfNGLxx=E_ZT76w0te0== zD)2``C73x@a$?ELUJOORuQ){>o)A#16TfD=A%;Sq6HZu@3&uste`NKWYLz?Q;1r|P zPbr?*zFzLgjlYyKps{M6UsmiD$91pSayyGl207_lTZaI6)+UZBvIM#p`uX)FUi5U+kBmpNjNwbMs@Z`_S5F8gd=K`XcAw!dWg zA>yuUph!84!%Y})6h%`Yf@N_O(F;T|81RDoMUkrgx-lY=1}=StXE{mp)9he&B0@Sg zBJtVQ2YXY_czz9O?mn4wChDR**FTBW395D(ut=o&7dg8v8--U_@wkdvH8A#XRI&Ws zLx*Up?2hG%c$_&CoAs7rCkh|vlo{_c*Q4c|-RKTi=zCYo&@6ur#rIi+gcr3W6$K;- zC#7MhB*MgD71uWOKN*P#jNJS1!o(i#%;aH;0U7#Fs8UIAHdH)!?ZxbOcZdr7q}Rhi zIxpb#N?3w|qzaQ73U*X5;Jk_HKO98HYsx9*4o^fsE!2Lh5uQH6libsn&|oa0kUFU2 z-46#f6`K<0l;&L?7N4Pr5!b~Vn}Fx!0og=pGp~0u-Mm>EdwMB)26iKBB-`zs#;=wu zuw^e%Etb0?fcMiVDaGXR>d`zoSEYX={gu#ezE<_tK{$PCqby&-)3&qAU+d%K!#JXe z^d8j8c>#Bz{zO28PpQrN^mucxMW%SKBGQqq4BG zte6@vem=^<^bD?CqrgvDz&8x-0*m@79Mn3MF6XXw3^0kB2R%cY_Xk9--ced!S4(A= zzhWD{&8*3W@u9@WmmCtSD`2dCRfqd@+ylkZar|kRy<9@aJyK4<=qqi+#@CRZA3cGx zsyV)qpGLl&$cnQ;XB_C*uMO8EJ5pEIR2I2B9rM+<$ikK|#h>0x^7Z!(51CJ#%hwN$ z95)u?H}vjgTqHSO=frAoSe^mXtVoIWy@rF_2lPnbex;!JrnT_;M3_J>vpjDeCyfhy zz-hG6l*wI=(WHN|TP6UpnVUF6d^T@zXI9y_R5;4MK#hiKxxL&yYOw$HMN1u|R3^w) z)*RJfl1g{cpB1k+DV%cBUeG-kl2}hzKu@~I!+ z+>L>alh-xKMU=_hPf;xMjDHz=;h<;**>=~LLfL>9V-8iwQDToN%y$RlVeEo4LWM5_ zQ$xGxu)C^x`=xK`m|x*HWoEJ#eujJ`!QYDB-Tr`M>$4yv zh>6TB^iZt)=y;y+-N4dJkBHTFZPBjVlvWI%U#Zo=N z6|~0M!D_kh^?Do}*vlID3OVT1v7@iM42+sk_vuEoeZW)gceC*~VpSG{p-Qv|fG(l( zqjcA#U*g_#BgPyan9t<_8cu9hJ5Ei^l*O0R47`()c}a{A!I`HZPnDoir)b@Dw}xOX z+PkL0M4fz83lvIt7DSfP2y+^K185L&Dx+9gzUg2Qul;+`tWvK|v`n zv`@-nRA8%~3Rl$(*Ba{_w|~?qB7UAP+LLyKj|8u{CdS)!H{_1HTSWs{5-k@dMjggN z<_!>tooy|kvF^y@;hl$dqBKrRGj&JN7jJ;cD6Ao({Dg}UsT2t6QEBg#kIQaHlY)l`--SOQMPjF;xPI`tS*VB4f4ftw!UXmg=>Zm2n=0N_#C3Y$pfh z^;$653J0cdkc{-=rTAEw?}ULE_eMdTE~g2K+N=ESI$#k}$yL&IsO@X@VFd7tRoYui z3klr|QqlP4Kd0RhZ^cy&Bu%+576CqSK?~10y&z;%>>_FnjqA0UtoXZT5O^6BYwUx~ z9ZZfYGOIV&C-42`cNp7As3Y++CHyYqz5Sum8=D8q<$5P136!BhplRt`H!8CTizKMJ9N@vrof1qft}uxg6S?EUh< zBiS$G`0duDZy)Xj2SDcVlas@LWwvPF)x#LdC#Fo&0U6}$%`3f?#X%iAOjerECY z;hxLZdShqKomOD2cFW*&mzvf?I5Xt7Th2wieo}yT`McX?VhBV}Qul7{aFO0_H;)Ge z>GA^WVCuZgw6o9a-IY{T?qLFzi;<{A+%vGQ7gG9o87rXBG zLhkc=k7xcOkXaDjdPIMFB61r`_>gYIivCGf0g@{e5;%@e zm*e^|1pnRJLsQU0rGH4?@-2m{^9-qc9XZFfTKi>2`^-xb1iYhnCP&|2>U`4Gj?9<# zH0&&7fATn>prc@5orqiPfQ#mFo*$d`)#$gIyTWjRa;?BQhy84`VHEp=8ek+L$yv4T znYZ&BPj2ulI7sd$xr~Tkap>uJP?HT2%kxXh>%L-T^^|U-Y#%CHdUEsm$caR4T#K68 zKdYf7K5f7G_%s;&_1S5e03Wnde9comO2g}Y^u1LOnnwA-sqDjD&s1GBWcli|dzPmX zzK0Ih{gbY;I-V*g#6h@`h+X7QGf{vwzBs@`yMW6>)K8H9CDsLLxYZI~S_-`$72|F3 z0ykJ!KwXXRQZrW;a!!a^yK~<&s-BuDmtsap?&hz3y6t@>`uzM1*NWTLUTV!R`VlS+ zROhs2Da~%*L5XSLxNoYaDe+PGQ^+hbT1cH?CfM%iLdkQ9wqH^5E*NF;CTOaJl=Z&1 zY)!hG?lG{{70zcJNKk(9&Tf+hpP zy%T*w_cIvITG7`>g^%jF-FTOJSDT2bZ<54GhWDmiUB z7FZLxdtdnuQGk`UIE$zB0GNX+g+Jh59>!8}h2XhtFoY@^KRk+iU}&p5iaRyv4Wu`~ zv`}bk=4WKs$IVU8KUOndVvZ`8ty^F*+oy2I17zbb1C!4)Bsbr>;!3Z@Mv-)NTil%W z>SC}zvFIuW9>>)VHz$dk>0F5q5gx{rLVy74LoBw4JKvj+cCRoUPTn&G!$YyYFs5Bk zI^c%cRMsg3_&i~}x4mCC(;-sZuG`Zcm87MXfvomN_;^y0cMoW>^Y>IKlw@`9?@YH~ ztQy%uW2}6FbC<7nt^~uWbVXN5@cBn8cfP8Zu0jazStqjAaDJcsKr7^h~n8#c9Vc^9oV zz45&tbsVhra%0+F*o@1L$K@<=Ja4z~;0DoOn(zhBhea13ZD8G$b=sWqAKk;KG*8WM zGpT6dK)zri<8{f z%b%FwM%PeB<$o#HaEbaX?bYVN&I~Q!8&>?ms=%r{EpOw;b+Zm*nD#lWba?*ikU$D^ z?_Tbhpx*T)T7T#;?OWmfBL06 zSKMLLtZZ0%ll-wkT}6I}yY;C`6iXL zzWdiH^Iz&EoOkG09<0%Gb*>KT&x{aCMNO+(gbAGAPJK=}F#T~`GhKz<-$1KYr zRB>wSgA>(YvE$?>&}fq=Xs0Ako>w|BU(@#D1R5QjuMM1(=%KPP^VTv5C{P4^Mrx;; z(UZ%U4PLBtSN$x8b`zNY@~NM+-9>`?WDlwIAf~9CSK6Tfo}{8Gc(x|Du%58_qiDw2M;|snI`jY$iZ+voh91>q=jn9MH z*i~il1Kt;yQK}8EtEattvk(xHrhVBH2AMt?E7i&S&5mW)&1iQk zM6rpQuusCU^A~RF+|USE+-={dyCLsQPI1hed~dD%pK-+<#M$6 zv^m?BNmwFOaDlhLWN2;OvwhFtDN0UMBaoCa92^!ln`ShU-FZ+p{M-&x1-yh$v0UC} z0KMaE`2yhU8_)wP83Q|*Ae;URUU2oUSn(sc%DFv z*JeMH$WIuoQDZDowrAtj!X+0dr)PoB&|WPFylGp1+yBLZbkj7IjGD5gSl{x#v}vAA z9P6B#Vz8@S18^x02OL!%4q+1ML`yRycJ1F<9UUDXmpcwE$*(Q}6Cznre{46VNVf<% z%GdQr1r5oX94Yl`w^7=`%;L7Ad*Rpck_4CDJN2`+iRvsO?AIGTedOoL9#Nt#<$n6P z%;Ag-#+_Xr-Mgv&qNwq#g;olOfvN(tC?=$ zI4w&?X%zV4CQDpO%`^wnR~0Fl-Iua$0PXVaL+p)%qyvGv*f?Ev=w0u z6Fot^DEU~HhQ-p#wDrYFwXDEn^kd#jgF?WyNO~T9(l2I`X|;?8Vt4S9q4)gd!TnVo zdqd6n4yWz)9j&~vkD@pc)NXjN2Uw;4_yt4}G@OmpK&GG5#SmzcU^#B6Y_pq*tL#g^ zrQJNdHvA8_G{V*QS(V({^_g>Qdc)Nt`C?)!6jY zWfGh>Zz-rOdFo_|*iQZCUyHh7-Ji6YBK8`lAAUwv?R&u$k-hZ8uA2KBmk{QBhL8s-`_;C8Nux zau`hs)VZ0{2$q*x?58Z#I=>%Ft8~cBGR@?4uhYwtkTiYA=de+Tt{ZA$bFhK7l(s0f zYKTR#gCZ(^d8lTRA;cG>C?bz1qHY#3FpYz^VR1f`OQ>$YpV7PBCZOZt1j>pozjqiz=W8^V{Ww1FgjP?j<80wDPMeV( z%3bHVl%$!346heOBR|8ZbAMvu2H(wIZZ zQVITJvwaWX*ba%Vb3pvLJJx>3zULUC{^~yQ(e44Bc;OrZ#WGm>Yp68=;&(Sii<$REE;=e zulPthMBnjNl|I$#CWc;Y&y~nQZr>wd=frJxZ?PMKWz5rBl@VpHuIjm!!_C&7E5yC$n8#luRl{a6nKH?h+hrKOr&ktOx2YbONA+PmL?q;jMs~-RGs~yG$H$P}dlja9% zzvQFZE!^#k9OB2!Y*Vo+J_x9N!>8{x7xPlAI^3b&_M=DzyJg3>xjz5Ib>4b|lYD_eqA5^!s2nZX0km`0O_X49l?P?BA47g_y` z_xdj#cmIEQ`s%o-+OBK84Fm*4 zN-q)o{017uQ&{tO6Pcd zv24PmFK<(JMat8TQ;m#!QD+A9fAjIto~gbetfHb3Uy_D>ATQ>5e7f44^hd<6W4({E zbkOfuD$+w`XY{sG_bR8nIMh2|NlJMwY>b;pe;^yjI49Yx$D66|>>$ap5%-oNvM7zpV6~aXulP1v3k{~D9TZ{u%ZppFW+7KoGyfSu z7E5kOT&&N4HS~BIqBkC^piLJwbGk7-vZVl+_E17RJWkrWZaTb9*D2-Vnb^%I?sz;S z^5v=Lo0o)u(qn4YeWBCBF?kKmUiHe^sJ7$A2e~YLuM{g>jy>Z(i54ikfp^}a9P>xU zgiZ@2%BDc)%b?p5mk*o52h%0MlgJaL|KR*c8O_hzUJQ}BTz_Q+47E;nf3dJ#SvF8$ zARQVM1Qefc7n!f2u}|$niX{s3#A>T}xy88Fyu?pFL)pe$NDW03- zTHBR<3<-4Wg2=YIG6O3E&x~QqvR4GKdZ7BdtI$w>YtT;1dVc4|cCIta3XVFRcB!Gf zLJfM}IF$$b2L$5SiMEat&%3=H(ykITxy~)3?BQOtk%pV$+36^Y6eYWPITp&Gy8rXe zaCHWkU?#nX6CE8xNl69!_UTLG;)b#<1|KYOmrswbg2fBF9A!a^d&I)f;=L~k{%olp zjOMq?S7K?39XFj|BWtk>{Ao~k@%51T-pRp7-d=tmy(0aRu+e5Qv>X4i!{F+JA3SIpLSVnS|=e8-vb67gD$2~j$dd|CSZDF?ZH?EGW5+6G9 z0A{`hf~`(uD^-4$j0FeNhSLU3;}I%^>+04mq~xTjaiG=kuf#e(i2O4WgPgi{ss&c3 ziSzI0+rT5~cF}c#If2NRwynod2qXO`K@_;yAkT*~POsDRjeQ_Y0@sMv~$}OZEEXm6%}*&Rnvu zcXyaX8ZD=)-upO=%Z?`(*x7X!+HvPk);k%cLF($Png@oC*OUwQ2Vt*hpU)Kiw$r^l z4^5GXFM6jh`P2~M8mOok&ORSBls<8zAxBa3LGS=`fNC$D3{RoGW{U)0uk|MeMNNYQ zZF(%@s-15c1B`ZoB4M(52b)pWXPc{k^HzY#6wLta%Y5vFSqjwPX&5biTLX}9){lqV zDEX!PEM>`Xg==gsR)*rpnvnC)y{o`@u=EP19BHj1s-*SEkMFOZ>7%R2gM5>mlKDD@ zS-KoIiSivlGA)kHu2+YSx;UV2Yh-7jC8#`T;xd@&b!R`ObU(&tMJMFQ2y=mL26Y=>hu2_6htmx{krx73h1o2A z@ddHa_*~;MKo;)lW=$jZcF~`o-q|5EUv9{Mk)@8MrM01v`#t|IFjYg-1LMa7edC8D z9jTeX?z14p#6C>QA&6Jo9yvS-NWEPCzk=1xTiKP?jWZ!m&2@Z{%S`i+711~Xl`a_7N%5(8-wB>s zqM}@wAFfCo`X09km;01kqQsdT16A)5lMG7c_`lGjoXps=T3^p^nsDC$t+>gj5lh=JW!wXiW47s?9=dwv6dq@;ULAr3y>IH7d9TVv10urMZ7zzc8grtxCZFoF=BR-90D+gV^RS$yfT=TE6=c5t zvUaKxY1y9z4BKM>@T*d>^eaPMPp+!VSqS#S7(tlOz0q&`PxT8 z>c$XOC$W{v<~BI;{|fR<^WyxMh|{`wrL=z|Ea2~Se^)*u41pAA0_w9Vsa5i4Gz8uR z|H(S0e84!ImeLvYGvf|ozTP}dZ(JTWCnQ)P?}v*{xj$$7Aj_L3^TQ8tL`lgnC(IR1 z7dA?53U09&mgal5!1b1krQ?I^x1D?i4o*?vr$!~F2X|AzJtOf#LRiy1*V> zgj7cyHPp0OR=X!){Bh3iTDj##&g9xWeGP54{Q{+{)J9AIS3Q^j))Xh>QE7_ zsfkh@klLu7G#dsp@tVKI8a4e_U+)rA>+nH;`cc=PqDacC@np~EyNj(hv#DX8Z=BKJ zvcewS3(HFcXFC4a7Sz~={qB15FjlPs-LBx>eeg>_75p&xGuc3f?>s184i)J9uVTwU zTgLHe(w9-tNbHV%v(jBhz0Z};`8iud#lIsRAq`)R<8mFx9-9Tew%5!h6liHh$jz*j z1o$qz+E?A1k7~$#F0G;2JbayMo9T~DMw64y8C+O?;dZXy&S;?(SQ~7unT<*Fg67t~ z0X1EPQb{}|OHKUtzV)JcZY;M6AbM`n+$ns+=|cU40;##1x{Dg2cGJmI?EdhfivzPr zINX@5=GjpJHd#%U2MwD?YGJp}P0V_FjeYjJY31_%shO+xJfo&O{`=ckz5V|?I=Elg zwJl1(BLOjHW5orY>3S<^-RS_wU;s7lIMsendQ^ejg*SnP=?R$cqNPNu)aLAs}#>)$-?NqIWg ztYP}Cn9F}1=l+9urR!s?ovgK4>qokm>?YQv?mm=R3klU+fNua^l`%<|H{;RLK_#() zkuTEyA9MTtTZOin=VuS_335IYS6^8FQV~CkC&nVTgT8pDdktD9`I>6C$4UE8Nu?>T z$;qsK9Tqn!l*fMP3t1`MNPF5j9;C@e&zK!CYw1_=n?vTl0;g<=RcQXbWB8#t9=o;7}=Y5oqg=w5)kYd8C9Hpgx zWmrjY9o)&{yO+!0QHx4{6y(?caRCkaw%nj-U-yM7!E9}h{e!9WJfB=n>=jtJu8Sc_!x=vkHD=J=Frd9jQhetPv4TP%UM#bwZi;m+` z<_3#)Ef*4=lr~^xbae?9K%(7M)8e|V**Oe4{@R^QHHe!8H@F%GznvY~4NjjFlAEfs zFAU+@Q1U1Lxef>jcJL`$QH!hcuuFl}FO~VZUozBj=I;pI*@iN-JqcnOf0r1Oq0cr^tdQbJ z!p&r$3etN;=NlM?EWK?hdNxgKA>xuI^60&W;*w0NB*2wiE%h`zShe$LcF#1_R7|B+ zsCl&wNNt*z@0n(MpWZcyv^jytp}1%iSXo!sicjN=n%|E+-)P8qF0vneH0xK^8doI| za6uy0zAhhesvD=xw%$Bg!g~0uQBW20Pq;^%=~Bg|`LA5795z(?`h@*$h3TUyet7Z!@2fOSz%7AUln~dm;GQ)iS9w&+i4D7ton(JQQwqA zxq|#f6JO5$eWwq5yBX!$T|wwnw40ZjP8*9mdqnk%g#pd40Rd64?2TOu;0=1;&nF!y z88X*rCo$l*7n*gP;#wsaB>X%eE3F3g7~|9~mQZ?5N;jDJC_3^^VY3=k*+$Pwl+524 z%&JyEbw`B7OG0SFC~&>{IHOZL+Iq9mNiD3dtS=!K;1BLWxqq%Xuhf=EC%?N*V@z+S z(>44q`F@GARwiLgKOupNk~OY_-IyseBcy^|Q`!%b=6RRpzHr@Pk%(&u*g(_#l`yl7 z2C6=?^D6-3*5qjPsLW1K5wDc-g&|0ZuZ5OiE$SY_N;Hxqqabf_v` z&zF6NMdV`Im@8I|8m19x&cw#5JMI&Cu%+OES(Dc+=v~(<{Azu$24y;xynM!L5>oPl z25FBiU5QbM3_C8%urZF+eXT#We9Ma|jFzrVf|lMl;gPwqNw)Iw-RX`e4j&3aR9^CbWO}}|6y-E_=GNlu}RIKdgb(g z#uR%=3btG=W%oaRv2B%1eMdBqon9E6f>=Y)%5OIa5v79;zkST&-gZ1WWUYEn4CZ&p z+#enJZs(a_-r0ZCtNxy!MXH2^mYbNK_{ijoCV^tiWNUkheo{r65;h&uKSv;Sr z<&^gD)rDW>%lH6mOM^U`o(-cBk4*oXt`&^mkUv-oS{K~hw--^F`V+~IO_lF7%{P#I zW@f*+4AYQa&En5!Zql(dMYIirdUl)CVzjE9ogENi0?sGoD-UF?C&P+0);=>UzbXr&Tf~U42bn`O`eFVVWyEeWt>o?)UDwICXbNrJbZW z3mg0Sro(Jw#OYbxkY8bs!_n7i*5m=p2gAD3r#GcD0Qc3I_?=n#G;3G4km2rld4_Vs z^L=zwg^w5)s4q(E>$oapn^Tg9Qo&ExFnHLjL~q~Jo0X9I_p?r%)0vM`C?$@_EKc@z zRPz|*xhPv7DiSz8CK zf|%ccEGmlq5}1;cDu)ePyz*-Y|NQAJ9r=sPCKws?{HdvNA}#KgX>7>_`j@x9BK=l5 zL8C+$RVS0QxvwRePw3sgd-X|2VohQHUpV|M?M`m?uH z;Hyu*^M*CleAQO7qRON~GmcUBHPCP8SzwIr#s4^azRk5Lrt{vNsvGHxC1Xt!_o84C zDYT5HpyHcvc{C?eQBs_1_{L}yI>Lcs90tj?&rVkmI{nzb*FHr8ob!avBkU+YV#&o8z_={|=)V685BP=+Hk| z&Y@<1o&u$KIjH>i1Zzwbr9QX=N`W{9@2BR+j}0KP+?RS}NW2%r}SXnjGM+WRlVy`%<#m(DUuu;!fDznipH?i&{~z z-+_K2z^ycY;Ur9DPg1X^#~FkQq{@XYf8D$nN*pOe9O~$=-^&>Q^t9!_{!`X- z5fW_BpIT6bkc1~1mIS->Y7XO!<~45v7Xxln>hBGGnn-i*2nz@R`&?0lxCt^A%H#Xb zQqy2lt`R*}&$-G*8aS**(TxQ2}%(~5OUDH1zR)ppJ-jn zSq=h_iM*k)XOQ_DU4d8X@W^NjIPlBnp?s^;%GU4W){*rL{NP?Wkt zd9soc1%I;Vwzso$8m9Xb&~5hO@~>t ztp!Y#6$!jfI^}ro+KNML1_P}^|7mG21WmS_ACc6RTM3=~l4g>*{a!hu_!Z0E(>(=8 zkt4@G;?JMgtokQH4%gHwQEnoh9XmpUtsw<}m&oKxR#o)zi8qtn2wU=9!eHVdBM-rE zU&|ozeiSJqng5y?;DnC8dvwAmHyA^CyHy_N91R7EHyaMVyL1Nm zg?qNK$~a<)mOOq{mzgq6tG~Y&8V`j$9*e)Y8iB;K%4{d4ufJ=!bM2!9KQAx73!N0) zisc6~&G^Rub}h(a2EJ?CZ(8w`6qU^M#9zC`j69gcgROlC{X~Jv-m0I%`2o5T#hLrl zK%oAt>&8*_^862Y8F+T4w9OdljtD-xiabwS`t~QQNpj&o*Tgs#(0>T}9Fqzy9;ZIR zii$Uf*nF|XrGzdLEFpmpc%Gp^;DULT$>E!CxsUhHFmueRpi91glPc!*CXMxm4w_2c70_zLsPQh>5K^bPkK}E7PA!2}T<0I^ zDVk97#l9eZ?xxdJXZ+li1#(R7+ROd-LPJkv^;InmnTADUsAvesNybk@LsR_wmsYgg z<^{^$RUv0IwjKw5L@Zon$?;jm>K5wm2mi79$2noQ+%x95ra3S#*tnXiDg;{-}~K7b<)9jit^?`pf4RgdWN^r-u=Imr=i3P3xn z%&^;+QS5(Q+^|QaR(lT}4)^^Xdrg^rRF!-%D){Oc7$`P~kxD;z_>THHAQdzL4oTH7 zzw&I)V6`XrZmN_3b6u-b2c|Ct7`DCa#juJVO%D01uRdYoHwEM3KYs<@A6rCL)ei80 zd*rW{g1LkYBoDjJcC%@ZI8799M}w$2nB+drwd9aZ)tn%gm#uM!Bz)I*vVHXA@*@&r z7cPW8UB~rWO2Cghqx{GzkT8SN**d#YpdqX!7_d3-9n8m>LxPw2R)o2!4Yu2@o7LmYe-90=z@ih1=5|%cJ2oH38 z8DA8bk(OpJe>B5q2nR_WuCBzF86z!nof5S|!)}rizI_I zf#`EWMa3-Yyn5qozU8u3sBaS2t#~H$;)NK=WLnBW;c`_Mng9qJXcQWYitSDU37-@d z_UGz15V_UNsSY(YH4Jf4*C3=nSwHzZ<6sg#o>xeX^F5>8GZ|;o4SYzUlW1mf@s?1_ zQ6OP7$@}v&L{W$ldx9bH%)U=ZWz}@BPnM1$;P4yeAcur^_c$F-9XI_nuyOp4E}#LO zcFM5>c&U5>ff1Jl1^K-i-^e)SxD?qdhyMJ@%KG`|&(G*E>eOI@x=NSVTqk6u+^=3b z^Drfw$>Kx*xZ$`00efZ6zhKIzpn~Bm1P5Q9UtGXAgwG?p*r|6WC&8|fk#B|YXXEEk zh^@UnhK-$BYI%8y38TCGJvuy^QS`xGRtZ-r@M_Uq&T&j%-EmCY=e81HxZfR$yNNQV z1GDajr`xo9$sDnAg6>MY3LpLrp;&FwE8wTRd-u-WgJg%X)l!}Y=Tgw_Ozl}X$62or z=0;j-YAOu2wY0^F_Fd)S7kj)K<&nEK;Nol?V3S)^RPrslpoo|APzzeT(>nLzpm6iD zkZqj&XnJn0eAaLGVySC&Ro7T*WcnIJp=tf&HOR%jhL_vs*z8l0*crtBA6Uy(94X`x zzs!}ofu@~OXr-*il|K#3ilfVU9+Zj7R3fv?F+$2xdM~VBvsih>eEe)j1(XW zmI2(FX)jt+ZwyN?qH*RKnfvH{L94af)SIL>FRgesQB~mY&H`NN$hel4`8C4lpx1jg zv!a5iN)N%&*wu05MR!Q{hTz`j_V(pu2Ms2aO!jDWYjxKA+VqL(_VzNB0dzlQ2_OlY@9N70b?37EINRjtB`t$0B*8r?# zr#0MGLR(bi^gJ?Aw^&PvFiTb{FYIn|SW~1n5D;+Mzh1sxDZW*52&B`g(dC!^{a2;( znC@<|RK0iacID9nV-xa&Mtk06jbvdQa`O8y=a%%g5#m$K9XritVri_1u4rmva-@`_*K z@(LP1FYTx47mVr7vrtauU$}VLIn51SP37abq9Gn{@9^82osU3ZEr0O81dKNpIA~Bu zm!|7flu7o^cK6Jn2Q9=2nRcK@$i6wwp+BYM`3@dv2rVzR=zcO$X6}HmBV_)kcXYQL zvAB5NkKpFX#+zkSAmwlqGW&}y2bvd3CSKvWH=Ynv@e4gP3YKR+8WPTBFV+(8*4trE zr#E)iEYf`U#3?rx<&Q?X87qvjdq94$xgrk72#Kv6nv`q8@Z#wyoLc zK{DGGe;bKW8P!=CIfCM%zV?WAE4=@J{#X?8X=xybK~>9L?jEaU5ZBcIC?T7`PCBpW zi0$Mmr3l_`tqKWi?>9y9TeEzzS5o$-fq)_{ReMRk{4#XEsNB(CWqP>leq2Ewdq1Fa z>%KuN5%T7ZUX{u8Zcs#=e4Rq+`m8A}J*|{zo7yU|+G8VupwCa90`C8vYSp=4AjT)? zJ6S#USYy~Y)}YXn@{ERgLg=x`BiX5upnSoThJr7t;KGlF%&)ynegqU?Mh7_leK*C-O?{V6?n_I4-Od|4=HoSzm%vkY zBX5KwRe<^B=66VngCw_Y#qlmq$c^Yb%LI}?K02y<5g%O}ibk!5ma6l*q1H5+U#F^n z&tC8mEo?y?aX}Af(ThF@b1zi@ePfmR5YOY`K- z?x!8;Y&nQufoR=dg5!GDrN2(DsUiOemz?fy>bTHj)ZzdO`qj~4_bU_U43obY8x<*W zb6#h%B*Oc~*a681jr>RH`-8b4M_BE+N4{v+KD;0gu&6jb2i&RC-651P~p!Jtcy4fQ0Nhxo0e z*?o~p36SRP&7)cU&P9T(2k#28U!BBB<2np*hz%jcUeYF0u=6@EYP~~nnZ{C*G_KiG5iUWlV)Yxk|`Ku({{`@*vb1vI% zM}46-A~L?T^=efrWVPZt3=>q}JrJ2uO0>WZ2F_CvPL#uUzO-){&-o~t7-EC#T z)vCR)MS}lX;3Vt^Up>f>hWdfRuhDN8~*_2_Rcb9 z-F_9f>1J~=HH%zg<6zxAC=waCod{A_#u5&H#_Vh~JnkyytPEPaeVVaew5m*4f%m4p zCEsZwER<-Zr|-z*+r;~nc6TdsdkaMVyKQF=Ya({erb)Z@4c4`7Z=asckv@n5&;fuz zl|YUfz0EeFu4=S`-1jR7W`-0Lz@(t!ehc^{xcSI0c+Q%|-f}1Znhff4X2<0F{fTd! ze{$nV!30Cr^wf^Cy)1{XML=2bdb(8tzQ7*RaS>3IZKk z!X3v|Uh46rLjukZ*OM1NQ}VUeD^L91z;wT}3s>W0^%H%OQ{@hpv(1P8)`{;iAW1ElmB_r;a2i zD{HG@c#WOORu+e&jtVUql-n=U0%QFf8f`}idGL}^FNB(2Q`ubw`lD(YS|6*^CT+K+ zl2kTFYtAeEI@~?}ldnrE#W{gex? zmvYhgc~b+Sg^=csprTBN*P?pLU1RSIOdjjDDFL?UmRi2>Brs#<*d{(P$a8S{NBA== zW|b>F*(Fl#sjGjuW*o(n$+&CH4_{d&>+ok*V1*Ns`Wx+s0^E9FI)j_b(E^E%Q1b4W zo%Nn`W$$|dp-sCa3o(xU?QLnd-6Ar^dAX$?k98$Ph#Y>_MoX~1@pUvqy;xd~ev)TJ znvO`aviV45rPGh?vAW~MI0-~5D|*^GIJ}P7NN$C>$rUB^nfN#U}i|FF3@Q@JjagcegU!jZR4R6&asmO6$&OP zLh4^&CJVLnRy%-<;VD^PVb9R%0vUTnQPFdFo=zI$3*M(s7n-ifaK4-ZCRZJSshP%e zoa+WC+$&wE?WGg!N5r=3+SwxV02b_p7*_`VfFH>9RTmQ&Mhd1iC&QxuYEY96^b+6z|zQa}7>pyBh z;A$eM36XAM0PFMBR3)B;`TpGLXBv?^rua#o{;uBhT>y(!pu2^hJ`G&TH`IuN zmyw)T0JZ_+h_N+})|kQUVAB3E6StM%#8s~FEN_32Z+Z$H5r0(bj$graz2Vn*2i*S zr@IXH_vxS$6<4Yj73neiF*fs_m95pUE8UNI0H0YBX;IYE{lVmGKu&bJ1#C?YpXZDM z$_lkIU1~IO%@?;qLkDF|f4mIvZyan2nJDTNg7VMd_avg9w6)>`b%W``lg$93CAu!}npc_#{Z`@BwfVi{X+OoESFJ%SS8;L8gjWY62e&VT`$e+Xw1 zy17j^TF~1zspDxeyNe3={Eg&T=HLKAO7Inp{3#;;h`xlw=J^A5$Ps)I+%oa_6+b_} zsCxu%`z;*|yh{r;QtkZg-hv~BX_&@r;Fo(Vlkq+t!D~B+MSrD=cY6E00cPq3S99oO z3;&RIN!&L|p?l@YsR8R&Y8z<0Sj*7}PEk-6ZGQv$U6&o~kI@>ARn6 z-k3glOuX2So= zlexLp&bA@R3OpZS%5Vgzx51T4Vc$*E4Y@N->*FyTK`W|q^I4#)in*KJ6dE*0%$C%u zn$oG3@bZhg!z$u>(XUObFRr2nqJWH7BA*Fh9VfaYW;&_GfpW!OC3>moj*%?n6qROD z{p$wPg1jN>f(Q~vzfPNC7`Dl&pm6!M|7xXF;L9&Q$JMBy?ULNM2g2E;NlF9po3>r6 zh6noD+OD9NRR&$|N>-|UOnk~qNrtPXXX=&HU&*-yEfWEwoEodIWN_|;Kf%tJ{OC&B z@!+@JXc*0@ZlY6^_kccRa5eAYw7Xu?^WCHA$^m900>%H=84L7G`#<;5ocd`XIXl@b znjH`u=j3Vq`Q6uNM$fO7B1&}il1#ERI(&(`)XnP_M3RKl3Zz6t=xHr7o%!bc($K$k_Ns@7-^- zT>GdUqNE|pZ(uuVU7obuJ8B=yzgT-PV5g;EXGZ@ob+539wn!@O$K-;TWnpI3P|`%R zkmT51{K*nJ)j-(dU0t11N~Xzyyo!NlE5zeOrB#Zssx#S{}`0K zWMve;;`M-j_qil=-yJN9FfnNZJ4u)zs%7cj?9q7F5{CKVJA5YierjvFMZ*+>7~7!nR=! zg(8sAL=NyQE|C_%UAB35c_k~Z#{SzPHg-n=%GrAOZ)#fVnN#|=6x*7mBgJEEdSwr8 z`mqlSP_gd2z7|Whk|070owg&47IjC1y51I}P9CcuB65qdoagLkYMBc(;R`UZ z4r!kc9y}3t^fyBLfD}abH*Ges9Ga%vVXdJ=+%o_A6Cx0OW@|h$W%aiQC7X6u`>zGu z=|d(YAnqj*;GxUk07Ao!{d%v~Arr^|GgZ+FMm*%=lD%NF7Ace^N6%Eft^02FTb4U1 zVTWwL)nlMy`%^_>Z=eHP z0+i9DW67rpj@=4rre`L9wdpKwGAEG_MvI_S8$+p+cZ<=ZLAx*eh8ybmSEL+f9;_(u zfFE`C?YJL@zp^+Yitd3b3sw-X;SbX@<_`3Pi6zSES;y=(nrc zXUhZ_?=P99l`$FZ+$vP5!jG--f2xKFT(*q63suCbLuHMdJcV`zh^kHa)=tB@x+5C` zBRN^{b$s7r6*1tv&zP{-@m%Hg4M@=m%_~3Xe zVdWn-tILZUY(Y~>1C)buUj21D>F&Mi76bqaw6rQI1QZJii5HrQZe@@sqK?3`WkQ}1*gPMaV!B_Eq?PC zHO9}=+$M~Dm6STVN+;7Qy*iIU{VD?)!sD`T(Qz001yVUspu*v0zs?LzKr9B<2E+zo z76<))Wg=RL2D$IGZxguJ-mYj7t!lC=wK$@$mljp5qtAuTFvUlXmzS6R{{G#c;or1h-{lvNUg;9|#bCN7(4Ye=o>Q2wNKv)1xBCmft`Np@xT-BakGF z*$5aVE4Yuk*q$QSRqrG{Bt+wVzb|Tcaw*j7T?H*=;vNYxTgel*@*z;3FKf5WKT&e+nayBKx`W-GIogTs;{Pv2yw zygLhW8X6NPH%$5Q&>@lj)(K8{YgciPiLoN0-1Yr)A#r&_4u-j&S(3#K?qd+jKxMEf z;#%SFvF4e^i?>@Qv=gU~LfOy^mpOH!${nW+*K@DZ#dMY)u+s|rt2y_wOlxa2I`=)V z-NXH_)L-qz)3K+io8zwfPOZwV7Y4~l$Bxt?>HoR~ZcSqeVOj^##>R$U8q4a6PN{b4 z-#T3=@AJL~Lk2rLq}bZ8XdXNhktu6>bj^_t=q&nS(5{`FLZ%1bZIu(wJVZhb$`ju& z1=FNiZb;fK62BZBF87j<0Ane+@L?V=vthg&W+d&4_gj$;6VG`hRQ-VC-vtW`!x*;u zi&^F1nC{*&w1QO=khK0yH7=w89@G9lebn}4M>N*>y*zHhm2sPGu;uVJRCo~#;$(03 zJ$gL+b!(J6eN5VnG~Ve(Lt$5TamMp(@?-1XMAP~!tJlkLZky(Fqx9zwp3MRIXS*cH zf5g+T69!`k$-8LNzV-r2!6Hv2nTrid9W>Ds)_{d(zi}LOv>)R%nMM}6eyeA9Z;u9l zkZDlLKh83|rEF%ql>YeVNX39uI=}QHB1!%kw6e9N_`;z-!}&hF+x%%u-({@VOI(UP zzl_5qtVLs7D_Qcnx~h_MxqqBZ!% z+yRW8;bw8O{t0413eaLGWS0<^2S{0|pzF^u*Y5 zC#E*k-CU)kf&M*8Ec93D;lIocA*UF!kXHGe(mY`HY?>FScf-FU5a+~i*bP( zR;?Zo`oo|}p%*SX9*w+BOQm8Y$0UO~5#HSuChG}lSnkZAu7J*t#mqNo>9}}B#Vku^ zHN%W|toHHdF7v93G+Jkmx$J(IiOzqt)?)~I{R7#9Qqa^y7&ANxs*97iYfTy_G8UPJ zaMx*$3yWXWnrBmZCS9T2d;%3+k@*D>=e0WFZ7Hz7j9*eO2z&?@%?xrK+IT zakh==HH02?qloUhjB^2|IMKNe^Dz*4Tn2{W#81XIl`Qn=$bXW3w9S}HC{}EXR%zXC zed{F&*OpfN(2w|jv~=x@f>QdM$L8*xb$myzrWs<^=g%c2C7}{0$AK;Ct}&IfNZ{+c zbN9}-!>G5cp_MI%zS`Q=>SAG3E$_J9a=vuy&M(i{`a7`GOPA{5+*&cK*b2stWt-yCq|cu#Hu00zbg@=RJvHMI55(SuD97|>G9)MNDxBAP z1aE))_8xbOBhVHj=Ql6qb*YDIjchjT6Jk95h;MV+{;m5ov(*C8oP&A|yFKu|Bnq#^ zs`FZ#z-BFf1VDK)bfPS@|4SP@2O=E8Lg=-^!J-A(JrdM8mFS4Lxlv%aN=R1ynq1;5T}S!?UOi_OHKu12KjLi^IcsaU z)lxCjeT;_1&xp4Ze@I+1@QtpD%?eb3;hrHrrT1G(Yr&p#e)Km6=O5bd zrhb02!H56oklUJCmLtEG2~3m;l9fIMIw7Azk=n<4?CjVO`Sr`;pAT zOFN?ucm^8{Rb;m$G(CA^Z@h@H6EWK=DKuo;N?c9UwHMOSZ!Alt>O;`aW#GBA*FAEs z#4KgR%Pbu=xWYPXSjj@o1Iqd_FYM=1w1Il>`x@ezVem5!K=u;D?lYaD&81=hQmvwmKK-cKYakL-N4bPyfZWA1SOcQ2F4DMYA8g+yGkQHlRPO=zsn74Ws6 z@38KRdaA}m#X8dWh>6P8&AE5W+fG4J^US%sw&mj18dj8pISB6;V3^gatsRZN)$eWy zWSR#BqRT9d3M)taXK|rtsfJRJjebJ`1FzeG6fMv9?jwqK7*-Ag6?Jhbi1P_O-U5*M zYX@Ez#hl6Sj8Y|(S@lh`Wtxi=(M{#KIr#!RQYKhh^}oiO`F^Af4_DbpnrOC9AEG4` zQS`9sKG~4s;mMdBAe&YWyZJlMo}%u@+$T@TD3}H}JwS$8$^!`g6f1L&&|3*;a8|Wy zJ5!!rx^-d6*e`c_{8+~eWM>=S{~HTrUggzeoBFy@(a~C&nsN@;bC%k}B=m30OaWA` z#^=|m11|He?Rhkd<+Er><9Eg0&2H=7&;*eQTKhP>Z<)T&L|)@Lbe@%7VzJui{mYL5 zz%Vs!D|{V)e7%z7qS)+X-m+w`)7{RJo2ZgT_+6@S>ZX_6Y;={)HLg_Q0setT58bO@ zTEgWSgX+s3tq9;)=D35#Q)0B`ElDSTg+sO(K#5abZLqt#ZV(CU(E8*}&_F~^M38c| zUq9ZLo_h`o^9X<3x$2h)2A_NAFD_aUE_-aAD!dXcFrPJlLPbYX7vb1iT1e8=HrW}> zdkn3q6Hk*mb3QjI!SF-vngmXQf0^QmeufCdo9GLTf3`Z3NjwU~^%a36fm?z1R6=K_ zHz_*a4;fL{+?CEf)Et0m?zNU_%NW`j`3UDf@|-JEqSZ)V(B~ankT2Ir>roY8U=V!} zQ>&d{ngf(|BAsqk`2cH(^g6`zXmjN z`MH$Y_kUc#DP6V1yPg4CEoYyj$<9@G*Cw9X$yBOqbsVp(5CPu1b_j5Tc2@Z+HT^^` zg!JJe1)`!q^Q8SwDws{q*GxX-+yniVLC_bQ~t95R2g(d{7XH1d= ziqrW@cBbTsh zY+Q$ri=k_|#q3Jk7+k;^jY3Zsz+!GfET+ED%=z_d)SBvt4fl>7EgT#lI4*Uwuxn_E zaO(b`N^j_XuonOI%`k3teLXVnTWYGE$a=L3$6T!+JRrmQkG`uZgbhXJI0n-@xf%L4R9C|)VfZYrtqZB-N-362*U*;N(K3S1&}nfr3pO%LgD=GS zt0glag!4Jc^v=+p7;#0`xTb|HC&RnbHoSA6$0!o?5dvxW;}Y&{*8R5{_ADoH*S!(c zAX|!Z@jd+YM$Lzh(HIHoz>C8!QbSD^3CU;Xci&-d_;s6ApSkcBiHE1ug~tY zD(n@V4tq<_OKo_7j9DQS6&1RL>JrRNpg|guhh>mv0y2|3wu@ZtBu=TzHOpYOA7aaw znO6FF^t;+|h3o_)W^AROL^#VBAB_E5%|5V%XNX@V#l#rUC;K|)j7#+f6>phOw*ARP zp}?p8h(#~bGA#9e6u0vz;?qU;{>omNI6u|N`wdeS#oZvKQq2F(9(zKsymEHz<|m5i-9mf|Bn+>1CWv9`?Z z_;PtmEGk&M_-gP$<-D_^qKbYaVi+d2`X+r3lh^pw|N6E0n5-(9mKQ#s*p$o?4*-P&&{!+4o=8d-$or&%`du!5PqyNS={3^ z+0E6G&y+iB5bUK$->!a>%XdFhtGMn%C=9-sbH7z7X<NWmwXPi;WcC`d|UCzIyMe*jPZ3%Dgm9%z>z$eH&JupD*mfOuCNU`vkbfblqDK zo0(ckV3gZ**k*2M&UhU_Ow_PG@oOUAv5sxE8oRC?vf*}~&zxDsFt9{>T^aEz>QsfN zkhxL^KDzm#E?v_G9AwPl_rf0S0keO;dE6$Qip9($;~q1+Mq~RD7-U3|*w%-DoU^2B zo=@cQL7ZAxMU>RjAr{ZVX$z}9a>F=N(zMD-CCqI>Vj`06(55S}`977NXD3ko{RoSKjfXqh zTtYu+0>B6edZ!R>V;v#|9;opb`CI2x&R-8c6v6PB|p9dE^dDy>Bb3`mwWPDd{%t5HyC+i1Mrqn5W~P*q_onYYx60 zF5sw^%_8xf^V3#c({ZBc|Fw{5l?o7VxLGCfkN?0!jzuYDqBGOGhL?}u23D^KP8 z`f}SsUZe+AraLelHoNJpwltht=4H0zC&7H70hb6ER{_PSV9P*>AAkx)r-t|cWA3k` zs`}pWQ4|Xl5JXC(M5IgU?ohgs7U^z~R+JE=OS-!|Hn0VxyStkWY}j<4$w&SE&NyS7 z|Lz_4&Y@#4*4k^$IoGUrzVmsXXSBvMdKI;BbZ6`5*74(QJieXx-Snqpwf1IlNj)7M zU}2e0A_ef}PdT}E?*ITx0N~69)5Qal3|b+iKSS!_Ne@DJ9iV5YX=%GOF=lVb$4poe z_9LZ1laB$CjQJFMzS<95LAjM@{AP%yiIZfRf}rFfeUACl5aL^^mQYRF?Lrk(|Cs0pH2-ELY?c_0S8*w_>T=je<^j=wuTzl0$uJ8%@`{lW% zsbPP&@Fp?VYBl`*)ja3a%IeKUKWe39_Mj>>?f)gD%pCI5k+ftl?#{(H)d;E2%I}}X z*!o-9-5H!OIh?%KkFL>?7z9U`t=5*e5F;w-4>T+`RrJqms`ACf*TK2dK7<%D@8UH& zpX9m*kRi{xj`N`W`^x!a3+pg*{-GK?o%hhjHI~C~&kfeD@QneS+&|yU(C{RQREAR9 z=6WHk?iB`FdC3<5*c62i>xWe0JbPwyAX7f?0vnxZ@x!>>n+1;Q^{u6k{L_82Zh%5y zIUJjczT(mz-ps?$=f%w+^#O(OjKrLu(a{ z;b&@l(Lsvlmh;#UUbm_SV40kDGkuhjd2D33$_o7;p5>DwPk9X>-&@JP>QhC4rKPBy z#N}<)x3s$YMc^0J=Wkh-V`SLbUDNtP$%Q=kX}X@`DhNi<;s5&JJ%~vq#yQ*Ki%91# zNtBbAC$x@-)Ihr~9fJ^+p$|Cv<0iA1e|R8k)jDYT*RMw;#n{v5+e%W;Q1-{MQ5BG1 zckP@O+K@PnR)}3mD{_6E3CN3-uj&2CW?OIDzjv-WhT(+q)4n$}rEQt@|AeejotRG~ zl+%>L()9IN$4(zp>d&ZmGh&uZL{{=9$Mg|B6@5p7G#ivsa?uMln21_G!ZrCq*9W6> zj@by~*C}@z!Bv}gZP%8&f=z-Q+a&<+fA^v+*1U1lF+VTj!@Gc-y>;o;5OCl359~4a zKU*znU3#T$x{?{TrVSet{l;^0`GMIJ9Gk{#grGkmCL)p+H!@0kC(A|Q8cX6OXO z1?4p&9LpNI-!%*nc>HV(4B8Hl&PaMBuI*}SyT+YoPd-fiO2yZ5*rXsYe&(^thE3I< z01TXVuMq4)4yQxvgD`;F^(fb6`&DluuILZ% z7968O9Cx(|@#T^iW(QT|XIt?3_hLrN$tB`r7F8eg{N#3C9)ggPY*9>QNY{wTCRU$U zg)I|WM@Ec)rZ}0Yv`}t$ESp)>TqLU7OM>Y5qrPqkOGB-7yw;XVzuTNhh$v(hF*kq7 zgBL;30>0TYR^|p}*O1Y}WFynpg&hl0}Tb!-Cay!^Z24TkTSddnCo-n1IN_v^Q9Xh4lT@?|JFGV6n_1C4@5Lv2<8(F@Ce>#^X^O7GM@V-Ya7b7u z;DnjzaxlwcY#u&j=g@Zt>$-BjO$P4gl=x`(UY511OWkVgOuz26S3f;|?_Qi8d_In^ zg{Z|(R)djrO-Zuyd3v%um9a|pr70RxFF<)>U5*wN_t&3S^+T~t;-nv~20!JCk~TJy zg*vrlF;FhAR*pTkx7Iz|jVEgo>ZNC7!htu!g$}?%Ol(QS-1TrFF28sR9LPpYj(If< ze*Ba%4zmV;&sfG$-oj2SN-eG;7S~rbo?{LXDSPc#C>&mpS%a$lHr+wxJ4MfjixVwK zaEbuD12|X>N}H~4J*Wv9EZbXKTd-j&PEL~@Dms{D#2M@^X4m z-@2h3u|n$A;a`%q7stzoA#p>UewH=&^|A&?um1djj?TK1Fgw@REJ4XKY-jGlz;TY~ zz6Dp+y$?H^!iRCpc)^7a&eQ-9>Z(=)jtEn*gt)w3je7?oGo)R2qfhMST&$y7*XT2u z#yq0*$G&}pHm0&)4^`E-H>{y1Mn<>+| zU(RqBgwh~YJiDb|j>X5&U`2fVLfMgMZ+PRGX6&}{<$^vqM$MI+k=U)hS!~|wf)Rl8 ze{3Pc0|NpKvg}u?^_f@VyiTrSf|EX!?iai5)(t!Kc=PlcU%1bXlX@U>JKws5-GH;R z5h{tab72k;^bou8UH0{-fnSUGkJ(7b4<5Q`TQbMDR`vgwwVm#@vdZ!x?WQ|MAyxmV z!()A9tUHnoY=_5eu^B&zJzS<0g*#g(C+nf!Jv}UIt$}rd&cx=i3Y6EehxUg|ZJph~ ze466*&vm9hc0Ry+M9zSV3KMS<`hv%^^y#`aCMdaUX3tuV?*Pg%UsV$>5D_jD@YT^b zP(sU~l=P?DuCB$LY$p1hWS3o^^K5Qybr1uU9V6i(NuBpJzY!puX`o(P0;0zD_N3-j zSfjvm7%P#f5Ek?E$D@aPEV{?bqvqXRU7|dkTwGU%gM4eth7N{FNnX|ZCuQrptjR8m ziPhiPORKAsz1n#}Ow>uUm#$k&^LH|+f09d|aBb*W^}Lz26MV4=WtT149(D}Z{j{k9 zCNl%b$rry~=*dcq!Svk$2>#p~8$qv#us2Yms`q$Lkp}kdLKxgO&pIS-*tWmNWYzB- zdmojWw$1OK*I&8m2e~F8h-UjEQl1Eor;F-gmThm@x?M|ig^l7iaBIX!dEZ;1is1G9!mMO$S=agt5urY{H}Gy?$EDho?xMLA0R5Wd>OiGA#G>9{8@ z`~J)g*YCG&UBoPPSgqF%&6zeUPL0M6b18|<<8VrC9c^RzE6=+4g|oihd_oj6=e{Ah z0an5g3p4zubNNg^2xWraken=1C3^zgUc{uZh&RI;8iVtki(V~sCH9B=LTbBgf`1In zJ4HK$wS@i+!b?X%hhM1TRJ=EVI zi^z+ajC&0~0uyi$r!%T^-Z&lXxAL=$wIi9F&ni4vIe^;I*@}k>F^0 z1U5$U6JI%Lh}dWNF9MuZjm=zDTXbgW1b5Cids$Zdu*Mc9jgz?JXr^^{t)-lp1UjriGyvOh(Qq8wf?MAO!HvNigoq^JTZe`SNmWh-iWZVF4ujZ~hn+<7Om zA$slbd6_|X&)Oc*@qso<{P#)1;JUu?u`fJ22 z+U}Dbd$9Lm_quYCmrrTU1?TNBfs2~TphI17I2&Uw5ZxUhOFq9>LwP=#GbqhLNjayv zKQ)To9yqpla&YB3Yj)tCQMoodvy~>7m<o^wk~Tezz-%F z$|DncN4?^^w8@QtQE&W*`sON#$;gGvbEwBJsaIj z7a%$&+M#hqcARaNT4#PB^i4VwKEpDI*6Yl3CUsqC#5Rwu+2W8Ok^8)%F-e^T51az1#C5eSJK0R{7j1bfe)7$U zakBdY*os^xAc#H77Z0Mn6tlEgOvjI9h=;=7zO@N$JtiRWvdeXmF~-KbDEeY)Z&y-X zyt~yVFZ8{c-B$VWF8*uQoHZ=wp5ozY2BChc^5*d*SBs;WH-O1_?4~wL)dgc0pPkFB z2Mv?2%Cu>M+F4OJMY}66|NbNaZjeN`!ch%X-BNuc=`82YT0i^onk0#nlko2STDotJ zb)IVuXQ$v)(YnfFw$whv#rh}ZZ4Uxy`tX>25S;F0>x9R!u(`RvcwxN*B18cwlJ|02 zA+b+*{En5C6+n!bfzAKws^R!I;_@uri{JA%ru)4e9i1PmTV;T)pnHDOZXIVoYkzYF z{C+5CXbguLWNMt(0j1;par^*xIHg3~|6Aau6oA8+&MyRFGP8}Jg~VJOG#`-n33!#H z>&ILlje*83QbLxyJ?w6inYWs#I9re#?Cs$_+xiZCJTd5~V*QlZm!!^C;2aE7*Yzmq zWjW&y!?F6kK9>Wl+FbtB-Ej_wgYo2>qwijqo-gM<$-3-1nw`zKBH|4$YfAf}r-&0k z5;%J21e_}a?ztVu@~lLb7d%Y776-ol*w_(`Q>VRZgpB|;rXjbMVQ*cx2%4SUI%eRT z`yi>awcGKHj@H#A{u-0B=kEo)Hg}n@lV|LnXOjRpS`Xb?o)2z|bEZsJq>%`!w*V*P z!&$P??NScZXJW5f+H38LyiUGiR(n++m~7h~xU6(Oaq0BWr;@D!Q!Lw=om5Bhxtflv zPLf6U&Bc`5AQT5*)5KA<2w=X$5G|vnmfie^F&F&0CsCc7k#<`Z?o_wIo4xcKYh!x4 zIVZdAz2O2%~y9)>^JE0P@8#pp1M@}045#C zImNd4|Cn?vTgrTHuJbqc=Xg)M=c{(H8b+m*sAlXZAL`KoHvs>cgs|2nDVsKSN4{j` zRPS(0OLsTR>lYt7eAWYx2VqCRB_kyzC*a1;!36y^-kxo3Vq;+koZ(mp{Ox?K>jJO`Ab6?-8+ONy@;V$2Le3zEwjMfu~0U}H2-bFWhnu@xy@kX1z zcHU@Kwy`9L?q+5w-zFxyymG&~y1ILL<$d=a4_wgUX)P6icN=ipAq>2m6&7JI?B_&8 zxRVC(1NBKR0s;atF%{F`DQ&(L?}Hu4<#h%x?()*o>YdPgh*1#{@0C6;^JACHo3DSYz=Yu?*nFssb)m|5h^Nb2ua|rm+aDrkk#fF2n6P?Pn3)e zZwC?nh%rs=JiY@~plFe)Hk-tQ)vS%vhlcWnyn>Yt-%dMr9V1kezU3WwT8>h!>zLbqX686 z$nE8JmB=kf)**rSJjC$Jf>J`KruhDgteACbkg}#_gl*?c*t9>+01OL<_n}1Bg`S@} zLXV@p{Mb+x)l|5cMq!Q(+hc4c*@-^#(!{sN&I0CWw%xz<@j&37BvM{51PyD<_na=eZdF9TAm z1Z8FRa~?M>=h+#&vt>_Dvo@_{wMW1SSZF*s$5v*p8rP)gdOhH;_oceoFJ1}{k6WA^?+39FS}E0A z>$y&LIdx{7D)O&DIh)Kp^CB@Y5baVaGOh|t1XX%tK3@Un4QZf7Sy?p;R~47aL5=(9Bu%}%Dt)U|C44bX zdeYOyq{&eP`4Jr}tXT-ksIzy9OaAT=pdE7MCqT!*+yf_rtE)iR`!3)OqNFL7*VQ3I zVSI$ZH9_$fro&rGz20csoS)$`E-9`qd3w*IzERs|xt&7>(TuglHT*raKj#Ru2? zAwOyzjt3s1(B8_nyid*f09|7CaC&E&l_hElUs&#ndWMv8VTd1NgooRG$4hN}@e2-> z1(Ub1=8N=du9lZ8%MgKZo%6+8@nlgs<}&L6a($+F=2Ko!KcsvL>*L@}{F>$d;pd6` znS$xXIq1p|!2VMw=bOc;trYb%FDf|w0$G7E_v#_4Wfu@zUS0kmIn^z2U2$a)zF~#v zrLRRGq(tt4p^sFMoZfI%!&}G}nQb0Wfh+gsAJ^6E?$vpYn>!eSz^LM@os+B1+%&XK0i5E8aJOMG{GOT*DH@xg z$02MAF-e1wk!q?(wnQ0D4ZN(szL2H_i^!W? z2=k6$1_eiA7B<`XKv}Bq3IpKfpycFwP|gvd!!au z3;MmB+!QkizA4fMQX?bnSqFRlXw=r@I)ojVonf6e@|p6V zxaeAVM;jUY?rH;_DDZ@!1j61iI^;Y%6Imp;aYkN&3<-%7UjoG~7L%Kf=ziV3TYhk) z&$268jt%XOD7QM5wRIFd+|?>;E&J$ z|MIi9sCtzEjs3UG1GBWWwC2bK+$Cj8DFaPOOQHW&1!=?67lDYolaEpGuTr0#-bAX6 zpil4s)=r-HN1ouj+*Ygi^)s`t!YFPidX>})x9tZ~MWDi{K48zqMnNeP{&UHpJT9*t z6@`vbmb!qSI%8S??`68!r_pLCJ{YQV@=1;;KC-gGw`~g2#x0>@@8^hgM+wWZt6gnn z+5g=Ta|FjaQ4%U+#5j5XN;lv^YbgI-gA!$X8{hiR8UZe)qM{f?gbg5w>o?B4)flD$EA@Wyw8;LFVp~y6}`=zkBu)f1+@$i4|d+8%KReq+zM?5_}d3IJaJw5$4bNTtd z0{*Hsbv&;qn96$Oagl?IfpJTo|M%Oj%PXhxB*v<#tFvwIEaoY4KZ+OmSAJG1s$yz7 zH6@k;C#Brk1Rn;#9rmZj^!Wuirp5@=z)Ci`20krIHVT}7d3_dJUB#u@G_Ev6R+x4B zUK|4H>QtCu{9o7%r|#;Ztr}BKM1$ZZ97hSf-Gkl~8GnU%t$d?te0e zNS~eZ3kpz!?GOQ7bhKt|Ig!DQbGygKRmrc8hEiU$WWVN7Ti#uxCoSq@pLqA@5hRVw z8f7*4j8UAZXtkJ^kvJF_NxO_ijFN*qhjV%Dh|qQ1=nlW;CY?4P605#GTFoK@j6Zd< zrZ`CEW{j|msSJg^h>I8~raLDsQ34U} z@v+k};DQ>x>C(&l%N96+9DBc$Gbar&L7nSz6%4~3`=Tzp7!a+1hIV$Wf_aq6Gd)H% ze6_v1l{Oj`W!7zAp@sbn_IMTXuLpL6ZUo6mNKlcb%~UF8;51TG)}VB4C}aLAjb6zt z!pOwIXn3p029hR4ix3dg$m|*v+gw>0aUMG%HZ1DTZ%fQT zSuu`}QBhEQnsu_i-3FKp}^u2q1XfQ7wfxk1JCg{sb)(t0v zb5{c60>m8WLl^Kykw35dm8H=3Gzj2rnPneQoUxK0{P=VIvkW=4DEYh2>|TkiEW^L^ z!vt#{NqX2)xZ*+BgSeM}-ySdi{qJ0X^so0I{Ttm#|KFcb4Q@Z{Kz#in)cK<)A1${m zi0UVC+tB%2I&<;w%{s={g&cX2l7m7kc`K;5_76w}fWx48I6Gv#kVBh_+g~e6EA`Kf z&r#&m+d-5v&)k|8zNF2rNUr4l9WMm{hRftQei@I8>SKaZTch>;?-MdFsJAn{?_L_q zG-yxH0tr z>mHR3mpF(u6lJ9T^TY=cuyR8**LC1KtCNVc)gC*nzZIMY-m_`0{K$j#UemKnJEL0v zzlE|SqyFY!XYdJp4-Mw*1ereizpt`?h^k=t-xpb@rlK|uEx%=31Zm=>x3Kc&a64=) zzR28@gzIa9%%2;-@Bq8uSjHoLre`w-x@~sh2||Bwh=UtV+6UHE)zvnV9btArJN!0% z-g!wa`ZWdVCeT;s1ArwDpsl?6S6nf9e{lo(_miKb4UgXby%;YJE)o$DZ9k{-LF9gI z{`dMUpb+vSF{hv#H6MBVZzEVF2_r=-kNNui{`M!c1~4Dfmz3vQ#mk2R<=ESFh=7@*&ZTa|Mmup=R#!9AMG6^^0 zC;{HEf2I3xki?jK+eQ3+|7im8ipa&ig^&g5JSeR6J% z6_83E95*-h&4FcOPUu>n>c-Y4rOxsmHwzXsHvL$1ROD6#_kY_CBqTxWG)3-OPBlIQ zD{5Dcd?tlYi?H#)F`nxMH82k|6VcN!3`d7y{;i!8Emiy0Z}~~{K!W3;r#@URaZ67V z&pz?+@c4+Rqz>145pVx96D$P9GG9kZ;rb#1;=DjeJS^L+`w$VPDZjl{aCm4FRvWru z=xQo&68^NSVq#Whb$6{GMTh&p(hC-+azvU3RQj||Jx!OaZdHkrceJwV1M_V|o`YVo zRr)}$*k-DeDkNu{hU3o!F>nFq(|SIw)%3J9MWr-`fk-?Re5QOHVI^e^FJ|@?VK27A2YQmUDC>-utu%_vO{_Sj2qs(z$aUWzARimm9~0@hF`KWYDCx9)18zj zJcCE1%%^;jKZQO0^A&!5`|;x(+11bQl#`CwyzN<6H%mF1tB7+AYv&&e2Z{A1piIN{ zMRui2%nOJEC)`P$j3pCnMGR#BTL9%D)E9o|u62+R-rmk(923cJpy&SWXmL0zFsy}T z9hL;$_jc^iw-eO=Tm87^NQyk}HSFYF@H5hmO_|jS|GTw7G@}){*yLeW)_mC9vJU`y5Hw$ zgdOc`iLd{B0nYo!Hc|IhadEk7pa0)}rTTEsR*jox<#SQb2dN<3r~fR2LhS8zb(T;y zc`bEyn*W>W!cCi*4$L3GnONMxM32S(EWCX;dSxL<=TEGSF}@!2hXV-7T3$DH+Pf_a6VBoGlkWSi^Vo!tdcmMP*Jg>#hE; zM(hYgPN9dEavH8`U)|Sc#DB!Wx1GQNTg{k?N&#HPMEcjn#6&P@i-XaHt2!tA>Na;EIPf_zFYnk^MZ>UH8?Iq1Z<5me*WNuu3J$K^ zx!5e^0`@jLYIk?{--%;j76+p;bDx$LSg-dgga70^fIXpBtw5azoDRVS1zgkxuW0{t zD#4TT>f*<>2f{XE&pJl&C~-2n zvUMaK{p06D`PXTP6Yg_A{;$bDSJ4$0`18eoVxY#58TB;9xLU_+xpPJ3LN3WKyF!V1K=6X}ZX#ejR+H-N_wRql6`T0- zrk9>uQ%hANZ9HnLGSgK%wdj4{LXm@`lQK6?o)%G5k!;@`D-UEK_`6L=d9wo}cme7< z0HMIkxDLnF{Wvhj>saQzy}Z2$+ulq^IJ_XZk5r-QA|@8hE&yQ_$gU97P32IIzKJ6y zP4=Q4VtP5l_ckRRD4{cEBUmtvf8WHBoM^VaBf#hqEIB(f)OsKBZ0th(@;%Fod%q7n zTKtgFYdEQe`Gxr|Z@Z~$5TYx9FBh7`$gOmsoGG8QPJ*+trcZUdq!NqHIU$_)qeCQt z8d>Clu~1x9eZhO(bCIdV$mX8XrWWGu8m7@O;8N03P~RNviGXIclNP!DUX8{Jz<4Pl zPQcmKE&;WQmIDOF63SG*p=;auwlrvLsQWVlq_aRe{P)JbZ&V?Jw9%pD0`)Zq(8;G9ves9pdX}nk; zSx!6Xd}wF9)Q=$ZV?ragn|+`r8~AK}ZH0ay3SBI>HrJ=QdwhC629v`gB(UDza<<;fz{Notg#jx#&v%0kH zq08QP7nCQ2|8>&)khCFYy+qd5shP_(b7-AJg$`nQ{vn}?s?{mkCVnjDuJoz;+$Qd< z#GGal=Rz5bkw}1X`y-W~j*XD+WjETqyF*T6TGiJG#Z`i(-O+jL4gW?FiBlIg?Y6S* zlb>og#PNA_`0ESYp9Ui##mBE<3!S0PoK9B(x=x-k)ADjNvu+*RfmZ6Q?l~?WbGoO_ zC!95v%(oKh`ZZs#UT=E*7*}^V%~sRp3{yGqoVjNVbvUXykHSgPxS1$wm|SkV_=b{(rgGmjMfp7FAu-oQIKlA6xsopdQk`^Z<6(S;#}pzj z(EkGm6s^7mbdcG_(@BBhK>L-?b~Ps&qx= zUCAGwuflM_|OStG8FHT;-z|2kbBs zPR=(wi>d2bU8Zi++sZzWom?xF?D}jsYfo1O6jGXJCZa$L$XazLyw7t1HAFnZVM@TO z|E*+!Tbx-{Uo-ugC&Jt}3d6-m_;id(^KyN0JHx_OR=u&+aP~Bygm|h3cfP*n<#;nr zA|p(`neyxt*|u_)4p`)U;KeBya6B?Zw2!Sgi|U~6ws;vaMMi=|3Zzhz(; z_lQ9Z|2SwH($TvpMX~(S*t~Z(2rqK=8f-3(J_?N-crQH#KY+R$A^b3{c=x|vE-N0& z)@2+C2sn>Q^cTr7Jtgi4t-GGa?^SB{jp>~|UKk7dj(lgLXgx)t0Svb2v46m#ttR$QyMA#(Ni$p_qqoA^^WHQKLPUVht zUds}{X-8KCGw%kfx7!8A3d#V_Aq)fx(SA`Ck>A*pUD5e@zHDE-bG4H+2SomSuh#YGicbj2C{JIpQGw+$4hdLf6KJ}6l(wb?A8I#*0%J}PA8VU&8@46` zU!k?m=wie+A!g-s`k*Mb@8;Kdb7048OQa@gM>=yU-keg}O~t?4jU8&$&2YWT1bx08 ztrqIJ)b%B3T~>#c?H%Ln!G7mE*e=ob+(4SQj~C-lo(h`mEJo}3Gi;YEzrL?J@fYyc zs~XYyaOd>{!bZ6~x8Z<{VToc4t0{B#K>G<}G=T(|7`JRYl-z+o+Ur+?u1vDPte-g` zYX04Tnds>@mg=x9zkBwsRxISgZcv>JNsuQ@rIRUg;{t25cMaocR}M^jbwA{b7icX< zWn{!O_M-%KbeUwW?=twL43OpKSLQ2Kd3byblw1$RZGFmgxg5JJH&MkC_MxL8*enfpmi>0jG} zFn8w{-j3xF8rw5z(RVaz=uj#}b&hBNUG>juH+T52Mea4fbh9V)u$R#}ZW1VC)U8)n zEh>^{nYnL)DP#?P(Inz?<>$)A{!mT|--1&1#&2myC%6n5Jr>K0*~8p$R)4Y=_58~% z(|$@K5mUY3q4uNlmk(Z24F!S^J;PO$kJ}d?h#o2pzYDJRbNhldmo6Ks^~a;7>$i^6e4pQ8TgQ#0lmJmDk@cQ zvDz9tfIj+W-xz;nk|EQm<>_%$uj>Uh;XAVnU&&ca0_|TsYpPA$FR1OXFe?C^ocKT* zb6LxBbE57qLC84KmM}ZR(D-`-of3?w&rUBD-b$&QbI*wIevOr_@dn#qM!(e_qHU~C zjhs@dDo)zA>7>P&U+{!TeK3sN+EO9o*DZre!q|0W4)z=3VOGIB4tphc*8B|Bq*e-x z(DK4&>6t_37Pd(@RhUHVPS6P#EL@et)RcX#P>H`X!b3bre<}%+#~xO%pAdJH1DO~= zY%n<9-Fi+wpyZEeY!0ck|Y9IaTsWQlu9mqHu`ur|~_xwF#ai1)+vukT7Enexm7|BAj&E#aN zcZvc@2;TTX%l!J<6B72%4eMsws@YLdLlb@To;j(FPH!%xYb_NxS+0N$WG@}Uwwj4I zN%|GuUj%*FA|g2dYlOButzbvfP&Gz7GKovU%DA}J4deL~g!tG9j6H_+0Mx)AQJcGC zeGLmL>+p!J8{_h`yD$68Kf&wipaoq)*Dq?m#JO5P2a`PRTUqT|MAyB97aSr~hRaq_ z#tnL(5$m~hm@+0!o0(NeRhygqs@ZSS3j;BKY2C(XjIZ7WKJOsatp5>X+?E=W}eP%3xLf~ghbgOXz?wLKwFp-qACpg^5wzho{ z({oVI@=ymQR;1nxZ20e&<-yoQ1CJobs~n2v|SfY@v+H-_)g_@fhJWkWm<_?JipwJo3F`w{{u2t_d@&fM%|OfCOGt2)EaQy9&gfZvog83(!I+un zHtj$*b=yzq&1VrTlUaTuEk-{ii1Cp=ABUw6+&t=X`sfPT1D+H?d=Sso>133bUwPOK zL~XiLHcRhudh%Je`NlwOB~3zA>=Lbv?D|}W7~ag#OQca3%jY>}I98ZdicQNQN9rad3LTewGix+;6o(K8QUhtid7D!}YOreuB z?~#(?83o`Nu1=PnqkOKLB4X{P4Jb&Wu)127=&779*8|g#$m;kw|4loIQvVN&rUcA3 zbbD|}KX7dJHi(S+(DvYVlc$~ZOXa3~%u$RZM1Bg>49Ce4r8tXz?kqK{PQrj!MdL^x zdUXNYEN=DtK{>1CcL-mbS%g2d{AKJu@xp$sb%TYQUh)N(#yDvvH#|?0WvVwXtWsPx zvX1Ghr!}Aq5r7(-S9NT{rjU>5X?UO4)s@c>{h{Q(6{}YyA<3$tCwK0`!m_PB>2uy5 z=zg&e9@B(V7g`lRbeY3GU4J|WbJyi{IB?9S@ZqRNZdh#!*n4OVw%h}!Tu7w&{k%TX z+Jy}Z_9zqod;tpTQ|-7=J2pb^X2zWmDG43vOKZnoh9Uidw~@%{Hx0&{J*n7lozsTj zJI%BiK}rd^&nCXEVUWBk@fe#lcE933o%rEe$TM06H^D+LO`J}rq4v65uoc;wFJJ9CxnDVUxYaI`X8E+4 zBw>2#C8w04`uwQ@TT)$OV)u*PaRK}^Rt}(}VSuL}UBWefH zGAGUcb3g;8RX%ZGva|DRFZqA%MAe?SxFYSf+NJOo3#SSRNGro2k(M{_&chCQS)XO@ z=O`-GzE~m|vQ+LqpbsZvZz*hg)#`D$?A%A}dZ852Sw++!sts*?mP?F2{_+`MHsXF` z!DoE4sh<@0XNA{q*DonJ#))5C+$|_3d!Mg!onJ)!`81>}tG;NAR?-Rc(_Z0X41QvN zRh*7aY+~d)Ysfgd=T>>AGiGIO?l>4Jf~~P3H>XC)n?Z>v4eVE98Q(xP@@lpyuc|`= zjhX%|buNc!Qok0Z=%mnf^#-(nmmp`JiNfP&MfBkZ>)o5NpiO2vhnJgpBOELYe#N+_ z6^&iEQUmdgZxv?qw`jXmo`0NtsiH;)k~si84ISao8lCgMdF_JX*Aa8 zUO4#zH=hbO?o~{?*-03Ho*+%3#;i)xO6581yiPO5{G3uk$8CGnJoLNY{O#k-rj0dd z*+n3~R{xtXE#F&)wR&ZsCY~LnTg$`9LaEu%&ss%1!@!aF7>2ZW)pzg) zU&ZSYD5-WFrc-H-bq+=6_iSRu&mvn!^IQ{8V~o!`mk4YM`nI*=_4bTz5#s5QMp)8H zY7b7%@E)-AH5K<>g&5jIJ0CdP5z^m;N_Tt-iw>W=rt;~tB3QsC*5-*cmB)liYT?TP_h&~n z-CRt#0;M|*B%@4vA|_6H0FK};@`zoAB9xU_p*_^>T3#Ahyiw(h>4ZyV*zc#q8iY0MBa-hg_;>{ zuSxJ(0h+34wXWV~am>Pj}QEROnFV%DDHCk?t!Mb^K!_nHS}dJp>b_h>poBM1N{{lq|d=0%qo0t9hgK9 zhjVwEGoBWP#AE87X}^t9y*jOOUZs6-2dzRdY3BGEj_I}A?2w{hNq4;C$!t>HU_7B0 zD1mNH|1iSR!*dqvf~DrMhWm-q{#r-B{-r+;^4lNF{i?dxr{nm2zQH7dR<8_`>P@Z} zzj1L}93pkwCoT(cbN-Bc9wF+ug>Ve4Mn)&x%Ic((l%uVD=Gf{Y?=IN#UE1*+U-PDP za5vk(ynSPzDQ-9cdkZ0x0ym>7qUbv3(2 ziI0IgY_7LLnz#G<6t?40b--1A>BoE4l+(n=V~C7oqOYMZ_Cu%PbvRhdInk-I>ojbq zG&Sz_eoHAqbJ4%y0K2V66CJd*vS{nj6M71ae&gLlskOPR<*I=E%*o^A+RpIN8?N7c zAKI2u(u?0p3uz8EqQk5^G(s@wlo)Qo$F|Y}GmqCJTxOjj z{^Q~>g_9WW0QP1 zT7W{%H5M_c6EXK3+c93qbe5`ty{H$tXy??~L7DY2(S|O?Is?yLn^z$PT)2TJOFy&Ps;*|p|xJP zU&IGrc0pXeauOMP8vG(Ip1oR~m#gO-uO{}@wF&gJoHI^-j8Qed6wbxHG`HWdqdK7f z_}TteU1x+iWXeNf?Q;qjY>kg_KE5Im^riOS3n8Q@eRUf`NB5Mf_uq6w1IkU+x;I#^ zl2%F456k2FoF2QmBfJ_3N8ge=NB`n*t#@m|Du<=C!#&pv$vmd4fOGaXZEU&Hai}PzKw)G@Am60i=?4c=sT7Kd$kwII&EILIVDCC zkd#PxRPQ~0{xgrRD6yl_rqX*A&SH`?pgZ4Oja5fTH55V_MLRot<#&!jxHFyo-SN?A z9-)hv3%*qOfn+zj2U#8D?BW8QkaS`GbYhr6=XMf!Te`JYv<=-%RPYd)RnMWDLlWyk z;3jXYjq*JeXPAG@5iarT3r*40{PH}!Ki6%A+q2I&S^VM&k_{0D8!n9x!C~LwLa9zd zaDI@Yst!%mY=$%MnN|{^A19?&zhh&uoQ1gLgy=e2xoXlx8SAdl-6~Fxmm=q)TX55? zi%=Ix$k_2J)s6nm^f&iYbx{$SB=x)VgP3Q*l*HM~^Mhoiv*#h0FoDMJ_AKW=f`3kX zGaK5Uqi;x94`#$_)W7(zA&?x@LEr5}?J~@lwIUlZsHj178{U83g1~e;&bQTb?3LBN zkT@2{{qtiaq?*Sg)Y}HYI#N(7B)|PRzx4S&Y_t0zt1lrz!*+gra>1aC>rNlUm)k7L z`1YyK!EFftSdL(dLWtzD-=qC>9;8D6gyK=M>pRnqm3YL8%}{~jE7M3$9WF@zjAVIP zz{0FW<-h!LT95q`Sa8RmBMpe8ZrWz&HmY2W-TC1#BMiT+jKh2JXY_$A*u9_oBpOY( zil|8)JgVr33ZMDi-FbpRAb~`b{Ni*FPVzIAU34D!YfWbF0#vAy%5h_)L8)*)wEu}{ zNI=NZ2X$U;GJdGFZqdxC{1ZGjTR`D$-TQREQG3bV=bCYt?uFIV-hyj7rgJt(y1R z0$_esneT8MLr#T6sH9SP}5BO`{Es@SNui=9X@lzky>(dQFe?C(nV{h zLx%cdVS{(8+s4=#xM+BE^FryAff3!dT<7BQ?FU9k;LduJm4RbEqTj#uW9}=7Xx)^N zGV^iv9{{5II%G0ZB~zlqNn_-JaF?X~d_upUf1m@U4Gpgn95XaIvYWDpD{hm_BUj4QxMI z@(U)##ouW%vuI+rrJc>LeQFz9AliC}c;f%I&s1R$9;3(+HW93C9#Ak7< z+2jgZ>2ZcrlE|u!dbMoV-KbxNNv$!)Sx3$1zJg%vaw^2&)du6Yq328LTwk>*w?t~m zjQl*^Xj8=Hb8ISFH6KrU-N?kTkZMiEMn^I7w(sV2x9{p`jF*F)#?ottuW995W^wzZ zlpYQNtje>2x!VZ+mz3{~R{BT02L4uVSNX1pabhV(Hr@~49*TSv6|FLdUBGpi8)VzQ zO92uW=9Z7qjmb~+hM+*~b# zAcR)DV<_kTIW&vja5(IW%TZ~wD1-&Yhh3ue{+DT=4BX;fwnjEGsR*#Sy9s&8V4EMO5byN-p@tMdu@BpR}j+$zuU^P|G%jE>aaGO9*yx;2Wwt$ej09ULK9-lGF=bvCGT;&JA>DWRRE zW-=KhsWHg*D4u5%?C^$+UN2vIYaFISo}fypbyr1dgNE5+^0T;$8N9Ixfa53q&QN;+ zni|E`I0s(_OzbQ|EARS~n9$|z3|xF|Ls`WATEB*Xd-kW!!(k7T&6q9`Y9n)!IuP`h z*8kB}{|AbU2!V&nnM4ARpvLH1!Zg|7QCP%b_F&+R zF3}QLJd{N(7oQH){+vrNi{4<(f%}i=J|HuM=RcrDVmAsbOA$xV&(}9t9T{KjnOo_G zuu3N7uvKx3-~?Us1(OnN?WknmP0-Oc=EmjdUp)3C=ZxztW{jI>RA^bx%a3$-WoTq@ zVdk~7SPZ;&ksrQPhPnP`iBp&}{;cuUa<`)A)X{Uo;sNjJdVC@Ah5h4*g_WrBV{=yG zbR{RxZp2W}##d=IN2ZA1-1L&4s^UYCREuU=G^NHvM&@;4hH9jW9{DZWZ|@v$QHZrx6koYqn2{CTpn*l@lYjbNka@lFczI6JHHzMbS% zaF*HpNYyKS`-#IWWGWAtnLum#^_N1UnL0_1WeY!wn{Vvsg%R}_&e(*>_jW}pUf_na zq}pJdjRG5@RcMBa^KZ~NS<68cYO)`v)l7V;YTn(o^+B`gHP2XTh{Zc%P|;Su(z5$y z)@!XWK70MT3D>;I7#~PoX!@+?vZRyn#ah+~Qok@{GNXRy5!J}o*3j^$lz(E<{q<-g zRmANC!U7VmG*CP={wp@9h|36W{yV~vc0BXgKAJvpmh|kDUsKfFT;Hx9t~l$FV?68l zEQJ}zxw700q@lq}4rq8|i*L1fCTyo~K*(qA{Aaw##pq9p%lI&R^aa{{8$sAZKcP?a zU-(K)w45TKA1n24#AV~7ugmowJ(1F0#scjf6gU!9S2HL;J%#lqQ9zwOgw6lZ@Q z7Wjd%X(v=?k<=HOLB6NRn^iJfNiM1~(hMifN05zd1h6b0L8F*|hPA*vN8gpmmLE-t zG0_lB>9Y`U3J49yCYNKp4QPbc4#>##&&u?yKJK-zO|cr|nzBe{na@Q>4YAG*5oY&J zV6RE=4M$cm(T zQ*WK}`z4E?J#`k$Q*40SjGz`*FQgy#$4O5SC7r;R9YiDj+dDe`LuP0pgGu>It+I#8x#?qb5 zU(`B>>}@ONeS!;rfF}svf1p$#2l8%AZ#lx2E~tKE*m@K$3LB)kvgmyjQ7AOFkD_rq zv2n2z6E{uv6XcprRf*0bBx5vuxbZfNFgz@p$f!3n;^YBUdblx&#?e}NslawNr9vEF z^8KRS?UWpAjq*$v2-{1THF+O(F!!=yTbSVH+;BOrWHc##RpP;U zTk6qvqfy>?ulRLuzt3gK;9h$t;=+-Y0)1P(NDs`nJJ|WB=eHJZWUdz%;RFfsPEmq_ zmSSE+1|1U>`~06k-T1^LQ83?9zM6d+|4HUul|WvClt^KkbN$o|5IP)aF@EMP(0*P1+l2C-}|3>X&8gTn=Wy&r0R6Z_-8;(6I7C=>;E31!p zmirqkMLy2cm|s;|g`Z|3|AJ?B2czCI&T*}S$Z!=AA<*DK>gbU1QOIP4v-$Ld=tzI~ z2ybxt`9-&4@19IQD!a=2A8veW>Vd+P?TqtVOA;zyZwn z31QJ|_>x~%_Z42M2c4|Zh1;%$TL4#6L>T|n3LFLQxRvesF<}~C9!AzR35T|0Mfl%q zNEbOV+S^=4Z>uTLFFlrhJADT_aYS=Diy=VQWzs|2^l4)^lL*vSh*~a!+r?iYOdeTR z;M%+NVH1uM=s&p@LRl4r>(?~n(#UcY6ssbER78^o(%k_*MS15*p*kgWl2J8+jQXDF z5*|UwtIU_}wzXF)!}kqg|M8|MynHkZnrMniIX1`9QKd9ikrUxkkMz=u^I>K9R%z+m zT07?Z8S!u97>3zLWquFz`|bS+`M`5nH*&p_j4V?p6e9SdHp1q!;gsQRMfY7zK{S<+ z`tqw;hbIi(m-wTsq=A#=8U0ytn)Lf=3t4f$kPPKN_5=WQ=5VvifFJy`IS zA|3YLbGe-fS{=H$dGVU~^s}U;AuW-+XA;2C4SGb6l_cS$=!-A(7kjwl2nD1k#VoqtP zs>_%oZ4?SCNrg?J-pF2m+N%MXd97gE#qB&b?t8u*hpT6ni=rKVp=|e;Z7O^$kIqA) zi==*TWgKF2JB6gp2hkMPH?8l-Vo)~nxc1SaVv-oaZ2E2vU%unruLRQ0-9LD>)q0Zo zi;-RZ`hpUo@`ejF7iDPRns9O7o6rI&O`vzHZSkR_f6u!to3f!;??&o?DxH^cP*aQJ zai`nWOllxGt9Ke%dwOiWVzZXBWj=fT$_qC58neS=xV1!j($^1O02ma8$(r|S&ZK_N z26{&Z3vX>V%Klrs?D~>T#N>G~T!Ai`@B>>U!ED2!8(PF?8uTb+9RU8G;Z!VfwR4k{ zY1!e*5+BPF`(L{>T^0^{uE^XGs_$+_zwjkPBUsqNBGikpTpsBPeP|GLlt&du?e}NoU60 zhe&Op2>iV-(Yv&72=DIiygkpy-vvo~Pt;2Zh|2!e4nS=~K|%E)CQp=~k|);HN@h1P zX`Nu`WAopZ65DG^qC>Ws&?*s#kg^+!!NP8PoNSW%(Qto_2FPOsYC`xV(%rgB;)TtuA!x{SfW#i74gDAKm%#2;r*n`ZW&bFTJq#zRIka(7{?y z!YGIWz^}y@lE+n#?!rFgigAfWhz9rda@fOEL)LWdWf=c}SP5Hl&&l9_St-yTBj>7| zHh!_?SH&D#j}n&SH+y_D^j)-Y#3;Y79H16u2~aWeDIH{2(Z zUdc;K-#3b@n6H7sx}hOq-rRX(2A99!+kn~ zs0-Rg9-^3m=~=MBmarxW4l&6`VnQ66BO<1H2VbBXRSV0jAzwjT0iGF$CV84sOeD~3q|ruDEb%6 z58gY76SXr9Zt)PsX;U@~Gol$=H>jXVP+TGl$4{hi7#jU;$y7ea&~2~Jl}B~xRx!lu z7b=ggPSp4(>DDx7X=|8PFBT{5jp7&hUZEDtSc|iZ9;&Y)Cfbsz%FuZ}TH1IG`n#+t zTWYq}F&TD{V@G|(!-{&N$ibsoqo@RI<9DN7Q!=?P;Nz||R(-EDQhC#)%*NKRq&M1- zQ{x}3^W;Zhu{Y{ByK+=?V?zb)lk9dUrk%^-u>O`|pd-`ToHG&BPPeHgRYthqZgu`j z!%sI@+){5wJ+sr)N&k^*R$F#lR2;u(j7j#yw)p@*uK+foIq{*+jJ8 z0)u)GgRgjzHZX~3mJ_|#@5*mVOYX*)i=Lk{zb=0-lFQ8BkLY37lFf)KO593dW+KAG zMTjHK@L{Z()yHwdslmsobY=vikyXw{#=Y<9$S zge?S2(_jG|3zvsq%`2Rs1dADVBt`29cs&XH4-4o;;Pvu<7<`LLE4N&~+tGz$IWS<6k8J8orBbWC001E&Lhcz<`l#s0iwM&By4tD%iSH0UGBgM#3}q{-h@5!+ zR55^EZdM$}QW=VtT}M=(@4w!fT4HxhC*f|8d%H7rRN)BFJXbd)YrE_> zb>;WDJWApuZTnTlnr~=odl8d?niAN4`d;@^P}olPZ#>hsJwC_V?K}>$8LkaI9Z+Ji`^A{5igZFFCC7*&=a-Pxv9kgWU3pdd{ua@iN^N(uWy00HKv*w8#^#7@ z2=M8bu2`5_&wWO9uY*xZ8F_FmKh~>2rEcdfbow5A3IFA`o@!K%yUiMGWz38ZE0GNH z#tq|JdCGH*EHM_kUM>bYNp;n+v{l*~HTkp=`?J~;XnIDEC4T3#jf)uSH$`+g!xP=H z1~vXg*~;~xIrJL(4c(=wDtE3!gAD)6+brSjx{;_xdvo^S-aTy#Q?yWJOu7^2g$elisOhrDJHk=ndMsN@3Q4tSYJ^7iX?8>VN~mzIQkH@ZzAwXiA%-gpcn_= zy|nO{tI**5tJ9OseLajvp|PdC|1;G?z4CuJ69RyOuJA@WY`+>&-5@zTyE3KU0P{2&%c)OsdD;eI&Y=Mz_8yySpb1Af4Al_4&#?*#mU+cnBifN{ z;_Y-tNMb;vHk+j(YBMu(bnUf>a^O=`7#hl`w+F+*3+DCuL&WE|T)f~0FNw?QporNw zQc>pXBIcv&?y(4&e>Wt7*LoMem4i0`InyQp;*=csxE_rUM6%2UJ33u9+cxBfK1{2; z&)cQP?BikJ=WY+C9_Joz+32@EEo-lS>9)0va(S^5N#pXA-o4$&3hAp~x^B;)VG;Y9 zI$YCs&79$jE^&*@Xm78UwDXheD8Zi3jG?g|otj4%1o*4)2Luip(T`@0xi!4_;feg%E+2q9qL6Wyfg!F$Yj?OO{sqN9xb70QvrixL;l_gPSkmIsa&|l5lFijO^aUtSP zn9$McOHX$Q@yYPq?PS(+TO_j+oy)&ich(FHRO!1rWGrP))CS?1flG7zn<-AqE*_%K zCO8|P|JiYP`oyz0*IH~;=W`u_W|k>v(z&9dB1{vl|Am9sDUDyr(4b7VLUSbP)APlz zBa;M5&F^irsf8Pv`4B}2WS82w?0UfbDnn`W2|b9%ekQFkaeGU_+it1CeDSEC12?QbC=atw!kHhhAltV zY&W!Yp+6pT91^-UN-AKPQN@ysGZ+Izdz{ooXi1&9e`ro!-Gz1wf*Yo=iW(zAXy0Wi zHuJ(Ve^$3>G8KlQq?N%4l-~L<(pW|X6{uzv?7r;nr5fV>z zUs4TIP51Fk;RM6NhA=T=Fe-Dv^=SlUY)iLlrp{;*#vwx0 z3?{yCcB3(*3r|*kS+Skj>i^x9cK0aU7=RRIxs7@4C*pH6sFSmP@tfqK=Z@-bUkT)c z2YBk@y^fiRMsT~GKml}!7IE7E?y_l-m+&>gYa%iXU=>nQ23f2bd_VyDmUHoaRhMTF zu<+-OqU@xdIoDwtuW0tYFngfP?Y2QTuF88+$Ddx|oIZWXYK_5cbROD8?5d?JV0>Gj z2x7+xPtsnyE1EW3j~xuYs=e(0jU{KHMEW6+^}RqqDM*uu_>Q)k-?OtG{vFfc`=krg&6{lrMqwMFV^}Kam|P|oLMgl`XA|? zxs=VbR&tVA7B1RY73hV;@dA~$xrXD}((XN6iUi6WWoXIl{3U&B2iV`0Yn&lkw;&0O zR{bBW*iIjOPf(eiN$fo;6G&Co>kzu8C-y_5I}Nm?GZ={mnu?tR?`Xk|*N_eLNC{`1 z-81k*Kn1`eb66zBl~F*A#p6%ah{FweVj@2-Tq8Yu=S@Yhg?ug zhadC-T^Ylc*#|`>lBX-;($KWJ4)Uzy+5{LlI3$@W6Q@QO(CGH?VY?)|{rLDOOO(!% zWc2*pMGP}|Ik!JhtP3@-^9ojCeOhrO?24)m!Ozzc%(vMQ z*?4n}pH#`}G{_WM5pt$L^l~N9o8N0?fjDe&gkwiM8e5V=JhYRE?icq10Me9B*iR0O z1ahNhD!qe~eW@R6Uf=(4>YD}$CE;xF{w`HGZ*+13`z!I$D$m9cQI|JktzbY80dSgaJUv(zh z3A4Q9bFMFa2@z)Zqf<$5V)iL0lP)QS{khL7S;$zVr}`~n7yZ5cbu;3pyq<4iwF8S< z_B{+jzX*NoG~`Ct6N{EQ`4bfMK)-aQKSU@UN#vlIqOXFD%zK?JK~lGWThgL9VHCI{ zc`^qAAmh~lC;K-!Qw!w32v7z%$EZVTGuZ`~U}C>9fc!3(7flaQT4ETu0X~$V8b_R% zBJCY3#tA1g*>N+BhnB|XdWC88xHH~}pBvvuaI9)XSeO}(>S#evaQ=?d&rB2Rt+2k6 z@9QE$>H!A)@1~Tv{9PO2$UD;;9(2LUfqdIS+t>o1Za7gyt)L;Zui9IoQCG z|9FWc&%o*0duqEk4^sMWMLOUllP<>ZkJQ1uGcUnr#}EucrRGZO(s&CwT5U!XSy(*4 zD$*Vrl4(SFD@tHmElH>J-~@En(;&L}>GfDgi>7VskcAt9gt- zT-?*)#r^SmUt7S8S&j%hWxpw5QT;bI>onZgZ}qNj5*IKZH|fx0Fzd1ph)D@ddPG>} z949;Zl6AF>X_Ev7u3LifE{9jWtxO}3e%v1!ipQcWC26XOf7HH@s&FsS0cj!sf};-Y zV|~%?e;o5?3@0hIWTKOWENtTace;~(L?{m{UHJRg5l>$Z`HYprFdDcz0bJ}~dTmww z8!XrR-9OCzIOOsgtz>W!WLs_q3bJYPxPh9#Y=Nf&Kpqppy+6Is#ymRjqqA(}6?2wA z*61K z>^>gV_i^kFBiK^_V}>n*DW&$;#WKf@9?e2+43ys-p&vz=Y-Y-JdhRysTa`m>$Z2JgVsGQN`57)}bd|Nb#5ye}` zT1U1Q4d-w7`!(AqC%I&$_K^NyV^bkyS*drAaryZb*+=PHiPPG!Ohl-D@5xPybF?eB8{7AdiiV% zc$xZffaH;DpJX!Sk7BX8z=v{3<#t}aB(f%JnwVGPNsRIwYCrmQaD_9hF}MHfVz$3% zB`u$&YS)B*whZcJ`<=KYcbrwkgffG=YK`1q8VM8=3 zAK?3N1qwO7Voa_-nOLDMXl7(-3t1!Z@>MYDj!oH1)L;#9X>6i!yOOi1PjcE^ zFqBb8CzdU8FmPi+fBow2)|@opX&SpQ;&AJKZy^Rak7$hPk!V*=RoPh+R)6hP;3CUd zsG*PAdhra17TEh%z50mmL-MKiHPMJ@fS)PXp?A9yC6cFubUH2ldr~4Tu!rTvcs^QM zItztDw}R2jmni;XFE&cgL31JZc}&Q)qCzgbB(RDyTCuF`Q&NhPq_cYs%=5drbUTd< z!2I9Y96ZPa_3ns0q_6(D)M<(sz~ou zvC(RsgKYcNq^90Zb`V$kKPezx`X$(L)22Ci?Hhkb|hkM4p;&z-dw+ zCz576vG(0#?rtt>qMvL$LLPcg(XS6x+A(EV{<99q$Jt(pnV>rDx4sL#M((ELcjOHZ ze=4@gD1jjoM4bN}%jbRZx$U=GlkUfuwn)Arl^^QurnkNlIr+!=h^ul+op0s_~K(%EQt;fC=Bfmt)vUV{P>jxlyfpZXDRZ9 zM+%>l{qEeTB5Z$-ZQTE_Nkte2x&L`X%sMseHME#D#~&eD5w_vqWja~uBR3Q;k0JW7 zWt(0-n;4s%Z9>%*czt{8gIF#hPxm1IPF5VYkK!7+>)3Paap_bM1}aGi?(Hsgb696- zd2B?Exb5M#>Q#DfeYws2(_^jG#`p0t)1-0-bFO>5h@PCY#E}FM-zWRN#Fq8D7<&pI z#Gj4sZt;ID|1FX$h2A$-$@_HcIgCyVr=3R>}Em zE{DH0{dGDKMk}z0%vgtENA&g>+-^?~!np8Il2l#dlz9@lnJHmL@|d_oD_eJhQ;Yzt z%>NM1o=s~YQ?nE=J3oprd_2Ol^JteYp#@6tF_>F-g@=a|8Q;-^=-S|Wx1!a2!kq!9 zmHyB9GjmBFH*Q6l@a6j+m(-}Y2az7LWo|umVr(q=HZw66QR{J`4g_=|6bv`wA}}lJL6+XBlbE(6TXQdJCXjbhz}$c6nbDdknp=2=ja3hda&hMbnY@WM^0j zW+ksTc9h&e_5le5s3<7DgbZm37v5QE^e46^Q<(TF$bo6Rc^ zwTu9rCR>c@;+_JdTn_8 zvRQ(RM3!Kn5CiA3b5)nE@WI*L9brGG^2$MmsmnyrskgPxz+Z21SRL$fO7)D?0=jVW zDmU$)X~oLh(<9CE=Be$+(Dt&(HA2u4C-fS*S2g6{0CkdJkh2XV08fkI%mI^&If^{# zggwpza^)L|kPerA)?*sMOfP2z>4V|QHD zPof3VFxZv$QeSZo(9$;JmC!5No5uydC_6c*cy@v+M%nn;hn$x)Ez1##?0CPTghsm7 zOgiz>zv<(I-djrnG?u1lj(kc}shb@r17-%Nm% z4BY2VP+y0u$E)H$n!r{k+79j~8_;^wEPQq}qaNMsyEx%=M-29&ORmnZhJwA%#EIMIy_bjUD%?qXCOL}9F^QVZsMpDNpf;A>Igf8J?irV26~mO$+m2TXXynm z-;z`*TM5p{{8efvO_G1~*>ZRR%+z+@%kSSJ0miah%=9wErBnxgRm+NvWg00Kw&gqA z_FKH@ZFR{0@0ed8kA}>d$9B{B0gJZ&*O(FZYNN$v3snM8!9(J9%M0Q+!G1H(_4b{e zogH?Tja%%fpOK=RtmdJ7g48-WyVI>rX8WOzjuE_S=g%@MXV;-OM;X`Wr{nx7cSc1s ztFc4&X`MFx$~B0rsSX)(JV#=FT#`6{GWxoZu-~a^Uz@1Qj`}PL?6NZyJy5R29kNh0 z)nf?aiJLlm+%b!ZJr!qSg}Y24`eYE3V^aX8_@oiXn~Qfa*qh#vw`sTmi$P9KEXj}Z zul50jXQE$+zdj+`a5i%t!~Aqsf?enOkoF5OH&kc3rY{gL>vX;$gHL!cfs2Oa!KNw0i6n0UN^73 z@pir?%V`1w;{a%ud{uM$(lEMh(~23r`dfq>%vZm?@%%Nb)$0Ugc8grpcoQbLRZOec zb;7`Ar9MR}|H+2uBCUjM+qtcp$jCa=ey^Q9}~seXhpAO6jwaatfqtAt6% zjsMu_Kq`=Vb1J9vZ)TSz9F{mV7_skrpB7K_%xJN){5h+CQTAtfSs>Rv^zIHV6dR+U zBXY!i1=w8mHbXiwX(T|`VDX`fX21Oev#e~NWn;kE^=}TJebdRp`5`@dlYP0}0|x;a zA+hM4a-ZKl8vNvlJRv44Hz%9ApddTIeI|8Dtv`DKzj$}CAMqo!GJ9f-+1i>bZ|>lT z2j{Od&`a@!G{IJ*i$G!Mff@<2_c$%m1@bF*WtzezI+P*X+Mm)g5Qj6@g|AK&FMK&&)yv=cswN^YR!>aj1};u&`` z&i(w(>2dHZ03KGXpsljjsi!=b)AN?x>!Az#aQC8T!*>mt&G~*yL&OCDC*NEJmHm*U zKf?tdwO6;I~3lNUp(!Y&1O>J9al*1=CK6JW5J{5^3ABT|Mm9n&)n;p54#HAtRaAr;&gj zU%O3-7!f#*uuGMt?W2i{frYczHhK1y2Z}eBaN|c(PpBhUiN0gx2ljZ=+SyfYUd~Rk zhUHY}A`brS-?RCE?lSfHfuE>|PrgG-9L+M05R8V4nlRgC+%YSTH_rP^ZT{9e>5_R& z?O!gy-ep(kM?k&%jRH@x3l2P~2;#Z+~lrkDPHiLk3X z?j7lqzmv1)WZC7z|a;uX^y5&}`N+yDzVy+Oo|#E`}PDawzaJhl9v zO9&CWSsKOJwnE6Crc|Mmv*SJ`%ddldZb>`sRa0Aia!&g7xQbo)?Z8by*p6vB zFdm$@c!K(tY`xXNO>^gK2UlzR(p_Jgbd;;k`qSS6ZWT)Vpkgi;GEaVJJ*?Sf;m;p< zn-zJN@HMO#d)=?0%AYs4&1$ZN!5|)b)A6MD*taqvT2q830@7eWKPphsf!{{dkfeQ= zF6G)2YW1E^G21qkG8*Y6)_fi zw|p$3EhkF?kEGJp)6@KM5tLVDI63gsto|LA3YS~lsM#>9U*p(&Yvd!uV_K3iugJYeam(vQPm;ob7F?wzAgTElLJy+=j7pI3&YB$>ftaB1_Gw<>b5LE>O zXAUQeaE`mn1!=5Z1-uRazE%hI_5Qo4SHaxu!tRshZt>g?=fx{Iu|lD*2Ecj?I7^RR zTSH0U@TjUbmw!k)M?U_U7lV)jJEF4ISooTZPZi%$e^Fl(nEZZtoK^*^D(CjU%qhMe zh^NW;|2Ra#IyU-3%w7q3ii9jb9eT0lCcwHKpz0bL&T zlNM{Ue^<+E0K-B>pM3*E;1MTeyACt&`7H4QGHn2S75Fw;A&vi3)9gNUw>_mSCZ#jY z^0aaaB)pEK`TNabt-a~*jFgfbnv(xo{YlcIJ~*-I`YkPKme+;^5=K@+-XU|vLQd2# zJk~%4PqeJU1#zEZUUOsDXYnKBK5dQYkHYR}$6m-6U|BP9pKT6tv01M8Eer&k3Dz0r z!9d%^yozWbD(K%n6>eJMhDPs+b|PahBadr$7Uv7krnu@NPXAtgB1rDLTGEw7o zgo4j8kJJtA{DHYS?`@hwimO|Igu!T7%!1vJlOc~48dvF}HbKG~qc#;wSivGSJ5B!; z7qE<_;Me7pZt#v|^{TU8)RkfT>}8Eb+m3$sp_A&@pRvnh6|{;0QdDRfha=-JVUqjG|OKm*QfriXEbO5wG7= zwA^_X^}Y}hIq+L6p!*GU-VrQ^Ho>H!(d`L;z_O}rr=KT00pB4yk{R>Dn9K%32*A!H zdNX^3i88v1hClB>a zk~f#xKn`c~-VKb}?^w|N?(z2h;o#8@j6grc=#Cr)sXe(u z;s>BxMX#Gfdgs``u#=>(Jg8^OLz}W51 z;Eeam?ri<^D$<2(L9c+`vcX@0|Nge_7TNr`+S$d0#A7IxHX^6prI#N(Cvbgyr(+b* zkVoWuLkO zcLG;RhboLqhZD_5Xaxuba@$$91GTz%vUVc>Y#DURC$n+6$y2mdQ6- z2!z>)(%H9|35;;U*TLtI`B@7mziV1*%nx+uwTqq2ETA(F@Rb_PTmmzWzD^wTk~_Pe zU7hz4BrM0k4uA8N5*hTs0J{^mk{U<3XMw?cvgEnS>1w8naLP?GT7}Q7WBTPe!@e8p zR5UUDLnwmWh&J)MnN`#WEVr8`cs1f#ev$2+{Q&T<|2xovYpHz0J5(=!813Qlv3`|^ zRxd-RSZ^=_KEr`M6Xj<+x^p3}lRvtWOb06Umcl>}r_aOzaQ6iALky#}-s%O^IwrHz z8U38`d6tWpd*~#SA>z4OJzh}QK-!uv?syd~2)0-JdyXujK#=((y8JS|^{j>joDaiR zF=QI1_L7L=*un@IpQB1P#Hffq<;+WZc&ukU`MPcPVW+1n$~#y0bi+4Lk$i#4o3mTd zY}|kp#_U$xEf}f6k$QPlM)Oh#wIc#De=z)JuJ7BkrwfmMkj;Z!N!_+1xC-?Q@pp|+ zA{3KX7!!lkroBeJ4CIinsvDCkrvrQRSkLt`-stv|wYM0lWY*d6dNKnWL7zVP{-gYP zMy`-um&tECc!kwt*PQOM@x6x-;GN0+WuH7?MY-`r2a@9m&#(;E#-msI_%wt1^cMZt z%2W2on49P$d_6QrKl+vYk71;koA({0MwivL#iJQ{M`JT;xAW#lD$WwGnj4PV=><1$ z)2rW**0QzeB#Q)WWTv@hDPc{yeFDzT>|l!`R+(fBuGg?{r?RB>+#1+tRX9ZvEM2r0Zmnh+Nu;tnNo`$_|*^4sA#f~aF6?9rhv~RZ*o3Bfp6l9 z{a3!k_^A#)>-k-ZF_^4R6TTz3;b+?hDx_#X0ra$IA~JkC%YeSq@2`SWj9aplUtTR5 zdCeqZGbN28F`+H+yr}+UgBw`6oya=Uy6Kq6xMtP{0=%1<+e~=sl%QrEn|yGFUIwf; z4mdx<3ir33&Ot4vSL6}0yD*t^6*n=S3*AQsN{B~nZ>{hNq-g3zyh24Km`Rg7~wPp0p^6s6vsvc=VvR2{=Mp zLpLMeUJ=5<&cT``r;u1g!S&9{oM7%%NUj|9#AIp25x^$!Z4qzYFzlwGD0Ml{ELb@Ya^Sb#i9>TFrXg-dmei*G@?c5dv0K*Y=!s&g+yJg3Q?XUz8CdsU*hW7BeY(U=QZpYf#|`k@*Fcv6<~@VHEVeVVAL^nqc5<%Tu?&^8Op@oj8=SY`nu1El35ofBl4^qdJms zWy-X^U|%U6-|p_#+~&9(n`*H!8Ohy!xdhsb>uqwZ;X9@OFQ*K5{uIN0<|2WSu!_}C zhQ1RD%FGjhE`hd*441BN@P=rxDuEH`qXUkh*c-NlC)Aatd#ufxO_9&3-513EjmZzU`4M4v7*WXRGT z1$>W4&CEc(x=75?xp~KoUVdy;NLc8&I&AGeM7wM{sbbN8!vcuH&*J(9%YntGrr?&` zZ-=RIQznEuxB9 zFoswl@HL+{-UrOrtk8246FhqQRR+$*H@UwFm6BOKWE#wk2*DxEe`W)O9%>ekv>Yy> z*M5)lMItYLLmc7Yqc0+#p??d82G=+S&sFg`W7mInlh$Qn2LuH4ghaO>Ep$Svz^eoD z+4wxvv8VYs+67iy%i2@f;fN6LAYebaM=nz{^*dJYEwzewp4O(;sisK;)iCEGcT@AglI zhGMJcG2xnR{@IzU4eIu#ChX`%EE1-6MxzAMKlxdKs2wDIXnyv;<3S;rlLIxvD{1vX z*dH{+N9~tVr2t`1ch!uBb0f361TIH`;=P1};}P*mnjr3Sc@%1f?wq?A9KfdRJ9<6+ z#id;JOjZp{WH1qYm$1*mQy6r=c=c0{lWTK)MN(sJ1Xs^LjJF<3y1p8DsM)<0Mhob5 zNuYvM;R0L{i6e5hwP(V0H3>HRC2Fb=TRh1Vy!vzy8sArZBMGu z1}hua!x%gCFcU@$ujluNqH`L{v88G>7ZF#`h^Y(>fp$t4%Qf5UE$*+&UAMoG2SkJ4 zZYMkt3R>vunZa25<^MccL;Eo&E8P&#b)LEA++k%Sgq5d^Oi`uhYuoy7T<=q3>?MFX z41MO{KZ~V`!(5>TyxHGi;clPyAmEm~G?aBCfKU(r?w=1TD%ixTNRHJZX@#?OGN1>s z;7-*l1`_%o7BE3qna`0hL=OCV+%E@4&)$G<+_EYUDXQXk+@7{n9@u=w{+nd%Ybj`r zy|(k@e~AEkOxcYx&p{#Jvw+5QSz{>jCrCz;g-ylkD%;;64-V;~c{Lpk`Zv;?9^~U{`YF ztzk~K#zK#KaTgVB^(f$D>pz6BV*K}SbJY*^ zFhFn*!QI_GK!D)x78u;!Ex`tN2<|=%GRRqE@9gBezw>hMJujzUU}jCrYN@KOs()4A zvfIcvO5EMkWI{F>g_3e2x;GY$j>|49+Cqtxb%KVtVRdU`jMnwSh~e!w);g@;bK>Yp z6I=Lt*pn*xHDnv;Fpo zBqyobqXI%(M9w7FWL$l#vs2p%Yl{jaS*(@Qn~lr7hlaEnTKOn3bjEzNsdQK{eQ1W( z{!sPI-^bGf|;rOQ2cnSi1`ef6?pcZTwM=ns4$J4fz*N()gLDil_Hi$qkMe ziH%|Xv|8FU;zOdDx7Nk^xz%r|tc(MMQvg)0#uRc>vB^}K+vu^4<%tD0Y8 zuf%%97^ISzLL5USws-Lj(ELRd=2xMu`=$Ck)dsidyaFI&iGi<%OjD}Ygs9ZWBZA(o zi%MlcdO(%kM^Ba*g67UAB!U!#1L~eiClq$tsCw^}TQuM2t+Q*$Fi2FEy(E&pKe|n+ zysb{y_k4TCeGGI=wy!T8jVswmyqXvloG zHaUv)E=1RvAMz_-ljs0E%lpBlde!*#j@5@4iM94J0wbrhg$GLVQ)UypuSTi!AaRsb z6*oH${I_prk94F0P|KU|^LZRA2n+is&F;ZRJg$ZV#qo~L&MFcGb#pJmxsP^7WdL>m zLWi%+%)s~PQ6=}xh?$AFCyqUA+u!}J*wl-6KtlZ%uwSQv9`NgkgQ z>SG?dP|x$|+vqZ-(`OK=6=fD^{ZOEci)MYkzDX%zl5|jUK{R~drX6CRtO58CWjd<` zv1b+2ae&04TYU$f`H$P{t56L7P<{_TjK0Xv^=3QIRKcj3DD*}1cv{mW3bs1=H1!bl zn$4mE1qh1cS!wF}!TOysMRkf;^!F7N;E)O}?kdaQ((G1^7u`^Kb>)}D~L-tX7$NC^C7fP)7Ic6c*} zfX>eFCz7A>#T)C>phi`#1+j6-)PdG4<>Jch+lC2tk9LqOy8*PfJ8pZ0I6c8~UB7G` z3Xq4qPMPv#!4eO?^G&8c3d)$vpW5O|H03A9D4-Njl}i<1jZo`y1_*TLZ_30-dC3W@I)OUG9wO5$1!^~Ry9VM4;;n}T-4m_?mqVI(xNqfJSF|Aqv zhI(SM&*n7+ag1Gdnfa8=p~c;y__{3T&r_D*gp;F5fMW$sa+$73co4q+rduhQ>+*0Q zp3J3eJOnAUShYeqdXwr~InpQ@b!(g$8qI^?5q+DtkX$@d6dV5t9S!%2xlIqB(<;qZ z5-5Y>1#f-zUOm@!TcP-Zv|MF@Uq~Nc2y4dGooc2st`!F7{=9OeyK<@BdceSTm@BnY zW0d#9y55C|AVlZDJigy0u8}iy*f1dRGXLG^zCHuE8oK+)zli+XrBLGM(ZRJZPp}#K zI^)Wv6lEU>zsQ`e%cMZyCk70oVN+f@&jg1#^5gj>hwo>1iM29d;lxdWG;StbvpLKV zv5YNMm5XOUP$;x<-llU@YoNsR`3@i5K^oI2C3!Y%pU(&jlXQQ<6mDW`sJ>z6fO0W+ zfx>j)`k5Iql-&{!Q^XI$v?KO7OC-$b+bMbIlfmrNteV@RtBL7p=iZNUn-HR`Pa;w+ zh6Us4R5Z3U>YuoV!A}~dd@T)eBuOU4cc6!v@F$G-q!1ZalZsJW%ZdqwJB#Ym(m zz7_T55Pr(+tmz#S(2?aAGvDyF@NN^B8=J8}4(Zy`*Eh@WWT&TpomhHUEMS(%gkqk4>s@Ljqd;+Lm8Y zax+qUejF{ZS7RYk>Z4S|g^xc`SG*&$nHUBn*s&E=}mpM51x?4k%pQU;*#=`aTJ=)TynCRGP5cIgweaMsm=3*#MS%f%pa*Ea6 z6(+%}RYHn`h71OSX=YJotGV_^dwLw8<0IwO7wQW(nDYU(4K+uEm1*wKn_Wcgmm=Io zNHnp~FOpE$PON#!_0Ha_(F5m`w2~10=gd#O6lNKQM1_M%zU%G@qMbq3xz>ANK=xE%T`1gn|Nv zYF)3Xg8VvR&KRx(7$I(oRy;(ptdZNOQ^F#g z6tx||JY2&YhQaRIZO81~BaL0{=xc}I)t&Ad2V#1sXXhvTK3%vS9smIrA|7yTSSux= z$t@D*3|1>D!}sz(J~q(_70hYc)LU&}S$tbbTPIXGwLG2Def})fFQ)84A%R3Z{OInNK zR1Hi;wAZfHO%7@%pF-QKIR(bHU0xMOMUDQry{=KEIV#bLYCJ#QVcOs&kHL;40Q^uE z90)K;nOg_gT8EawHDc7JHv2)(F14U-j%V``$U+Nn+PR6UL`pKx z!-FQR)eKeb?SI0qFgJ;VPGx>%+#^(uoxeX*&gqWZ{;_&Mz|_?T^7>@9M47w%? z*9;==RZOcC8`;qFeP^dH6LB$8wz!hKrtxen6qA{bGr3Xw*%J_m>*1_x>-*^u$#|`J z;#`OLNRYsFe_yQLfuzZs8*_I7RdnM_AT)tCDHvSeizJS8W*XJ>1Of8tE<9)ULAvwO z5ntM(P&$WE2AboyAMtW>&q}D>Q@tXhqM`y37ajN)+=FH`C6D(h0=PSJqnklVd@IvRc}`ubs9 zpZk{oJ33zALTR@>+!CH5#o@lbdoep94PT!<5ET(swcnk3@8B3*nA1>;Ci-qaLAcHQ zBRwSZt!f%C8akr*irmOUhot5ajniqvR1$OVoA>W60e>py%{+L>K7B(gm9dJ~Z-8 z%WZtftMTFEl}Wx7XZecMZBM52dm>5Inw5srX&^oFhgfk5yUv;>-}Y{{*k9nP-H~o+ zea{`f>gDNF<`bWpR|Uu6WaHgcB86Ul7jIW;k|br#oO(@+W6P||^wt7OR8I(!G}U=z zY2ff3&A=SALIpg`=(Vq;oC*1Z#B3iw6}B$TmOk*x0(?HdYY{Yj3_n*f9^W%1@%9cx zIfyC4cwuGLG(T_d+m0tJT|hY=#Lct!&|f+Pv}>j&H2l43^q?9UxnyHqUke+9fTWnc~XF<%oVMg9?kRUh5>S{G%%+>+VSDvqU5mIjV zMMU!|>9;Q6Kqt?~MBbrnKG-8Q)f^iW7h0KF>WccpJCn}{ORq)e@o zeXfa{1T+^}Y?%j7iw+MXS}@42Q{1lJ3u$_4iM2TGo`0`p*i`R82i6imz#ea|nriQs zgqQ%K{nF-ANRcvHXg60mY;0R>sF$uKKl#u^k*2s zmGJEKTAwA@-soL*2!AipR<86{LM@na`B%E1E>dppZG}2CmQ_EFk<;-!1{H2W#L^m9 zOT3p_A7V1(ia**&$-4Z=uRkwzS`042eKurxm91Kq-8AQ*>wLW&7bdCY_CAwgp!6RM z6!W{|`^HTvFOL-j8->AIvEyqSY>H8BL)bxvdd5CpVHq~btb8c^VFz3u7`JbYa7(s_UI9;B4w(R zH6}$2ld;ud*<3y-F%h>xG1=(Q(DBAKw{tcO&1TmL`TFcmXdj>ik`wM#bLIN@>|&M! zeqz>oQlz&1oAI7aFq`GDfl1o5#lsax2h$7!4wpg-np|^#)XItwOJ*Xc@}x%?$WMs& za>+0s@jTn@c7FFR6m3FI!4A$G#SEoT7|Z@zrtjC@d_oux_gr*~@PKueWxL<19Dhjy zgZIo3G4P3s4kwQF%dfySO=zznY3$`;6jGZ%rU-PkJ?)0|`U*}cRh7y}`WQ1#QV%B@ zAMX78g7^AeVOl2uW#3lr(cNjFE@VTIe9G`;$fN>Txnd)?Qbm*em>($Rm_>VJ5qo%N z3ehE}RFnSN#aVgO?(SEetbQM$dwSeIh{9HPAq7~T%6*+{uekDnEdA$e>z&ZNu} zjtog`YW1lS4`KW8_;*apg|6g~gZJ(dy~y!(%pkp^a@{SnG8w7wkx`0jTNlr&e!>dodn?sGhkv?O#lYP2tM zH0b>*s?1aWM(s`Md6fj1fh^qogDioL95IcqM269Fsl}`?2Lk(ZW}baFz`3qj%*lBK zBcyMfCLcO%i==U(4RZCz#<|ynga1m0i{WUfDeYivp3{!J50Mc9(Q54T(dr8+~QI&jrR`$ZA z%3!fgNXeE}A?52B`hEV2UZ(tw!d%ORdUwE!Zc6jLlEzya>g;#ahp_6V)%}k#ii&Y` zhCvm@s07rwywrsm(?$$8w$mf?u4n4fHC)-<=4b>Y$SC-cfQirMQ;b9vVEjn)glz@H zs-2C}Rae0RPzm&E4#d>4Vro>!Q{^$M15lq2f15X19!jciyjr#efgGCAjXu;l@1T!7 zlzZmj)1JIj2i?QP_1IRceqW?C`l7S1ui{7`0Wi`5Ru$xI|8Q>Rl8EWejcy+Km70S& zeU7rsa&b@8IauiyD`z@A72bJ&;9zHnoBQ;{q$O8U$fBRIrBr+_1^L&_9osGF11x9f z^GD9LERCp=%%>I6`JpYvv!%wjk_jS-+0HwVXh+NYsYW-tU<0b-F2=fC7``)sM`pca z0h03Z+9sdq?o z+`OrmSIa4c&y1#UMzt~?38$;qu7ok%x1__BR0U;EHnkkg@Z5UWRi7AYVK1`S2V~@8 zNDFP2k5$O z+>jej;aiuv!(kMaMx;_J;Ve6>m=l)lyZeI;h7!7-_AOUBo+@%A60%onSOsVl#jLi^E~dyW&mFy zyjkqLv^H5tTCCcjvv6%|>^(JDGP>oV0*^|WbpDjF_nwT5t4nK&-}P``6H}U5=L#P@ zSy+)6AE(VP!`a|`rVNP1vkY3On_F2W0mim27W2)c7P4ZVM76hVqQ;R%PGX*whsV%t z*HvG~MP?}g9ROh>-`TE`O!(xVY2jof*UK-I7Kv6?)Jnc<+SHP0KV^rprySq3yVgeo zwnx!?(l@ERD#@u#R`?)kgYCW#%_S^`8<~1{9AEd#a?2o}`#C6?t z>joH9?%Aj&TqSJ8CKe-XrMy)wlTw;1C&Dd+?wM|s)STk68Z*PV=A4g@M9s(uxTB$) z@TT#Fza&5U*ljd1BvcEHBF5B3vrFFX#ySL+UIc>6Bq+WA# zG-8??{dLTPR;OTp~i=t9E<*mUGeg|AOSJ=J+DZ2%3fIhhM_W+9aZ+fL~g z!AuFgrly9OC^R>pYGCaN0)OCeEdC;Vp42w2{_6V*>F1ehI!OetSi#VC+SRlbLg_&c zGTMF8`M|1-C3hjCrKg)(sqU9SfabjvRY1&qbaEPl+G)#wLnQP|*k`0XQG~Z= zA@`F!a<#NBlg%Ga-1olTMK!Xz5xgvf<8ZSd@OoID0m3GLP5SuKFbWw1MYD19NO4iM zhvCkx628FU_yzw3ryhNHkMvM&v;&duZJSWF?(oRmYEw;I)NF$D0W;WUk1ZNE~4;Il+FYPsi?_AJDL|AxDy)Q0eUkg*ZX7kAZ07xjb2hAzDiZXx#?0!=Bw@^Ea_jB}vC0hE&bv zMaf*cmpsF#w%>$hcn!vdiLc3P9S7Z9IKzWq4flxtq{1v1-)_faGwOB(zzMO74r=eT zEi5daII1hZu$a&pHLsM{`EU(Kj7g=aNUU8YEYYa9b6)+tX%@8VrgrJccj0xDE)3*x zip=GAb82DN&KMQ5jXIMY;1MhJdvYRd#Qq3D^AV6;%x|s#YOC2Xuf#L|TV}LZM0nDM zV@2#}I}xPm(5W#-+k@47;(?M}qD0QkP|vNo+ce_FfIFba6<0^v-LQ1zM8n@4u)3`? z-0|19Y&p7Q5f3vD5ystA(dVpYe+|P}beAS3u;Bs}&WPxno@?7x0S22mKMA=w1Lg*! z!g&N=Bww?p;1OKIUpBFnzd>W3ygoF~gDRY_>Y@~>HIDeMZb++d6;Xs>5MslwBJ;$l z=9pM+20WWC7d&~Z5@}HT~-#OtzX@-{T*KKzl25;sZJQLLhJcGedB$dQ669wo?izVwr(*Q7Ljh- z)zdYr>ARe71s**A)@4_KhY6n1nn6bx$N;>;ni+)+coJTe*@Yg$7f2JZZM;V_Q6eZ+w~Us_Dx8ixT6iF@j- zs3>?6oE?pD!#gW??A~&Q(BtcJolvl(DWC9d*=oW#_+}CU;Y746?c&eq9w9IaVE1euKe?|wq&6t>h*Qvr=-2$D!!z$q02wI>7@cHa zY-mF}v(h}yE%3rzq_j=e;?idD6EdDLQ@Ocbm;&xtLN87pu_m4FGRf@A%z-NKM)X2! zsB;B~jB#;~u7JF9%=^Y}$x2bp?(i$vjtpRz5+lpF*9NA$)*TIC@xH#F*o-NiYeaq8 zQ>~rUz=p?ovfu}(VfO271z&8mIL^!?p6w>5S|znn$K9*|*%iC)rfWZ}Ot)G4bllui zA$|H1Vf&m9KgvD$YaV zD`^NmOC)JHFiDFL)B#Sj`cMZ-DHnDYMjJL29S(++&o^Akn!f2z(_`Yfy~!nBVj9#w z`jX6}e-V~r*)J-A1VKk_u{?d-1bHrO@mm+50z@})BQGyD0-OO$4&2R$VHhSrGPnFq z3TYG_)W`H&_u4xIG4|jqJ*RkVS68OpT~ZAv56|J!X%1(Fu6e?!gd#JEX(b24Laqz9 zLwR@}(`Kg%w?E(DeqyzA2iZnFS?L}bzgZdL>5)k;pRKm$zfJ!hMao;TX?;nXo#1mH z9*WDcyqZ5+Ei+8DWbjUu+0x}iyM)(ju@Hmz@?fziY!>!Y#Dr-yXyxEe(64mOWv;nY zajms&ua)mX$LDi4={ab)nX2lsxOIFisIy2$=z3lzN?=0XV9byx4`Fv&^cDh;T^{6H zn*&K})hFjjjXmLb@Lr#b(C{7;!IhaJ0O@_XA9&~FD3Md`k`Kuc+D~R*Fp1b#!yI6I z=kXkkO51Z~vHF@ma^~iBn$zx5{*mJ=g3AkJI(@C`ixowQ|ZZB0Bp7+~m^ur1<4m`R63E?S0w)pp&w%QJ) z&usX;7E2)+%z^I)L|0N#wq5}sKCV-n=l-ZjPhL$$uQaM-3s*qmlMI{z5Eb-1$8v9P zkHra+&dNQ!IWeqFeT;$({9VR!ay>&i%-ZXV&SrTA(A#w61hb%QW^&+u!<1u^{K`M5M+@sRA$ z8vua(6Nyox7)t>YMdSR506y+eT#T}WKHC<)3~$`V`ZtDmzw>i0GzkLvnRuS`C6v8g zTJsao{U^9{f$j)%8FyTd4gNga7XYlv?z1w2B4IxMF}xN1=6D+bnq89bj^tvzs&XBpco1WAoPGj>3z{0Rnrb_c6KYaGcp3+nXju zT)W^t^hbXqNPc;}gw+8L_-YYq@slvz?b*US+g;RvR&VxU6q;fWk|R7t{a=+E{O z-M26_o1QvXJ(L(=IIlyg=05-4{>?Z$$%TZ`+v3f<)Gx@kW>ChKQk@!w@*NtRAICwy zNWq9QsB8z-=pN9g?^?f;;mGLX9K0TH)ZdQyE=6*a>m-xigMX%s;eiXhEIA$uGEhYa z=3uj(Hb#82#mrFYcA@#9+Cza1-#SCtxnh59)MIp#`TO%v>pASQfR2Db3F1;@BGKWW z$Iy*^QjzZgFC3|aiC6GKRySv#n6qZs$@KvS6!{JMC$GH4T<6=1^@T})Zp(kL^>4j)m`ZE-n&egF+|~>T z?%HAOJl5#=*dxU4b6M~5+~^T1;nHFEL-a-X)MNu=crj-ki##cj=J=a~)~>8|5Fz8> zE8^(87#8g5?2A_ZN{iAuuz~wHhSh}1{J^)I>cz(*pt|UVBm0xEEj)>Id%*6E$n28D zpvT>WU+>_u7DSVGLKj&~GGo~bK$StbPk+r-s}8_m_K{!KQE^@5ia3`stIHRmb;m~n zi2MBF`TiCcrnE5!X(z+h&sq=FfCvFKjAge-E?hB=M@doYMn+Za2;OAT$T9*$Bh1WtF_*a&{&j$w|zv{GUN3sWoY^^Y!Xvrs4 zG(pQp$f}>#7Xyn^cpZ2`EAtqx+sR;r47QsOy^|1uRYCBw2-f-{jS?pLgDVN#C_pz7 zhNXdIgY)?CGTnp-z^T`4C#u&4<3%3d-zzf7Bs2vNJ-j7%yH}N=^@9@4S}GV>F<--Z zs9HGtf~4d|z=fa+6crhF+&fTnImw0Cr##?IA2qznG1Tf@)_ktOHjwt!6Nj0RbY>FoT_F*Q{!P61_=>I{+ zSYFFzl_F*cEZ_@7KnTH7-IU3zD!B3nYr_CBOwE1%2aKb_9;q zNhnc!?W?tn(-zb?ba4y!u6XY?!5>5SU$h=@K}OT=CY)aei{j~ptvhJGRz*QEw|GK> za-f-V=)_@*#_H&B8S3C5c#RHFsiJN5nqcrMqoQirc5bzrVf^OG^0QI4v82%?es#c- zVmlmiP%4RC$^v(Ifd2x$2>gMc{agNheD~>}khK4N@AL1A+8ozcfA!zlsAk(_o|WUnl7&r?A-&*P!4gzqs@0@8+uUy4C`? z_PLdT@FL6Se@2YJ94=8rQv$)zU0xjnvkm#%0rc{}J(svNQIyz-ryl@!OI0t?LSN*{ zxc7I5wOT3#!%CxEW~@;?YhU;8|JmOB^7G^rC50mpE~$Wu@I0<9k-z&Culu0*b~4;< zcMKdCUegdI&t`0A=Jn?g{Ddi(n0v@+PWs)aBj}h;u%r8HP!>X}`GYjn;QZmmv)#RJ zoeHrU;2L!|mv<$dp-cZq<_G^J-tfL9YfUjnAfnPgPXPYZoQNCgnN`>*sPD8nJzLa} z>-X@_ki9g=$7^hmB@}S+^YNSB|J)nYVGuv_|5H6ZH8C;qxwCMYhVxErv$7y*hw9HY z(Cxc>MMAc&cEcfj6y$!=p5-WO-U3Ahb=vb=-pJiQd0WdF^|X3NWMBE6U5u!|9qz zCQR(>)niW}?7b-)%lqpi;EgUp9%l0Fh|-26utZV*2V)~MGo&w7^(HRFzefY<0Nk7F zY*c7pV+Rgwmtpk)$!nRjdq$1ym>Cp?QS0hw%3~>h|JY8M`L!M=R|SWZGHXcJb{#%6 z;SG6ZmYSoa;|{hos{QylOZNiR?`_$dacZ-@w!7MCz&@($58c!S z^i#=HlO)i=)*FEbmx%wJ=FR=5m_AjON+n#8$)8-qgXH(GAw|HY@ArrQLE`^q zJa^Cd|2hf?0)I#W5CH8@-KYP%3Mi&RnZI=bcP;(Ds{H?~pg^Y|6TmNXJbim}U}u7B z%tf5pu6@xn`X7~h{mKLr#vaaN#}_z2YVOyJ=`Qd~Qn_mg9bG^td}VFz2=bsp3@?LD zSo`bU*LTIs7hw1=q?=E|GJFNaz{5De(|?}aNr-?lC?nO)jMumAO%Xp%9^F5j6?mSm z4&?O|x`6F5F%hL!0ugYUk$x|@m&H8+ogo(88B86?DBk)vn#sT0<9GBvnQi)s7em*|k1!eE#3C$)xG` zUyEmeQ+J;2QYoRjmGL)Wil^1HKiEMo$Y&vaDyfTK;htr#Q-O7W*N93MgYlf7BP^U{p!QQ`o!92r2u{-r0<9 z{N9!P;Q)=^-5dv3TMGIBj+y_I^gMo9q|?LsI{P!11g7={ZrZ;(bq5A>@194K%Q#p> zj1Y!$_=i9g5s~}3TwWrr4>V0iV~ub@bqERX{i#|MS1uuhu-%|q%?*vba@v)aT;Nm)Ai$8-$PyTF6M|MZu2BB$MP(njP<{FlckEDJE zK3J5}`Y~!b6MxRKIJ;RaIOImeR%oPx!!1;B$@4}uQRcG5Q$cw#bLpdgj7W|E@9*`5 zj9RPo&vbsB*9E_VL2&)M^n-&Xs`j8-O)d<{r!k<=TTZS&(hdY|u7IfCWIOgwh*<<2 zOGYawLt_^tER|~ON-vr;jcMPz!~^_5Eys_U#=&u=)>iYSQ5+yr|0)n&Bl~gSR^jYl zFp@cJ?%({ZAhFMWYruX>t_sB!0Oe>XJ6NWo48^SYx%@}5crc=tcBf}U7cAxrgf6@?l5o46o+-M7J|&%pY93ym|K@ouFc7+$Mvf!pf53 zlX!I4HSE6M1@Qk>WHF?l7%RPe1J$d5Dh>bxt0QVKb~KvX|BK{#;d3j)yb$g!&W|6K z)=F8+%Zb<1{Y3@Qj%0VtFPW&IzMxx(F z_cN#C%@lPO7U8b7@fs5`w!g&n^!$ee8jft}OFN4FKl{OuQP5dem z<>F1=2T6a4@J0bzO`km%>j#nhVq$7c>ct=E{q?Q`9iJG_xs_e=E|o$Br==;e-u@*T z0_ioiO0+Jy)_xSI&wo;Q@prv$D~IFVJ&^-Qbg3&;;qza@%e#jJ++<|#MS{Zb{+hSc z;9NhqRFwQ8g>OoxK>+3cz0DCIg4}FjOCWyR3pZ9cdV$B8P*l6ZjJQH*rANR2Uq*{8}{AmPOfvjtsUO|4oa4 z2%2411t`e)oM4-u>9Sy<2HoES+;j`T^?#p+#cB0%tyMthl%|sFzgxiI#_q0Jt-R)5 zt35J42l;!%KXUsc`Y$ZV_F;1mV+CGM(AfQ%A5E_)dAbf@TYQuIZ_W_HQ2%VJ|MTqs z?p6MOvHZVI8^Zrq!1({vL)V+TY6u8>xf05nIpbRv^KXGqH62zqHad(NF9#n2SZBds zzc6Cj-rg3}UqjQ+(a4e8vZK^w0oK%r7ri5)=C#a1wlP6uhiOKgEG;ebZiVY4Qv`V#y4I_L(RU#w(O8L$NnBzNhfjKO&3 zgnk5j1L1v&2{W3MSc?k|rduFyR#DK9EmkOCiNQ6`QBuwyUr@;3LT%zOQzjja`EI4p zP{-WpxPg9pguX}oa-@fADvRxIuX?3Qzg5<#mHUu0`ig)tIoZWlrD>Ylk#%+&2v-9r z36xHY5^bu~(w-ME*Q?jq)tGFa4UUf_b-^|V4cg@(oxYC$hKUJM_7!w*ZyWT;BKCI#OD3J2qb*qg>1#U4)0T+Gp3!PG%bHBiC8ird7+?)hpq~NoD?lJhq+&8s0F}eduF0iUn zM21_=9y`@@owZ}(0wUeRJgp|T+G1wfMl~1t{NY%Id~}V0l&8BJ?fk9dtg>PAp}F;! z{IPnY7agy?r7=0$I$2z4CE>b)Ec&{6Igxw!45hTwxxp&?0 zIl7bQ(>L93u^bED((#sp+k$Q6u@46OhglY2$!%MBfM=}mwq@3ydatQAEZ|1}m3M@^ za@XaYLldxVTFW5OR=49C_Ek3rQkKe~H;XC2n%YqZM2)_�Y9^%>IxAG1|fz)x3TH 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⩔{tKI$YzV`QAUOTJ3c~0UR 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

GoMy=LVq)g{rrWc*3{% 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 literal 0 HcmV?d00001 From 9804cdc26efbf6d989be2be246c30fa6ec96b921 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 16:46:10 +0100 Subject: [PATCH 15/26] 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 e00b25c4fdf..ba473be129c 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -108,15 +108,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 c40007fefac6dbc228b3c24b1af1ecef39f760b7 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 24 Jul 2026 17:39:17 +0100 Subject: [PATCH 16/26] 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 f072659daed2fb26f56e5d1786fa9c649a7a2141 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Sun, 26 Jul 2026 01:53:30 +0100 Subject: [PATCH 17/26] 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 From 2c67f255e8239cda7a37c5590ff45adf4414b9f5 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Mon, 27 Jul 2026 15:06:50 +0100 Subject: [PATCH 18/26] docs(run-engine): add CK virtual-time A/B benchmark plan and harness A prod-like A/B benchmark for the concurrency-key virtual-time scheduling change. Includes a method doc (hypotheses, scenarios, metrics, results template), a queue-level micro-benchmark that drives RunQueue with the flag off vs on under identical load (reuses the fairness test harness; inert in CI unless CK_BENCH_REDIS_URL is set), and a deployable end-to-end noisy-neighbor trigger project. All numbers are relative (same box, same load) so the scheduler is isolated from absolute throughput. --- .../2026-07-26-ck-vtime-benchmark.md | 253 ++++++++ .../design/benchmarks/e2e-tasks/README.md | 28 + .../design/benchmarks/e2e-tasks/package.json | 19 + .../benchmarks/e2e-tasks/src/collect.ts | 98 ++++ .../benchmarks/e2e-tasks/src/loadgen.ts | 100 ++++ .../e2e-tasks/src/trigger/ckBench.ts | 42 ++ .../benchmarks/e2e-tasks/trigger.config.ts | 11 + .../design/benchmarks/e2e-tasks/tsconfig.json | 13 + .../bench/ckMicroBench.bench.test.ts | 555 ++++++++++++++++++ 9 files changed, 1119 insertions(+) create mode 100644 internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json create mode 100644 internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts diff --git a/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md b/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md new file mode 100644 index 00000000000..72146a88cbc --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md @@ -0,0 +1,253 @@ +# CK virtual-time scheduling: prod-like A/B benchmark plan + +A method for producing defensible A/B numbers for the concurrency-key +virtual-time (SFQ) scheduling change, run on a single prod-shaped box. The two +arms are flag OFF (today's age-ordered CK dequeue) and flag ON (virtual-time +ordering), under identical load. + +## What the change is (grounded in the branch) + +The concurrency-key dequeue used to serve variants of a base queue in head-message +age order. Behind `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` (off by default) it now +orders them by start-time fair queueing (SFQ) virtual time: + +- Each CK variant carries a virtual clock in a parallel `:ckVtime` ZSET; a + monotonic floor lives in `:ckVtimeFloor`. Both sit under the base queue's + `{org}` hash tag, so one atomic Lua script touches all of a queue's state. +- The dequeue runs two passes: pass 1 serves the lowest virtual clocks and + advances each served variant by `quantum / weight` (weight fixed at 1 today); + pass 2 fills any leftover batch slots in today's age order. Pass 2 makes the + new command a strict superset of the old one, so it is work-conserving and can + never serve fewer runs than today. +- Enqueue and nack register a variant into `:ckVtime` at the floor with `NX`, so + a brand-new key is reachable from its first enqueue and cannot be parked behind + a backlog. This is the case a per-key concurrency cap cannot fix: one tenant + sharding work across many keys. +- Flag off is byte-identical: the pre-existing Lua scripts run unchanged and no + vtime keys are created. The behaviour lives only in new command names. + +Tuning knobs (real env var names, all positive integers, re-clamped in the +`RunQueue` constructor): + +| Env var | Default | Meaning | +| --- | --- | --- | +| `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` | off | master flag | +| `RUN_ENGINE_CK_VTIME_QUANTUM` | 1 | virtual-time advance per serve | +| `RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER` | 3 | pass-1 window = `maxCount * this` | +| `RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS` | 86400 | EXPIRE on the vtime keys | + +Stated limitations this benchmark deliberately probes (from +`../../src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md`): bounded tombstone drift on +ack/TTL/DLQ paths; member-name tie-break at equal tags; future-scheduled or +retry-backoff variants occupying pass-1 window slots; and the pass-1 window being +narrower than the live variant cardinality. + +## Validity: the numbers are RELATIVE, not absolute + +The target is one node on nested/slow storage. Absolute throughput here is NOT +prod-scale and must never be reported as such. Every result is a ratio between +flag OFF and flag ON, measured under identical load on the same box in the same +session. That relative signal is what isolates the scheduler. Two guards keep the +arms comparable: + +- Same enqueue set, same timestamps, same step loop / same load generator across + OFF and ON. +- Fresh queue state between arms (the micro arm FLUSHes a dedicated Redis; the + end-to-end arm drains and uses a fresh batch tag), a warmup, and N trials. + +## Two arms + +### Arm 1 (PRIMARY): queue-level micro-benchmark + +Drives the real `RunQueue` directly against a dedicated Redis, comparing OFF vs +ON on identical synthetic load. This isolates the scheduler and is where the +defensible numbers come from. Because the flag is a `RunQueue` constructor +option, one bench process runs both arms in-process: no webapp, no redeploy, no +worker clusters. It reuses the existing fairness harness +(`../../src/run-queue/tests/ckVtimeFairness.test.ts`): same step loop, same +scenario shapes, same conservation checks, plus wall-clock latency, a Redis +op-count, N trials, and file output. + +Harness: `../../src/run-queue/bench/ckMicroBench.bench.test.ts`. It is inert in CI +(only runs when `CK_BENCH_REDIS_URL` is set) and FLUSHes its target Redis between +arms, so it must point only at a dedicated throwaway instance. + +Each step makes one `maxCount = 10` dequeue call, records `(step, key, messageId, +wallMs)` per served message, then acks in-flight messages whose logical hold has +elapsed. Wait per message = the step it was served at (all load is pre-enqueued +at step 0). The logical schedule is deterministic, so step-based metrics are +identical across trials (the harness asserts this); trials exist to stabilise the +wall-clock latency and op-count. + +### Arm 2 (END-TO-END): deployed tasks on the worker clusters + +The realism check on top of arm 1. A deployed task on a shared base queue with +per-run concurrency keys, driven by a noisy-neighbor load generator: tenant A +floods across many keys, tenant B sends a few. Each run carries a per-run region +so the load also spreads across the three managed worker groups +(`trigger-regiona/b/c`), exercising multi-cluster placement. Latency is read back +per run as `startedAt - createdAt`. + +Project: `e2e-tasks/` (deployable, secret-free). Contention is forced by pinning +the environment concurrency ceiling low (so many keys contend for a few slots and +the CK dequeue order decides who starts first); the per-key lane width is 1 in the +task config for reproducibility. + +Because the flag is server-side here, arm 2 is a manual OFF-then-ON: set the flag, +redeploy the control plane, run the load, collect; flip the flag, redeploy, run +the load again, collect. The exact toggle + redeploy belongs to the operator +runbook. + +## Hypotheses (tied to the change) + +1. **Bounded wait behind a backlog.** A light key arriving behind a big backlog + waits O(number of active keys) under vtime, versus O(backlog size) under the + baseline (which drains the backlog first). Micro: `ckSkew`, `ckTrickle` + victim wait p95/p99 drops sharply ON vs OFF. E2E: tenant B start-latency p95 + stays bounded as tenant A's backlog grows. +2. **Sharding across many keys cannot starve others.** A tenant fanning out over + many concurrency keys (the case a per-key cap cannot fix) does not starve a + light key, because the light key registers at the floor and is reachable in + pass 1. Micro: `ckSybil` victim first-serve step is small ON (near-immediate) + and its wait ratio drops; `ckManyKeys` shows no permanent starvation even + when cardinality exceeds the pass-1 window. E2E: tenant B (few keys) is not + starved by tenant A's many-key flood. +3. **Work conservation.** A lone backlogged tenant still drains at full rate; + the fair order adds no idle time when nothing else contends. Micro: + `ckHeavyIdle` drain step ON equals OFF exactly. No-harm corollary: the + symmetric `ckBalanced` case is not made worse. + +## Scenarios + +### Arm 1 (micro), all ported from the fairness-spike shapes + +| scenario | shape | env limit | hold | probes | +| --- | --- | --- | --- | --- | +| `ckSkew` | heavy 120 backlog + 4 light x 10 | 1 | 3 | starvation (H1) | +| `ckTrickle` | bulk 120 + 2 trickle x 15 | 1 | 3 | starvation (H1) | +| `ckSybil` | 20 attacker x 8 + 1 light x 10 | 25 | 3 | sharding/sybil (H2) | +| `ckManyKeys` | 60 attacker x 8 (tied head) + 1 light x 10 | 25 | 3 | window limitation, no permanent starvation (H2) | +| `ckBalanced` | 4 symmetric x 25 | 4 | 3 | no-harm (H3) | +| `ckHeavyIdle` | 1 key x 60 | 25 | 3 | work conservation (H3) | + +### Arm 2 (end-to-end), noisy-neighbor + +- Tenant A: `A_KEYS` (default 40) keys x `A_PER_KEY` (default 5) runs = the flood. +- Tenant B: `B_KEYS` (default 2) keys x `B_PER_KEY` (default 5) runs = the victim. +- Per-run hold `HOLD_MS` (default 1500). Runs round-robined across the three + regions. Environment concurrency ceiling pinned low (e.g. 5) so the keys + actually contend. +- Optional placement variant: pin tenant A to one region and tenant B to another + to separate scheduler effects from cross-cluster effects. + +## Metrics and collection + +| metric | what it shows | source | +| --- | --- | --- | +| victim wait p50/p95/p99 | starvation relief | micro: serve step; e2e: `startedAt - createdAt` per tenant | +| victim first-serve (starvation bound) | reachability at the floor | micro: first serve step for the victim key | +| drain step / total served | work conservation, no loss/dup | micro: last serve step + unique messageId count | +| Jain's fairness index | share fairness during contention | micro: over per-key contention-window serves | +| dequeue call p95 (ms) | scheduler op cost (relative) | micro: wall-clock around each dequeue call | +| redis ops (dequeue+ack) | per-dequeue overhead | micro: `CONFIG RESETSTAT` then `INFO commandstats` | + +Jain's index over per-key served counts `x_i`: `J = (sum x_i)^2 / (n * sum +x_i^2)`. 1.0 is perfectly fair; `1/n` means one key took everything. + +The micro harness writes `ck-micro-results.json` and `ck-micro-results.md` +(the results table below) to `CK_BENCH_OUT`. + +For the end-to-end arm, the primary source is the Runs API by tag +(`startedAt - createdAt`), which `collect.ts` reads. Two alternatives give a +tighter dequeue-only timestamp if the API delta looks noisy: + +- TRQL `runs` (verify column names with the query schema first): per-run + `createdAt` and `startedAt`, filtered by the batch/arm tag. +- The run-engine Postgres `TaskRun` timestamps directly (createdAt and the first + attempt/started timestamp), if API round-trips add too much jitter. + +## Reproducible A/B procedure + +### Arm 1 (micro) + +1. Stand up a dedicated throwaway Redis reachable from the harness host (a local + forward is fine). Nothing else may use it. +2. From the run-engine package: + + ```bash + CK_BENCH_REDIS_URL=redis://127.0.0.1:6399 \ + CK_BENCH_TRIALS=5 CK_BENCH_OUT=./bench-results \ + pnpm exec vitest run src/run-queue/bench/ckMicroBench.bench.test.ts + ``` + + Both arms (OFF, then ON) run in one process per scenario, FLUSHing between + arms. Knob sweep: add `CK_BENCH_QUANTUM` / `CK_BENCH_WINDOW_MULT`. Scenario + subset: `CK_BENCH_SCENARIOS=ckSybil,ckSkew`. +3. Read `bench-results/ck-micro-results.md`. The harness fails the run if either + arm loses or double-serves a message, so a green run means the comparison is + sound. + +### Arm 2 (end-to-end) + +1. Deploy `e2e-tasks/` to the bench project's PROD environment (dev + short-circuits worker-group routing, so it must be prod). Pin the prod env + concurrency ceiling low. +2. Warmup: trigger a handful of runs, confirm they start on each region, discard. +3. **Arm OFF:** ensure the flag is off and the control plane is redeployed; then + `ARM=off BATCH= pnpm loadgen`, wait for drain, `ARM=off BATCH= pnpm + collect`. +4. **Arm ON:** flip the flag on, redeploy the control plane, drain/clear queue + state; then `ARM=on BATCH= pnpm loadgen`, wait for drain, `ARM=on + BATCH= pnpm collect`. +5. Repeat both arms N times with fresh batch ids; compare per-tenant latency. + +The exact deploy, flag-toggle, and secret-key retrieval steps for the specific +box are in the operator runbook (kept out of this repo). + +## Results template (paste into the PR) + +Fill from `ck-micro-results.md` (arm 1) and `e2e-summary.md` (arm 2). Keep the +"relative only" caveat in the PR text. + +### Arm 1 (micro), quantum 1 / window x3, N trials, dedicated Redis on the box + +| scenario | metric | baseline (OFF) | vtime (ON) | delta | +| --- | --- | --- | --- | --- | +| **ckSkew** | victim wait p95 (steps) | | | | +| | victim wait p99 | | | | +| | victim first-serve | | | | +| | drain step | | | | +| | dequeue call p95 (ms) | | | | +| **ckTrickle** | victim wait p95 | | | | +| | victim first-serve | | | | +| **ckSybil** | victim wait p95 | | | | +| | victim first-serve | | | | +| | Jain index (contention) | | | | +| **ckManyKeys** | victim first-serve | | | | +| | drain step | | | | +| **ckBalanced** | worst-key wait p95 | | | | +| **ckHeavyIdle** | drain step | | | | +| (all) | redis ops (dequeue+ack) | | | | + +### Arm 2 (end-to-end), noisy-neighbor across regiona/b/c, env cap N + +| tenant | metric | baseline (OFF) | vtime (ON) | delta | +| --- | --- | --- | --- | --- | +| B (victim, few keys) | start latency p50 (ms) | | | | +| B | start latency p95 | | | | +| B | start latency p99 | | | | +| A (flood, many keys) | start latency p95 | | | | + +Expected direction: B's p95/p99 drop substantially ON; A's is similar or slightly +higher ON (it stops jumping the queue); `ckHeavyIdle` drain step is exactly equal; +`ckBalanced` worst-key wait is within noise. + +## How to reproduce on the box (short) + +1. Dedicated Redis up, forwarded locally. Run the arm-1 vitest command above; + collect `ck-micro-results.md`. +2. Deploy `e2e-tasks/` to prod, pin the env concurrency ceiling, warm up. +3. Flag OFF: redeploy control plane, loadgen + collect. Flag ON: redeploy, + loadgen + collect. N trials. +4. Paste both tables into the PR under a "relative numbers on a single + prod-shaped box" heading. diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md b/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md new file mode 100644 index 00000000000..b1c1020225a --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md @@ -0,0 +1,28 @@ +# CK virtual-time end-to-end bench tasks + +A tiny self-contained trigger.dev project used as the END-TO-END arm of the CK +virtual-time A/B (see `../2026-07-26-ck-vtime-benchmark.md`). It is deployed to a +self-hosted instance and is not part of the monorepo build. + +- `src/trigger/ckBench.ts` — one `ck-bench` task on a shared base queue; per-run + `concurrencyKey` makes the CK variants; a slot hold forces contention. +- `src/loadgen.ts` — noisy-neighbor load: tenant A floods across many keys, + tenant B sends a few; each run is tagged and carries a per-run `region`. +- `src/collect.ts` — reads per-run `createdAt`/`startedAt` by tag and reports + per-tenant enqueue->start latency p50/p95/p99. + +Everything reads credentials from the environment (`TRIGGER_API_URL`, +`TRIGGER_SECRET_KEY`); nothing is hard-coded. Deploy with the CLI (`-p `). +The feature flag is server-side and is flipped by the operator between arms, not +by this project. Exact instance coordinates and the toggle live in the operator +runbook, kept outside this public repo. + +```bash +pnpm install +# deploy (project ref on the CLI) +TRIGGER_PROJECT_REF= npx trigger.dev@latest deploy --self-hosted --profile -p +# generate load for one arm (flag already set + redeployed server-side) +ARM=off BATCH=run1 pnpm loadgen +# collect that arm +ARM=off BATCH=run1 pnpm collect +``` diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json b/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json new file mode 100644 index 00000000000..cc194bfbd69 --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json @@ -0,0 +1,19 @@ +{ + "name": "ck-vtime-e2e-bench", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "End-to-end noisy-neighbor load harness for the CK virtual-time scheduling A/B. Deployed to a self-hosted trigger.dev instance; not part of the monorepo build.", + "scripts": { + "loadgen": "tsx src/loadgen.ts", + "collect": "tsx src/collect.ts" + }, + "dependencies": { + "@trigger.dev/sdk": "latest" + }, + "devDependencies": { + "trigger.dev": "latest", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } +} diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts new file mode 100644 index 00000000000..f36049d669f --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts @@ -0,0 +1,98 @@ +/** + * Collect per-tenant enqueue->start latency for one END-TO-END arm. + * + * Lists the runs for a batch by tag, reads each run's createdAt (enqueue) and + * startedAt (execution start), and reports per-tenant p50/p95/p99 of + * (startedAt - createdAt). Run once per arm; point --arm/BATCH at the tags the + * loadgen used. + * + * The headline metric is tenant B's start latency: under the baseline it should + * grow with tenant A's backlog (B waits behind the flood); under vtime it should + * stay bounded (B takes its fair turn). Tenant A's latency is reported for + * context and is expected to be similar or slightly higher under vtime. + * + * NOTE ON THE TIMESTAMP: startedAt - createdAt is the honest run-start latency a + * reviewer cares about (queue wait + dequeue + worker pickup). For a tighter + * dequeue-only number, use the Postgres/TRQL alternative in the benchmark doc. + * + * Auth + config (env): + * TRIGGER_API_URL, TRIGGER_SECRET_KEY (prod env secret key) + * ARM=off|on BATCH= OUT=./e2e-results + */ +import { configure, runs } from "@trigger.dev/sdk"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; + +function pct(sorted: number[], p: number): number { + if (sorted.length === 0) return NaN; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[idx]!; +} +function summarize(xs: number[]) { + const s = [...xs].sort((a, b) => a - b); + const mean = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; + return { count: xs.length, mean, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99) }; +} + +async function main() { + const apiURL = process.env.TRIGGER_API_URL; + const accessToken = process.env.TRIGGER_SECRET_KEY; + if (!apiURL || !accessToken) throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY."); + configure({ baseURL: apiURL, accessToken }); + + const arm = (process.env.ARM ?? "off") as "off" | "on"; + const batch = process.env.BATCH; + const outDir = process.env.OUT ?? "./e2e-results"; + if (!batch) throw new Error("Set BATCH= to the loadgen batch id."); + + const waitsByTenant = new Map(); + let total = 0; + let missingStart = 0; + + // Page through every run carrying this batch tag. BATCH is unique per arm + // (e.g. off-1 / on-1), so the single batch tag identifies the arm; filtering + // on one tag avoids any multi-tag AND/OR ambiguity in the runs filter. + for await (const run of runs.list({ tag: `batch:${batch}`, limit: 100 })) { + total++; + const detail = await runs.retrieve(run.id); + const createdAt = detail.createdAt?.getTime(); + const startedAt = detail.startedAt?.getTime(); + if (createdAt === undefined || startedAt === undefined) { + missingStart++; + continue; + } + const tenantTag = (detail.tags ?? []).find((t) => t.startsWith("tenant:")) ?? "tenant:?"; + const tenant = tenantTag.slice("tenant:".length); + const wait = startedAt - createdAt; + (waitsByTenant.get(tenant) ?? waitsByTenant.set(tenant, []).get(tenant)!).push(wait); + } + + const perTenant: Record> = {}; + for (const [tenant, xs] of waitsByTenant) perTenant[tenant] = summarize(xs); + + const report = { arm, batch, total, missingStart, unit: "ms (startedAt - createdAt)", perTenant }; + + mkdirSync(outDir, { recursive: true }); + writeFileSync(`${outDir}/e2e-${batch}-${arm}.json`, JSON.stringify(report, null, 2)); + + // Append a human row per tenant to a shared markdown file so OFF and ON land + // in one table you can eyeball before running the joiner. + const mdPath = `${outDir}/e2e-summary.md`; + const rows = Object.entries(perTenant) + .map( + ([tenant, s]) => + `| ${batch} | ${arm} | ${tenant} | ${s.count} | ${s.mean.toFixed(0)} | ${s.p50.toFixed(0)} | ${s.p95.toFixed(0)} | ${s.p99.toFixed(0)} |` + ) + .join("\n"); + appendFileSync( + mdPath, + `\n\n| batch | arm | tenant | runs | mean ms | p50 | p95 | p99 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows}\n` + ); + + console.log(`[collect] arm=${arm} batch=${batch} runs=${total} missingStart=${missingStart}`); + console.log(JSON.stringify(perTenant, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts new file mode 100644 index 00000000000..0ba8bf75cfe --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts @@ -0,0 +1,100 @@ +/** + * Noisy-neighbor load generator for the CK virtual-time END-TO-END A/B arm. + * + * Triggers the deployed `ck-bench` task on a self-hosted instance. Tenant A + * floods the base queue across MANY concurrency keys (the sharding/sybil case a + * per-key cap cannot fix); tenant B sends a few runs on a couple of keys. Each + * run is tagged so `collect.ts` can measure per-tenant enqueue->start latency, + * and each run carries a per-run `region` so the load spreads across the managed + * worker groups (multi-cluster placement). + * + * This does NOT flip the feature flag: the flag is server-side (see the operator + * runbook). Run this once per arm AFTER the operator has set the flag and + * redeployed the control plane, passing the matching --arm so the tags line up. + * + * Auth (from env): + * TRIGGER_API_URL e.g. https:// + * TRIGGER_SECRET_KEY the PROD environment secret key of the bench project + * + * Config (from env, with defaults): + * ARM=off|on tag only; must match the server flag state + * BATCH= unique per A/B run pair (defaults to a timestamp) + * HOLD_MS=1500 per-run slot hold + * A_KEYS=40 A_PER_KEY=5 tenant A flood shape + * B_KEYS=2 B_PER_KEY=5 tenant B light shape + * REGIONS=trigger-regiona,trigger-regionb,trigger-regionc + * runs are round-robined across these worker groups + */ +import { configure, tasks } from "@trigger.dev/sdk"; +import type { ckBenchTask } from "./trigger/ckBench.js"; + +function envInt(name: string, dflt: number): number { + const v = process.env[name]; + return v === undefined ? dflt : Number(v); +} + +async function main() { + const apiURL = process.env.TRIGGER_API_URL; + const accessToken = process.env.TRIGGER_SECRET_KEY; + if (!apiURL || !accessToken) { + throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY (prod env secret key)."); + } + configure({ baseURL: apiURL, accessToken }); + + const arm = (process.env.ARM ?? "off") as "off" | "on"; + const batch = process.env.BATCH ?? `b${Date.now()}`; + const holdMs = envInt("HOLD_MS", 1500); + const aKeys = envInt("A_KEYS", 40); + const aPerKey = envInt("A_PER_KEY", 5); + const bKeys = envInt("B_KEYS", 2); + const bPerKey = envInt("B_PER_KEY", 5); + const regions = (process.env.REGIONS ?? "trigger-regiona,trigger-regionb,trigger-regionc") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + type Item = { + payload: { holdMs: number; tenant: string; key: string; arm: "off" | "on"; batch: string }; + options: { concurrencyKey: string; region: string; tags: string[] }; + }; + + const items: Item[] = []; + let n = 0; + const push = (tenant: "A" | "B", key: string) => { + const region = regions[n % regions.length]!; + n++; + items.push({ + payload: { holdMs, tenant, key, arm, batch }, + options: { + concurrencyKey: key, + region, + tags: [`ckbench`, `arm:${arm}`, `tenant:${tenant}`, `batch:${batch}`], + }, + }); + }; + + for (let k = 0; k < aKeys; k++) for (let i = 0; i < aPerKey; i++) push("A", `A-${k}`); + for (let k = 0; k < bKeys; k++) for (let i = 0; i < bPerKey; i++) push("B", `B-${k}`); + + // Interleave A and B enqueues so B does not simply arrive first; the point is + // whether B's few runs start promptly WHILE A's flood is queued. + items.sort((x, y) => x.payload.key.localeCompare(y.payload.key)); + + console.log( + `[loadgen] arm=${arm} batch=${batch} total=${items.length} (A=${aKeys}x${aPerKey}, B=${bKeys}x${bPerKey}) regions=${regions.join(",")} holdMs=${holdMs}` + ); + + const handle = await tasks.batchTrigger( + "ck-bench", + items.map((it) => ({ payload: it.payload, options: it.options })) + ); + + console.log( + `[loadgen] batch triggered: ${handle.batchId} (${items.length} runs). BATCH=${batch}` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts new file mode 100644 index 00000000000..397061dfdcb --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts @@ -0,0 +1,42 @@ +import { logger, queue, task } from "@trigger.dev/sdk"; + +// One shared base queue. Per-run concurrencyKey (set at trigger time) creates the +// concurrency-key variants whose dequeue ORDER the change under test governs. +// +// concurrencyLimit is the PER-KEY lane width. Set it to 1 so each key holds one +// slot at a time; cross-key contention is then forced by the ENVIRONMENT +// concurrency ceiling (pin RuntimeEnvironment.maximumConcurrencyLimit low, e.g. +// 5, on the prod env of the bench project). With N keys all wanting to run and +// only a few env slots, the CK dequeue decides who starts first: that ordering +// is exactly OFF (age) vs ON (virtual time). +export const ckBenchQueue = queue({ + name: "ck-bench", + concurrencyLimit: 1, +}); + +export type CkBenchPayload = { + // logical hold: how long the run occupies its slot, in ms + holdMs: number; + // carried through for grouping in analysis (also set as a tag by the loadgen) + tenant: string; + key: string; + arm: "off" | "on"; + batch: string; +}; + +export const ckBenchTask = task({ + id: "ck-bench", + queue: ckBenchQueue, + run: async (payload: CkBenchPayload) => { + logger.info("ck-bench start", { + tenant: payload.tenant, + key: payload.key, + arm: payload.arm, + batch: payload.batch, + }); + // Occupy the slot for the hold so concurrency actually contends. A plain + // timer is enough: this task exists only to hold a slot, not to do work. + await new Promise((resolve) => setTimeout(resolve, payload.holdMs)); + return { tenant: payload.tenant, key: payload.key, arm: payload.arm }; + }, +}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts new file mode 100644 index 00000000000..36f3572ee09 --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@trigger.dev/sdk"; + +// The project ref is passed on the CLI (`-p `) at deploy time, so it is not +// hard-coded here. This keeps the harness portable and secret-free in the repo. +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF ?? "proj_REPLACE_ME", + runtime: "node", + logLevel: "info", + maxDuration: 120, + dirs: ["./src/trigger"], +}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json b/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json new file mode 100644 index 00000000000..282634969b0 --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*.ts", "trigger.config.ts"] +} diff --git a/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts b/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts new file mode 100644 index 00000000000..88051f71ae0 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts @@ -0,0 +1,555 @@ +import { createRedisClient } from "@internal/redis"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { Decimal } from "@trigger.dev/database"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import { describe, expect, it } from "vitest"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import type { InputPayload } from "../types.js"; + +// CK virtual-time PRIMARY arm: a queue-level A/B micro-benchmark. +// +// This drives the REAL RunQueue against an EXTERNAL Redis (not a testcontainer) +// and compares flag OFF (age-ordered CK dequeue) vs flag ON (SFQ virtual time) +// under identical load. The flag is a RunQueue constructor option, so one bench +// process runs both arms in-process: no webapp, no redeploy. +// +// It is a deliberate, defensible A/B: the two arms enqueue the exact same +// messages with the exact same timestamps and drive the exact same step loop. +// The isolation and reuse are inherited from tests/ckVtimeFairness.test.ts (the +// step loop, scenario shapes, conservation checks are the same); this file adds +// wall-clock dequeue latency, a Redis op-count, N trials, and file output. +// +// It is INERT in CI: the suite only runs when CK_BENCH_REDIS_URL is set, so +// `pnpm run test` collects it as a skipped describe and never touches a network. +// +// Run it (from the run-engine package, pointed at a dedicated throwaway Redis): +// CK_BENCH_REDIS_URL=redis://127.0.0.1:6399 \ +// CK_BENCH_TRIALS=5 CK_BENCH_OUT=./bench-results \ +// pnpm exec vitest run src/run-queue/bench/ckMicroBench.bench.test.ts +// +// Knob sweep (optional, defaults match production defaults 1 / 3): +// CK_BENCH_QUANTUM=1 CK_BENCH_WINDOW_MULT=3 +// +// WARNING: the bench FLUSHDBs the target Redis between arms. Point it ONLY at a +// dedicated throwaway instance, never at a shared or production Redis. + +const REDIS_URL = process.env.CK_BENCH_REDIS_URL; +const TRIALS = Math.max(1, Number(process.env.CK_BENCH_TRIALS ?? "5")); +const OUT_DIR = process.env.CK_BENCH_OUT ?? "./bench-results"; +const QUANTUM = Math.max(1, Number(process.env.CK_BENCH_QUANTUM ?? "1")); +const WINDOW_MULT = Math.max(1, Number(process.env.CK_BENCH_WINDOW_MULT ?? "3")); +const SCENARIO_FILTER = process.env.CK_BENCH_SCENARIOS?.split(",").map((s) => s.trim()); + +const keys = new RunQueueFullKeyProducer(); + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "error"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys, +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +function redisConn() { + const u = new URL(REDIS_URL!); + return { host: u.hostname, port: Number(u.port || "6379") }; +} + +function createQueue(keyPrefix: string, vtimeEnabled: boolean) { + const conn = redisConn(); + return new RunQueue({ + ...testOptions, + masterQueueConsumersDisabled: true, + workerOptions: { disabled: true }, + ckVirtualTimeScheduling: { + enabled: vtimeEnabled, + quantum: QUANTUM, + scanWindowMultiplier: WINDOW_MULT, + }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { keyPrefix, host: conn.host, port: conn.port }, + keys, + }), + redis: { keyPrefix, host: conn.host, port: conn.port }, + }); +} + +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; + // Human label for the "victim" the fairness hypothesis is about. + victimLabel: string; + // Classifies a concurrency key as the victim (the light/starved tenant). + isVictim: (ck: string) => boolean; + messages: ScenarioMessage[]; + envConcurrencyLimit: number; + holdSteps: number; + maxSteps: number; +}; + +type ServeRecord = { step: number; ck: string; messageId: string; wallMs: number }; + +type ArmResult = { + serves: ServeRecord[]; + drainStep: number; + contentionByCk: Map; + contentionTotal: number; + callLatenciesMs: number[]; + redisCalls: number; +}; + +// ---- scenario shapes (ported values from the fairness spike, same as the +// tests/ckVtimeFairness.test.ts scenarios; nothing imported from the spike) ---- + +function buildScenarios(): Scenario[] { + const t0 = Date.now() - 500_000; + const all: Scenario[] = []; + + // ckSkew (starvation): heavy 120-msg backlog on an old shared head, 4 light + // keys x 10 on later heads. Serialized contention (env limit 1) is where the + // baseline's age order starves the light keys. + { + 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, + }); + all.push({ + name: "ckSkew", + victimLabel: "light keys", + isVictim: (ck) => ck.startsWith("light"), + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }); + } + + // ckTrickle (starvation): bulk 120 + 2 trickle keys x 15. + { + 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, + }); + all.push({ + name: "ckTrickle", + victimLabel: "trickle keys", + isVictim: (ck) => ck.startsWith("trickle"), + messages, + envConcurrencyLimit: 1, + holdSteps: 3, + maxSteps: 1_000, + }); + } + + // ckSybil (noisy-neighbor caps cannot fix): 20 attacker keys x 8 (older + // heads) + 1 light key x 10 (newer). 21 variants against a batch of 10. + { + 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 }); + all.push({ + name: "ckSybil", + victimLabel: "light key", + isVictim: (ck) => ck === "light", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 300, + }); + } + + // ckManyKeys (cardinality ABOVE the pass-1 window): 60 attacker keys x 8 on a + // tied old head + 1 light key x 10. Probes the stated window limitation: the + // light key must still drain (no permanent starvation), even though 61 + // variants exceed the 30-wide pass-1 window. + { + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 8; i++) + for (let k = 0; k < 60; k++) { + const ck = `att${String(k).padStart(2, "0")}`; + 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 }); + all.push({ + name: "ckManyKeys", + victimLabel: "light key", + isVictim: (ck) => ck === "light", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 1_000, + }); + } + + // ckBalanced (no-harm mixed multi-tenant): 4 symmetric keys x 25. + { + 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 }); + all.push({ + name: "ckBalanced", + victimLabel: "worst symmetric key", + isVictim: (ck) => ck.startsWith("bal"), + messages, + envConcurrencyLimit: 4, + holdSteps: 3, + maxSteps: 500, + }); + } + + // ckHeavyIdle (work conservation): a lone key with 60 msgs, nothing else + // contending. Drain-step ON must equal OFF exactly. + { + const messages: ScenarioMessage[] = []; + for (let i = 0; i < 60; i++) + messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i }); + all.push({ + name: "ckHeavyIdle", + victimLabel: "lone key", + isVictim: (ck) => ck === "solo", + messages, + envConcurrencyLimit: 25, + holdSteps: 3, + maxSteps: 300, + }); + } + + return SCENARIO_FILTER ? all.filter((s) => SCENARIO_FILTER.includes(s.name)) : all; +} + +// ---- one arm of one scenario ---- + +async function runArm( + scenario: Scenario, + vtimeEnabled: boolean, + trial: number +): Promise { + const keyPrefix = `ckbench:${scenario.name}:${vtimeEnabled ? "on" : "off"}:t${trial}:`; + const queue = createQueue(keyPrefix, vtimeEnabled); + const conn = redisConn(); + const admin = createRedisClient({ host: conn.host, port: conn.port }, { onError: () => {} }); + + 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, + }); + } + + // Count only steady-state (dequeue + ack) Redis ops, not enqueue. + await admin.call("CONFIG", "RESETSTAT"); + + const shard = 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 contentionByCk = new Map(); + let contentionTotal = 0; + let drainStep = -1; + const callLatenciesMs: number[] = []; + const armStart = performance.now(); + + for (let step = 0; step < scenario.maxSteps && serves.length < total; step++) { + let keysWithBacklog = 0; + for (const count of remaining.values()) if (count > 0) keysWithBacklog++; + + const before = performance.now(); + const messages = await queue.testDequeueFromMasterQueue(shard, env.id, 10); + callLatenciesMs.push(performance.now() - before); + + for (const m of messages) { + const ck = m.message.concurrencyKey ?? ""; + serves.push({ step, ck, messageId: m.messageId, wallMs: performance.now() - armStart }); + remaining.set(ck, (remaining.get(ck) ?? 0) - 1); + inFlight.push({ messageId: m.messageId, servedAtStep: step }); + if (keysWithBacklog >= 2) { + contentionTotal++; + contentionByCk.set(ck, (contentionByCk.get(ck) ?? 0) + 1); + } + if (serves.length === total) drainStep = step; + } + + for (let i = inFlight.length - 1; i >= 0; i--) { + const entry = inFlight[i]!; + if (entry.servedAtStep + scenario.holdSteps <= step) { + await queue.acknowledgeMessage(env.organization.id, entry.messageId, { + skipDequeueProcessing: true, + }); + inFlight.splice(i, 1); + } + } + } + + const stats = await admin.call("INFO", "commandstats"); + const redisCalls = sumRedisCalls(String(stats)); + + return { serves, drainStep, contentionByCk, contentionTotal, callLatenciesMs, redisCalls }; + } finally { + await admin.quit().catch(() => {}); + await queue.quit(); + // Clean slate for the next arm: this is a dedicated throwaway Redis. + const admin2 = createRedisClient(redisConn(), { onError: () => {} }); + await admin2.flushdb().catch(() => {}); + await admin2.quit().catch(() => {}); + } +} + +// ---- metrics ---- + +function sumRedisCalls(info: string): number { + // lines look like: cmdstat_zadd:calls=123,usec=...,... + let total = 0; + for (const line of info.split("\n")) { + const m = line.match(/cmdstat_[^:]+:calls=(\d+)/); + if (m) total += Number(m[1]); + } + return total; +} + +function pct(sorted: number[], p: number): number { + if (sorted.length === 0) return NaN; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[idx]!; +} + +function stats(xs: number[]) { + const s = [...xs].sort((a, b) => a - b); + const mean = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; + return { mean, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99) }; +} + +// Jain's fairness index over per-key served counts during contention windows. +// 1.0 = perfectly fair; 1/n = one key took everything. +function jain(counts: number[]): number { + const nonzero = counts.filter((c) => c > 0); + if (nonzero.length === 0) return NaN; + const sum = nonzero.reduce((a, b) => a + b, 0); + const sumSq = nonzero.reduce((a, b) => a + b * b, 0); + return (sum * sum) / (nonzero.length * sumSq); +} + +function victimWaits(arm: ArmResult, s: Scenario): number[] { + return arm.serves.filter((r) => s.isVictim(r.ck)).map((r) => r.step); +} + +function firstServe(arm: ArmResult, s: Scenario): number { + const first = arm.serves.find((r) => s.isVictim(r.ck)); + return first ? first.step : -1; +} + +// ---- the bench ---- + +describe.runIf(!!REDIS_URL)("CK virtual-time micro-benchmark (A/B, external Redis)", () => { + it("runs OFF vs ON across scenarios and writes results", { timeout: 30 * 60_000 }, async () => { + const scenarios = buildScenarios(); + const report: any = { + generatedAtMs: Date.now(), + redisUrl: REDIS_URL, + trials: TRIALS, + knobs: { quantum: QUANTUM, scanWindowMultiplier: WINDOW_MULT }, + scenarios: [] as any[], + }; + + for (const s of scenarios) { + // Wall-clock latency and op-count are pooled/aggregated across trials. + // Step-based metrics are deterministic, so trial 0 is authoritative and + // later trials only assert determinism. + const offCalls: number[] = []; + const onCalls: number[] = []; + const offOps: number[] = []; + const onOps: number[] = []; + let off0: ArmResult | null = null; + let on0: ArmResult | null = null; + + for (let t = 0; t < TRIALS; t++) { + const off = await runArm(s, false, t); + const on = await runArm(s, true, t); + + // Correctness gate: identical load must serve every message exactly + // once in BOTH arms, else the comparison is meaningless. + expect(off.serves.length, `${s.name} OFF served != enqueued`).toBe(s.messages.length); + expect(on.serves.length, `${s.name} ON served != enqueued`).toBe(s.messages.length); + expect(new Set(off.serves.map((r) => r.messageId)).size).toBe(s.messages.length); + expect(new Set(on.serves.map((r) => r.messageId)).size).toBe(s.messages.length); + + offCalls.push(...off.callLatenciesMs); + onCalls.push(...on.callLatenciesMs); + offOps.push(off.redisCalls); + onOps.push(on.redisCalls); + + if (t === 0) { + off0 = off; + on0 = on; + } else { + // determinism of the logical schedule across trials + expect(firstServe(off, s), `${s.name} OFF first-serve not deterministic`).toBe( + firstServe(off0!, s) + ); + expect(firstServe(on, s), `${s.name} ON first-serve not deterministic`).toBe( + firstServe(on0!, s) + ); + expect(on.drainStep).toBe(on0!.drainStep); + } + } + + const offWait = stats(victimWaits(off0!, s)); + const onWait = stats(victimWaits(on0!, s)); + const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; + + const scenarioReport = { + name: s.name, + victim: s.victimLabel, + config: { + envConcurrencyLimit: s.envConcurrencyLimit, + holdSteps: s.holdSteps, + variants: new Set(s.messages.map((m) => m.ck)).size, + messages: s.messages.length, + }, + off: { + victimWait: offWait, + victimFirstServe: firstServe(off0!, s), + drainStep: off0!.drainStep, + jain: jain([...off0!.contentionByCk.values()]), + callLatencyMs: stats(offCalls), + redisOpsMedian: median(offOps), + }, + on: { + victimWait: onWait, + victimFirstServe: firstServe(on0!, s), + drainStep: on0!.drainStep, + jain: jain([...on0!.contentionByCk.values()]), + callLatencyMs: stats(onCalls), + redisOpsMedian: median(onOps), + }, + }; + report.scenarios.push(scenarioReport); + // eslint-disable-next-line no-console + console.log( + `[ckbench] ${s.name}: victim p95 wait OFF=${offWait.p95} ON=${onWait.p95} | drain OFF=${off0!.drainStep} ON=${on0!.drainStep}` + ); + } + + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(`${OUT_DIR}/ck-micro-results.json`, JSON.stringify(report, null, 2)); + writeFileSync(`${OUT_DIR}/ck-micro-results.md`, renderMarkdown(report)); + }); +}); + +function fmt(n: number): string { + if (Number.isNaN(n)) return "n/a"; + return Number.isInteger(n) ? String(n) : n.toFixed(2); +} +function delta(off: number, on: number): string { + if (Number.isNaN(off) || Number.isNaN(on)) return "n/a"; + if (off === 0) return on === 0 ? "0" : "+inf"; + const pctChange = ((on - off) / off) * 100; + return `${pctChange >= 0 ? "+" : ""}${pctChange.toFixed(0)}%`; +} + +function renderMarkdown(report: any): string { + const lines: string[] = []; + lines.push(`# CK virtual-time micro-benchmark results`); + lines.push(""); + lines.push( + `Redis \`${report.redisUrl}\`, ${report.trials} trial(s), quantum ${report.knobs.quantum}, window multiplier ${report.knobs.scanWindowMultiplier}.` + ); + lines.push(""); + lines.push( + `Numbers are RELATIVE (same box, same load, flag OFF vs ON). Wait is in logical dequeue steps. Latency is wall-clock per dequeue call on this box and is NOT prod-scale absolute throughput.` + ); + lines.push(""); + lines.push(`| scenario | metric | baseline (OFF) | vtime (ON) | delta |`); + lines.push(`| --- | --- | --- | --- | --- |`); + for (const s of report.scenarios) { + const rows: [string, number, number][] = [ + [`victim wait p50 (${s.victim})`, s.off.victimWait.p50, s.on.victimWait.p50], + [`victim wait p95`, s.off.victimWait.p95, s.on.victimWait.p95], + [`victim wait p99`, s.off.victimWait.p99, s.on.victimWait.p99], + [`victim first-serve step (starvation bound)`, s.off.victimFirstServe, s.on.victimFirstServe], + [`drain step (work conservation)`, s.off.drainStep, s.on.drainStep], + [`Jain fairness index (contention)`, s.off.jain, s.on.jain], + [`dequeue call p95 (ms)`, s.off.callLatencyMs.p95, s.on.callLatencyMs.p95], + [`redis ops (dequeue+ack)`, s.off.redisOpsMedian, s.on.redisOpsMedian], + ]; + rows.forEach(([metric, off, on], i) => { + lines.push( + `| ${i === 0 ? `**${s.name}**` : ""} | ${metric} | ${fmt(off)} | ${fmt(on)} | ${delta(off, on)} |` + ); + }); + } + lines.push(""); + return lines.join("\n"); +} From 5365d7dff392dc7cdf15e6d12b189e30c35fdb5f Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Mon, 27 Jul 2026 18:26:53 +0100 Subject: [PATCH 19/26] docs(run-engine): make the e2e bench harness work on self-hosted Enumerate runs from a trigger-time id manifest instead of runs.list (the list API can return empty on self-hosted, where it is ClickHouse-backed), add waitdrain and preflight helpers, pin the CLI and SDK to a matching version, and document the deploy invocation that actually works on self-hosted: no --self-hosted or --local-build, and --network host so the in-build indexer step can reach the instance API. --- .../design/benchmarks/e2e-tasks/README.md | 72 +++++++++-- .../design/benchmarks/e2e-tasks/package.json | 4 +- .../benchmarks/e2e-tasks/src/collect.ts | 106 ++++++++------- .../benchmarks/e2e-tasks/src/loadgen.ts | 121 +++++++++--------- .../benchmarks/e2e-tasks/src/preflight.ts | 59 +++++++++ .../benchmarks/e2e-tasks/src/waitdrain.ts | 68 ++++++++++ 6 files changed, 299 insertions(+), 131 deletions(-) create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md b/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md index b1c1020225a..afa3cfc70fa 100644 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md @@ -1,28 +1,72 @@ # CK virtual-time end-to-end bench tasks A tiny self-contained trigger.dev project used as the END-TO-END arm of the CK -virtual-time A/B (see `../2026-07-26-ck-vtime-benchmark.md`). It is deployed to a +virtual-time A/B (see `../2026-07-26-ck-vtime-benchmark.md`). It deploys to a self-hosted instance and is not part of the monorepo build. - `src/trigger/ckBench.ts` — one `ck-bench` task on a shared base queue; per-run `concurrencyKey` makes the CK variants; a slot hold forces contention. - `src/loadgen.ts` — noisy-neighbor load: tenant A floods across many keys, - tenant B sends a few; each run is tagged and carries a per-run `region`. -- `src/collect.ts` — reads per-run `createdAt`/`startedAt` by tag and reports + tenant B sends a few; each run carries a per-run `region`. Captures every run + id at trigger time and writes a manifest (`e2e-results/manifest-.json`). +- `src/waitdrain.ts` — polls the manifest's run ids until all are terminal. +- `src/collect.ts` — reads each run's `createdAt`/`startedAt` by id and reports per-tenant enqueue->start latency p50/p95/p99. +- `src/preflight.ts` — fires one run per region to validate routing + access. Everything reads credentials from the environment (`TRIGGER_API_URL`, -`TRIGGER_SECRET_KEY`); nothing is hard-coded. Deploy with the CLI (`-p `). -The feature flag is server-side and is flipped by the operator between arms, not -by this project. Exact instance coordinates and the toggle live in the operator -runbook, kept outside this public repo. +`TRIGGER_SECRET_KEY`); nothing is hard-coded. The feature flag is server-side and +is flipped by the operator between arms, not by this project. Exact instance +coordinates and the toggle live in the operator runbook, kept outside this repo. + +## Deploy from a standalone checkout, not inside the monorepo + +This project must be installed and deployed from OUTSIDE the pnpm monorepo tree +(copy it somewhere with no `pnpm-workspace.yaml` ancestor). Inside the workspace, +`pnpm install` binds to the workspace and links the local SDK instead of the +pinned published one. Use npm for the standalone copy (it avoids pnpm's +build-script policy on esbuild): + +```bash +cp -r ~/ck-vtime-e2e && cd ~/ck-vtime-e2e +npm install # CLI + SDK pinned to the same version (4.5.7) +``` + +## Deploy + +The 4.5.7 CLI has no `--self-hosted` flag; self-hosted is implicit from the +profile's API URL. Do NOT pass `--local-build` (it routes to a cloud-only ECR +credential endpoint and fails on self-hosted). The push then rides the host's +existing docker login to the registry. `--network host` is required so the +in-build indexer step can reach the instance API (e.g. over a tailnet): + +```bash +TRIGGER_PROJECT_REF= \ + ./node_modules/.bin/trigger deploy -e prod --network host --profile -p +``` + +Deploy to the `prod` environment: the `dev` environment short-circuits +worker-group routing, so dev runs never reach the managed regions. + +## Run one A/B arm + +The flag is flipped + redeployed server-side by the operator; run this once per +arm with the matching `ARM`. This instance's `runs.list` (ClickHouse-backed) can +be empty, so the harness enumerates runs from the trigger-time id manifest, not +by tag. ```bash -pnpm install -# deploy (project ref on the CLI) -TRIGGER_PROJECT_REF= npx trigger.dev@latest deploy --self-hosted --profile -p -# generate load for one arm (flag already set + redeployed server-side) -ARM=off BATCH=run1 pnpm loadgen -# collect that arm -ARM=off BATCH=run1 pnpm collect +export TRIGGER_API_URL= +export TRIGGER_SECRET_KEY= + +# validate region routing once +./node_modules/.bin/tsx src/preflight.ts + +# one arm (env knobs: HOLD_MS, A_KEYS, A_PER_KEY, B_KEYS, B_PER_KEY, REGIONS) +ARM=off BATCH=off-1 ./node_modules/.bin/tsx src/loadgen.ts +BATCH=off-1 ./node_modules/.bin/tsx src/waitdrain.ts +ARM=off BATCH=off-1 ./node_modules/.bin/tsx src/collect.ts ``` + +Results land in `e2e-results/` (`manifest-.json`, `e2e--.json`, +`e2e-summary.md`). diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json b/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json index cc194bfbd69..63ec78ff547 100644 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json @@ -9,10 +9,10 @@ "collect": "tsx src/collect.ts" }, "dependencies": { - "@trigger.dev/sdk": "latest" + "@trigger.dev/sdk": "4.5.7" }, "devDependencies": { - "trigger.dev": "latest", + "trigger.dev": "4.5.7", "tsx": "^4.19.0", "typescript": "^5.5.0" } diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts index f36049d669f..ed299755d57 100644 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts @@ -1,26 +1,15 @@ /** - * Collect per-tenant enqueue->start latency for one END-TO-END arm. + * Collect per-tenant enqueue->start latency for one END-TO-END arm, from a batch + * manifest (retrieve by id; runs.list is unreliable on this instance). * - * Lists the runs for a batch by tag, reads each run's createdAt (enqueue) and - * startedAt (execution start), and reports per-tenant p50/p95/p99 of - * (startedAt - createdAt). Run once per arm; point --arm/BATCH at the tags the - * loadgen used. + * Metric per run = startedAt - createdAt (run-start latency: queue wait + dequeue + * + worker pickup). Headline is tenant B (few keys): bounded under vtime, grows + * with A's backlog under baseline. * - * The headline metric is tenant B's start latency: under the baseline it should - * grow with tenant A's backlog (B waits behind the flood); under vtime it should - * stay bounded (B takes its fair turn). Tenant A's latency is reported for - * context and is expected to be similar or slightly higher under vtime. - * - * NOTE ON THE TIMESTAMP: startedAt - createdAt is the honest run-start latency a - * reviewer cares about (queue wait + dequeue + worker pickup). For a tighter - * dequeue-only number, use the Postgres/TRQL alternative in the benchmark doc. - * - * Auth + config (env): - * TRIGGER_API_URL, TRIGGER_SECRET_KEY (prod env secret key) - * ARM=off|on BATCH= OUT=./e2e-results + * Env: TRIGGER_API_URL, TRIGGER_SECRET_KEY. Args (env): BATCH ARM OUT POLL_CONCURRENCY */ import { configure, runs } from "@trigger.dev/sdk"; -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; function pct(sorted: number[], p: number): number { if (sorted.length === 0) return NaN; @@ -34,65 +23,70 @@ function summarize(xs: number[]) { } async function main() { - const apiURL = process.env.TRIGGER_API_URL; - const accessToken = process.env.TRIGGER_SECRET_KEY; - if (!apiURL || !accessToken) throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY."); - configure({ baseURL: apiURL, accessToken }); - + configure({ + baseURL: process.env.TRIGGER_API_URL!, + accessToken: process.env.TRIGGER_SECRET_KEY!, + }); + const batch = process.env.BATCH!; const arm = (process.env.ARM ?? "off") as "off" | "on"; - const batch = process.env.BATCH; const outDir = process.env.OUT ?? "./e2e-results"; - if (!batch) throw new Error("Set BATCH= to the loadgen batch id."); + const conc = Number(process.env.POLL_CONCURRENCY ?? "20"); + const manifest = JSON.parse(readFileSync(`${outDir}/manifest-${batch}.json`, "utf8")); + const entries: { id: string; tenant: string }[] = manifest.runs; const waitsByTenant = new Map(); - let total = 0; let missingStart = 0; - - // Page through every run carrying this batch tag. BATCH is unique per arm - // (e.g. off-1 / on-1), so the single batch tag identifies the arm; filtering - // on one tag avoids any multi-tag AND/OR ambiguity in the runs filter. - for await (const run of runs.list({ tag: `batch:${batch}`, limit: 100 })) { - total++; - const detail = await runs.retrieve(run.id); - const createdAt = detail.createdAt?.getTime(); - const startedAt = detail.startedAt?.getTime(); - if (createdAt === undefined || startedAt === undefined) { - missingStart++; - continue; + let i = 0; + async function w() { + while (i < entries.length) { + const e = entries[i++]!; + try { + const r = await runs.retrieve(e.id); + const c = r.createdAt?.getTime(); + const s = r.startedAt?.getTime(); + if (c === undefined || s === undefined) { + missingStart++; + continue; + } + const arr = waitsByTenant.get(e.tenant) ?? waitsByTenant.set(e.tenant, []).get(e.tenant)!; + arr.push(s - c); + } catch { + missingStart++; + } } - const tenantTag = (detail.tags ?? []).find((t) => t.startsWith("tenant:")) ?? "tenant:?"; - const tenant = tenantTag.slice("tenant:".length); - const wait = startedAt - createdAt; - (waitsByTenant.get(tenant) ?? waitsByTenant.set(tenant, []).get(tenant)!).push(wait); } + await Promise.all(Array.from({ length: Math.min(conc, entries.length) }, w)); const perTenant: Record> = {}; - for (const [tenant, xs] of waitsByTenant) perTenant[tenant] = summarize(xs); - - const report = { arm, batch, total, missingStart, unit: "ms (startedAt - createdAt)", perTenant }; + for (const [t, xs] of waitsByTenant) perTenant[t] = summarize(xs); + const report = { + arm, + batch, + total: entries.length, + missingStart, + unit: "ms (startedAt - createdAt)", + perTenant, + }; mkdirSync(outDir, { recursive: true }); writeFileSync(`${outDir}/e2e-${batch}-${arm}.json`, JSON.stringify(report, null, 2)); - // Append a human row per tenant to a shared markdown file so OFF and ON land - // in one table you can eyeball before running the joiner. - const mdPath = `${outDir}/e2e-summary.md`; const rows = Object.entries(perTenant) .map( - ([tenant, s]) => - `| ${batch} | ${arm} | ${tenant} | ${s.count} | ${s.mean.toFixed(0)} | ${s.p50.toFixed(0)} | ${s.p95.toFixed(0)} | ${s.p99.toFixed(0)} |` + ([t, s]) => + `| ${batch} | ${arm} | ${t} | ${s.count} | ${s.mean.toFixed(0)} | ${s.p50.toFixed(0)} | ${s.p95.toFixed(0)} | ${s.p99.toFixed(0)} |` ) .join("\n"); appendFileSync( - mdPath, + `${outDir}/e2e-summary.md`, `\n\n| batch | arm | tenant | runs | mean ms | p50 | p95 | p99 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows}\n` ); - - console.log(`[collect] arm=${arm} batch=${batch} runs=${total} missingStart=${missingStart}`); + console.log( + `[collect] arm=${arm} batch=${batch} runs=${entries.length} missingStart=${missingStart}` + ); console.log(JSON.stringify(perTenant, null, 2)); } - -main().catch((err) => { - console.error(err); +main().catch((e) => { + console.error(e); process.exit(1); }); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts index 0ba8bf75cfe..ad7e686b084 100644 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts @@ -1,100 +1,103 @@ /** * Noisy-neighbor load generator for the CK virtual-time END-TO-END A/B arm. * - * Triggers the deployed `ck-bench` task on a self-hosted instance. Tenant A - * floods the base queue across MANY concurrency keys (the sharding/sybil case a - * per-key cap cannot fix); tenant B sends a few runs on a couple of keys. Each - * run is tagged so `collect.ts` can measure per-tenant enqueue->start latency, - * and each run carries a per-run `region` so the load spreads across the managed - * worker groups (multi-cluster placement). + * Tenant A floods the base queue across MANY concurrency keys (the sharding case + * a per-key cap cannot fix); tenant B sends a few runs on a couple of keys. Each + * run carries a per-run `region` so load spreads across the managed worker groups. * - * This does NOT flip the feature flag: the flag is server-side (see the operator - * runbook). Run this once per arm AFTER the operator has set the flag and - * redeployed the control plane, passing the matching --arm so the tags line up. + * This instance's runs.list (ClickHouse-backed) is unreliable, so we capture each + * run id at trigger time via individual tasks.trigger calls (fired with bounded + * concurrency so they still enqueue near-simultaneously as a backlog) and write a + * manifest. collect.ts / waitdrain.ts retrieve by id (the Postgres path). * - * Auth (from env): - * TRIGGER_API_URL e.g. https:// - * TRIGGER_SECRET_KEY the PROD environment secret key of the bench project - * - * Config (from env, with defaults): - * ARM=off|on tag only; must match the server flag state - * BATCH= unique per A/B run pair (defaults to a timestamp) - * HOLD_MS=1500 per-run slot hold - * A_KEYS=40 A_PER_KEY=5 tenant A flood shape - * B_KEYS=2 B_PER_KEY=5 tenant B light shape - * REGIONS=trigger-regiona,trigger-regionb,trigger-regionc - * runs are round-robined across these worker groups + * Auth (env): TRIGGER_API_URL, TRIGGER_SECRET_KEY (prod env secret key). + * Config (env): ARM=off|on BATCH= HOLD_MS A_KEYS A_PER_KEY B_KEYS B_PER_KEY + * REGIONS=trigger-regiona,trigger-regionb,trigger-regionc OUT=./e2e-results + * FIRE_CONCURRENCY=30 */ import { configure, tasks } from "@trigger.dev/sdk"; +import { mkdirSync, writeFileSync } from "node:fs"; import type { ckBenchTask } from "./trigger/ckBench.js"; -function envInt(name: string, dflt: number): number { - const v = process.env[name]; - return v === undefined ? dflt : Number(v); -} +const envInt = (n: string, d: number) => + process.env[n] === undefined ? d : Number(process.env[n]); async function main() { const apiURL = process.env.TRIGGER_API_URL; const accessToken = process.env.TRIGGER_SECRET_KEY; - if (!apiURL || !accessToken) { - throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY (prod env secret key)."); - } + if (!apiURL || !accessToken) throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY."); configure({ baseURL: apiURL, accessToken }); const arm = (process.env.ARM ?? "off") as "off" | "on"; - const batch = process.env.BATCH ?? `b${Date.now()}`; + const batch = process.env.BATCH ?? `b${arm}`; const holdMs = envInt("HOLD_MS", 1500); - const aKeys = envInt("A_KEYS", 40); - const aPerKey = envInt("A_PER_KEY", 5); - const bKeys = envInt("B_KEYS", 2); - const bPerKey = envInt("B_PER_KEY", 5); + const aKeys = envInt("A_KEYS", 30); + const aPerKey = envInt("A_PER_KEY", 8); + const bKeys = envInt("B_KEYS", 3); + const bPerKey = envInt("B_PER_KEY", 10); + const outDir = process.env.OUT ?? "./e2e-results"; + const fireConcurrency = envInt("FIRE_CONCURRENCY", 30); const regions = (process.env.REGIONS ?? "trigger-regiona,trigger-regionb,trigger-regionc") .split(",") .map((s) => s.trim()) .filter(Boolean); - type Item = { - payload: { holdMs: number; tenant: string; key: string; arm: "off" | "on"; batch: string }; - options: { concurrencyKey: string; region: string; tags: string[] }; - }; - + type Item = { tenant: "A" | "B"; key: string; region: string }; const items: Item[] = []; let n = 0; const push = (tenant: "A" | "B", key: string) => { - const region = regions[n % regions.length]!; + items.push({ tenant, key, region: regions[n % regions.length]! }); n++; - items.push({ - payload: { holdMs, tenant, key, arm, batch }, - options: { - concurrencyKey: key, - region, - tags: [`ckbench`, `arm:${arm}`, `tenant:${tenant}`, `batch:${batch}`], - }, - }); }; - for (let k = 0; k < aKeys; k++) for (let i = 0; i < aPerKey; i++) push("A", `A-${k}`); for (let k = 0; k < bKeys; k++) for (let i = 0; i < bPerKey; i++) push("B", `B-${k}`); - - // Interleave A and B enqueues so B does not simply arrive first; the point is - // whether B's few runs start promptly WHILE A's flood is queued. - items.sort((x, y) => x.payload.key.localeCompare(y.payload.key)); + // interleave so B does not all arrive first + items.sort((x, y) => x.key.localeCompare(y.key)); console.log( `[loadgen] arm=${arm} batch=${batch} total=${items.length} (A=${aKeys}x${aPerKey}, B=${bKeys}x${bPerKey}) regions=${regions.join(",")} holdMs=${holdMs}` ); - const handle = await tasks.batchTrigger( - "ck-bench", - items.map((it) => ({ payload: it.payload, options: it.options })) - ); + const manifest: { + batch: string; + arm: string; + triggeredAt: number; + runs: { id: string; tenant: string; key: string; region: string }[]; + } = { batch, arm, triggeredAt: Date.now(), runs: [] }; + + let idx = 0; + let failed = 0; + async function worker() { + while (idx < items.length) { + const it = items[idx++]!; + try { + const h = await tasks.trigger( + "ck-bench", + { holdMs, tenant: it.tenant, key: it.key, arm, batch }, + { + concurrencyKey: it.key, + region: it.region, + tags: ["ckbench", `arm:${arm}`, `tenant:${it.tenant}`, `batch:${batch}`], + } + ); + manifest.runs.push({ id: h.id, tenant: it.tenant, key: it.key, region: it.region }); + } catch (e) { + failed++; + if (failed <= 3) console.error(`[loadgen] trigger failed:`, (e as Error).message); + } + } + } + await Promise.all(Array.from({ length: Math.min(fireConcurrency, items.length) }, worker)); + mkdirSync(outDir, { recursive: true }); + const path = `${outDir}/manifest-${batch}.json`; + writeFileSync(path, JSON.stringify(manifest, null, 2)); console.log( - `[loadgen] batch triggered: ${handle.batchId} (${items.length} runs). BATCH=${batch}` + `[loadgen] triggered ${manifest.runs.length}/${items.length} (failed ${failed}). manifest: ${path}` ); } -main().catch((err) => { - console.error(err); +main().catch((e) => { + console.error(e); process.exit(1); }); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts new file mode 100644 index 00000000000..cc7b1bf8e0d --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts @@ -0,0 +1,59 @@ +/** + * Region preflight: trigger one ck-bench run per worker group and confirm each + * starts (validates region routing + isWorkerGroupAllowedForProject before the + * real load). Reads TRIGGER_API_URL / TRIGGER_SECRET_KEY from env. + */ +import { configure, runs, tasks } from "@trigger.dev/sdk"; +import type { ckBenchTask } from "./trigger/ckBench.js"; + +const regions = ["trigger-regiona", "trigger-regionb", "trigger-regionc"]; + +async function main() { + configure({ + baseURL: process.env.TRIGGER_API_URL!, + accessToken: process.env.TRIGGER_SECRET_KEY!, + }); + + const handles: { region: string; id: string }[] = []; + for (const region of regions) { + const h = await tasks.trigger( + "ck-bench", + { holdMs: 2000, tenant: "preflight", key: `pf-${region}`, arm: "on", batch: "preflight" }, + { concurrencyKey: `pf-${region}`, region, tags: ["ckbench", "preflight", `region:${region}`] } + ); + handles.push({ region, id: h.id }); + console.log(`[preflight] triggered ${region}: ${h.id}`); + } + + // Poll up to ~90s for each to leave the queue and reach a terminal/executing state. + const deadline = Date.now() + 90_000; + const seen = new Map(); + while (Date.now() < deadline && seen.size < handles.length) { + for (const { region, id } of handles) { + if (seen.has(id)) continue; + const r = await runs.retrieve(id); + if (["EXECUTING", "COMPLETED", "FAILED", "CRASHED", "SYSTEM_FAILURE"].includes(r.status)) { + seen.set(id, r.status); + const started = r.startedAt ? r.startedAt.getTime() - r.createdAt.getTime() : undefined; + console.log( + `[preflight] ${region} ${id} -> ${r.status}${started !== undefined ? ` (start wait ${started}ms)` : ""}` + ); + } + } + if (seen.size < handles.length) await new Promise((r) => setTimeout(r, 3000)); + } + + const stuck = handles.filter((h) => !seen.has(h.id)); + if (stuck.length) { + console.log( + `[preflight] STILL QUEUED after 90s (possible access/routing issue): ${stuck.map((s) => `${s.region}:${s.id}`).join(", ")}` + ); + process.exit(2); + } + console.log("[preflight] all regions accepted + started. Routing + access OK."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts new file mode 100644 index 00000000000..38851517ed9 --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts @@ -0,0 +1,68 @@ +/** + * Wait until every run in a batch manifest reaches a terminal state (so startedAt + * is populated), by retrieving each run id (Postgres path; runs.list is unreliable + * on this instance). Env: TRIGGER_API_URL, TRIGGER_SECRET_KEY. + * Args (env): BATCH= OUT=./e2e-results TIMEOUT_S=420 POLL_CONCURRENCY=20 + */ +import { configure, runs } from "@trigger.dev/sdk"; +import { readFileSync } from "node:fs"; + +const TERMINAL = [ + "COMPLETED", + "FAILED", + "CRASHED", + "SYSTEM_FAILURE", + "CANCELED", + "TIMED_OUT", + "EXPIRED", +]; + +async function statusMap(ids: string[], conc: number): Promise> { + const out = new Map(); + let i = 0; + async function w() { + while (i < ids.length) { + const id = ids[i++]!; + try { + const r = await runs.retrieve(id); + out.set(id, r.status); + } catch { + out.set(id, "ERR_RETRIEVE"); + } + } + } + await Promise.all(Array.from({ length: Math.min(conc, ids.length) }, w)); + return out; +} + +async function main() { + configure({ + baseURL: process.env.TRIGGER_API_URL!, + accessToken: process.env.TRIGGER_SECRET_KEY!, + }); + const batch = process.env.BATCH!; + const outDir = process.env.OUT ?? "./e2e-results"; + const timeoutMs = Number(process.env.TIMEOUT_S ?? "420") * 1000; + const conc = Number(process.env.POLL_CONCURRENCY ?? "20"); + const manifest = JSON.parse(readFileSync(`${outDir}/manifest-${batch}.json`, "utf8")); + const ids: string[] = manifest.runs.map((r: any) => r.id); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const sm = await statusMap(ids, conc); + let terminal = 0; + for (const s of sm.values()) if (TERMINAL.includes(s)) terminal++; + console.log(`[waitdrain] ${batch}: terminal ${terminal}/${ids.length}`); + if (terminal >= ids.length) { + console.log("[waitdrain] drained."); + return; + } + await new Promise((r) => setTimeout(r, 5000)); + } + console.log("[waitdrain] TIMEOUT before full drain."); + process.exit(2); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 445765f6110a65f1821f0b7031a776f718175633 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Mon, 27 Jul 2026 18:26:56 +0100 Subject: [PATCH 20/26] docs(run-engine): add CK virtual-time benchmark results Real flag OFF-vs-ON A/B from a local homelab with a production-like multi-cluster topology (not production, no prod data or traffic). Micro arm: a light key behind a backlog goes from first-served at step 480 to step 4, wait p95 down ~70%, contention fairness 0.34 to 1.0, with the balanced and lone-key cases unchanged and drain steps identical (work-conserving). End-to-end arm across three worker clusters: the victim tenant start latency drops ~38% mean while the flood tenant is unchanged. Relative numbers only; single box, not prod scale. --- .../design/benchmarks/results-2026-07-27.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 internal-packages/run-engine/design/benchmarks/results-2026-07-27.md diff --git a/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md b/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md new file mode 100644 index 00000000000..4369e45d0d8 --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md @@ -0,0 +1,80 @@ +# CK virtual-time scheduling: A/B benchmark results (2026-07-27) + +Run on a **local homelab box, not production**. It is a *production-like* +topology only in shape: one shared control plane (Postgres + one shared run-queue +Redis + ClickHouse + the webapp/engine on this branch) and three separate managed +worker clusters registered as distinct worker groups, all as containers on a +single machine. No production data, traffic, or infrastructure was involved. +Method and harness: `2026-07-26-ck-vtime-benchmark.md`. + +All numbers are **relative** (flag OFF vs ON, identical load, same box). Absolute +throughput on this single-box homelab is nowhere near prod scale and is not +reported as such; only the OFF-vs-ON delta is meaningful. + +## Arm 1: queue-level micro-benchmark (the isolated, defensible numbers) + +Real `RunQueue` driven directly against a dedicated Redis, flag OFF (age-ordered +CK dequeue) vs ON (virtual time), 5 trials, quantum 1 / window multiplier 3. +Every arm served every message exactly once (conservation checked), so the +comparison is sound. Wait is in logical dequeue steps. + +| scenario | metric | baseline (OFF) | vtime (ON) | delta | +| --- | --- | --- | --- | --- | +| **ckSkew** (heavy backlog + light keys) | light-key first-serve step | 480 | 4 | **−99%** | +| | light-key wait p50 | 556 | 96 | −83% | +| | light-key wait p95 | 628 | 188 | −70% | +| | light-key wait p99 | 636 | 196 | −69% | +| | Jain fairness (contention) | 0.34 | 1.00 | +192% | +| | drain step (work conservation) | 636 | 636 | 0% | +| **ckTrickle** (bulk + trickle keys) | trickle first-serve step | 480 | 4 | −99% | +| | trickle wait p95 | 592 | 172 | −71% | +| | Jain fairness | 0.50 | 1.00 | +100% | +| **ckSybil** (20 keys flood + 1 light; caps can't fix) | light first-serve step | 25 | 2 | −92% | +| | light wait p95 | 34 | 27 | −21% | +| **ckManyKeys** (61 variants > pass-1 window) | light first-serve step | 72 | 9 | −88% | +| | drain step (no starvation) | 81 | 79 | drains fully | +| **ckBalanced** (4 symmetric keys, no-harm) | worst-key wait p95 | 92 | 92 | 0% | +| **ckHeavyIdle** (lone key, work conservation) | drain step | 59 | 59 | 0% (exact) | +| (all scenarios) | Redis ops per dequeue+ack | baseline | +7% to +23% | cost | + +Reading it: a light key behind a backlog goes from waiting out the whole backlog +(first-serve step 480) to being served almost immediately (4), wait p95 drops +~70%, and contention fairness goes from lopsided (Jain 0.34) to even (1.00). The +symmetric and lone-key cases are untouched, and drain steps are identical, so the +fair order is work-conserving and does no harm. The cost is a modest per-dequeue +Redis op increase. + +## Arm 2: end-to-end on the three worker regions (realism check) + +Deployed task on one shared base queue, per-run concurrency keys, environment +concurrency ceiling pinned to 5 so the CK dequeue order is the bottleneck. +Noisy-neighbor load: tenant A floods across 30 keys (240 runs), tenant B sends 30 +runs across 3 keys, runs spread across the three regions, 1.5s hold. Metric is +per-run start latency (`startedAt - createdAt`, ms). Flag flipped OFF vs ON with a +control-plane redeploy between arms; identical load each arm. + +| tenant | metric | baseline (OFF) | vtime (ON) | delta | +| --- | --- | --- | --- | --- | +| **B (victim, 3 keys)** | start latency mean | 221,476 | 136,344 | **−38%** | +| | start latency p50 | 219,901 | 133,930 | **−39%** | +| | start latency p95 | 259,092 | 221,061 | −15% | +| | start latency p99 | 259,136 | 221,078 | −15% | +| A (flood, 30 keys) | start latency mean | 95,130 | 100,136 | +5% | +| A | start latency p50 | 95,049 | 95,689 | ~0% | + +Reading it: under age order the light tenant waits behind the flood (mean ~221s); +with virtual time it takes fair turns and drops to ~136s (mean −38%, p50 −39%), +while the flood tenant is essentially unchanged (+5%, it stops jumping the queue). +The smaller p95/p99 gain reflects per-**key** fairness: tenant B's keys carry +deeper per-key backlogs here (10 runs/key vs the flood's 8), so B's last runs +stay in the fair rotation longer. Absolute latencies are the cap-5 serialization +plus per-run worker startup on a single box; only the OFF-vs-ON delta is the +signal. + +## Bottom line + +Both arms agree: the fair order removes the starvation of a light concurrency key +behind a large or sharded backlog, does not harm the balanced or lone-key cases, +stays work-conserving, and costs a small, bounded per-dequeue Redis overhead. The +micro-benchmark isolates the scheduler (the defensible numbers); the end-to-end +run confirms the same effect on real deployed runs across the worker regions. From 749926f46401e376c1ceca163fe6114d28b00d88 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Mon, 27 Jul 2026 18:29:44 +0100 Subject: [PATCH 21/26] chore(run-engine): gitignore benchmark output artifacts Ignore the generated micro-benchmark output (bench-results/) and the standalone e2e harness run artifacts (node_modules, e2e-results, manifests, .env, lockfiles) so benchmark runs do not dirty the tree. --- internal-packages/run-engine/.gitignore | 2 ++ .../run-engine/design/benchmarks/e2e-tasks/.gitignore | 7 +++++++ 2 files changed, 9 insertions(+) create mode 100644 internal-packages/run-engine/.gitignore create mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore diff --git a/internal-packages/run-engine/.gitignore b/internal-packages/run-engine/.gitignore new file mode 100644 index 00000000000..cb5b3f39f4a --- /dev/null +++ b/internal-packages/run-engine/.gitignore @@ -0,0 +1,2 @@ +# Micro-benchmark output (generated by ckMicroBench.bench.test.ts) +bench-results/ diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore b/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore new file mode 100644 index 00000000000..ebeab39cd7e --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore @@ -0,0 +1,7 @@ +# Standalone deploy/run artifacts (this project is installed + run outside the monorepo) +node_modules/ +.trigger/ +e2e-results/ +.env.bench +pnpm-lock.yaml +package-lock.json From d853b6382d3d8675b97bb218917f32c569d838f4 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Tue, 28 Jul 2026 12:26:21 +0100 Subject: [PATCH 22/26] docs(run-engine): benchmark Redis CPU and memory vs concurrency-key cardinality Adds a resource/cardinality arm to the CK virtual-time benchmark: a plan, a runnable harness (drives a real RunQueue against a dedicated Redis, flag OFF vs ON, and reads server-side INFO memory/cpu, MEMORY USAGE and OBJECT ENCODING; inert without CK_BENCH_REDIS_URL), and results from a local homelab. Findings: the :ckVtime ZSET is essentially a second copy of :ckIndex (~150 bytes per key), so memory is linear in cardinality (about +1.3MB for a 10k-key queue) and TTL-reclaimed; Redis CPU overhead is small and does not scale with cardinality (+5 to +12 percent on an identical workload, per-script cost flat at ~25 usec/call) because the dequeue window is fixed and the ZSET ops are O(log N); and ckVtime membership tracks ckIndex exactly under sustained churn. --- ...7-28-ck-vtime-resource-cardinality-plan.md | 156 +++++++ .../results-cardinality-2026-07-28.md | 82 ++++ .../bench/ckResourceBench.bench.test.ts | 390 ++++++++++++++++++ 3 files changed, 628 insertions(+) create mode 100644 internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md create mode 100644 internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md create mode 100644 internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts diff --git a/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md b/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md new file mode 100644 index 00000000000..48825d66c0c --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md @@ -0,0 +1,156 @@ +# CK virtual-time scheduling: Redis CPU + memory vs cardinality test plan + +Answers the review question: how do these changes affect the run-queue Redis +CPU and memory, and how do both react as concurrency-key cardinality grows (e.g. +a base queue that suddenly has 10k distinct concurrency keys)? + +This is a cost/scaling plan, separate from the fairness A/B +(`results-2026-07-27.md`). Same environment constraints: run on a single box, all +numbers **relative** (flag OFF vs ON, identical load, same box), against a +dedicated throwaway Redis. + +## What the change adds to Redis (grounded in the branch) + +Per base queue, flag ON adds: + +- `:ckVtime`, a ZSET whose members are the exact same full CK-variant queue-name + strings the existing `:ckIndex` ZSET already holds, each with an 8-byte double + score. So it is effectively a second copy of `ckIndex`'s membership. +- `:ckVtimeFloor`, a STRING holding one number. Negligible. + +Both are GC'd from the dequeue path when a variant drains, carry a 24h TTL, and +live under the base queue's `{org}` hash tag. The per-call dequeue scan window is +`maxCount * windowMultiplier` (default 30) and does **not** grow with cardinality; +the cardinality-sensitive operations are the ZSET writes/reads (`ZADD` `NX`, +`ZSCORE`, `ZRANGE` by rank, `ZRANGE 0 0`), which are O(log N) on the `ckVtime` +skiplist. + +## Hypotheses + +1. **Memory grows linearly with cardinality, adding roughly one `ckIndex`-sized + ZSET per base queue.** Incremental `used_memory` under ON minus OFF should + track `cardinality x per-member cost`, and `MEMORY USAGE :ckVtime` should be + close to `MEMORY USAGE :ckIndex` for the same queue (same members, one extra + double score). At 10k keys on one queue this is a low single-digit MB for that + queue, bounded and TTL-reclaimed. There is a one-step jump at the + listpack->skiplist encoding boundary (128 entries by default). +2. **Redis CPU per operation grows sub-linearly (about O(log cardinality)), not + linearly.** The fixed 30-entry window scan dominates the vtime-specific work + and does not change with N; the ZSET ops add a `log N` term. So dequeue/enqueue + `usec_per_call` should rise only mildly from 100 to 10k keys, and the ON/OFF + `usec_per_call` ratio should stay roughly flat across the sweep. +3. **Tombstone drift stays bounded under high-cardinality churn.** `ack`, TTL + expiry, and DLQ drain a variant without removing it from `ckVtime` (documented + in `CK_VTIME_KNOWN_LIMITATIONS.md`). Under churn where variants drain via those + paths rather than a vtime dequeue, `ckVtime` may transiently exceed `ckIndex`, + but it should self-heal (next vtime pass GCs empties) or expire (24h TTL), so + `size(ckVtime) / size(ckIndex)` stays bounded and does not grow without limit. + +## Scenarios + +All on a single base queue (worst case for one queue's ZSETs), dedicated Redis, +flag OFF then ON with identical load. + +- **Cardinality sweep (memory).** Enqueue N distinct concurrency keys, one + message each, N in {100, 1_000, 10_000, 50_000}. Measure the Redis memory + footprint at rest for each N, OFF vs ON. This isolates the storage cost with no + dequeue activity. +- **Steady-state load (CPU).** At each N, after building cardinality, run a fixed + 60s workload of enqueue + batched dequeue (`maxCount 10`) + ack at a capped + concurrency, so keys are continuously served and re-registered. Measure Redis + CPU and per-command time over the window, OFF vs ON. +- **Churn / tombstone (memory under adversarial drain).** Build 10k keys, then + drain them via `ack` (not via vtime dequeue) while enqueuing new keys, for a + fixed duration. Sample `size(ckVtime)` and `size(ckIndex)` over time and confirm + the ratio stays bounded (self-heal + TTL), not monotonically growing. + +## Metrics and collection (exact commands) + +Use a second plain Redis client for measurement so it does not perturb the +harness. `redis-cli` shown; the harness issues the same via ioredis. + +Memory: + +- Totals: `INFO memory` -> `used_memory`, `used_memory_dataset`. Delta ON vs OFF + at each N is the incremental footprint. +- Per structure (exact bytes): `MEMORY USAGE {org...}:queue::ckIndex` and + `MEMORY USAGE {org...}:queue::ckVtime`. Report both and the ratio. +- Encoding: `OBJECT ENCODING ` (listpack vs skiplist) at each N, to + mark the transition. + +CPU: + +- Process CPU over the load window: `INFO cpu` -> `used_cpu_user` + + `used_cpu_sys`, sampled before and after the fixed 60s load; the delta is Redis + CPU-seconds consumed. Divide by op count for CPU-per-op. +- Per-command time: `CONFIG RESETSTAT` before the window, then `INFO commandstats` + after -> `cmdstat_zadd`, `cmdstat_zrange`, `cmdstat_zrangebyscore`, + `cmdstat_zscore`, `cmdstat_get`, `cmdstat_set`, `cmdstat_expire` + (`calls`, `usec`, `usec_per_call`). Compare OFF vs ON. +- Cross-check (optional, OS-level): `pidstat -p 1` over the window, or + `redis-cli --latency` / `--latency-history` for command latency. + +Derived: + +- Memory vs N curve (expect linear, slope ~ per-member bytes), OFF and ON. +- CPU-per-op vs N curve (expect flat-ish / log), OFF/ON ratio. +- `size(ckVtime)/size(ckIndex)` over time in the churn scenario (expect bounded). + +## A/B procedure + +1. Dedicated throwaway Redis; `FLUSHDB` between arms and between cardinality + points. Warm up once. +2. For each N in the sweep, for each arm (OFF, ON): + - Build N keys (enqueue N distinct concurrency keys). + - Snapshot memory (`used_memory`, `MEMORY USAGE` of both ZSETs, `OBJECT + ENCODING`). + - `CONFIG RESETSTAT`; run the fixed 60s steady load; snapshot `INFO cpu` and + `INFO commandstats`. + - Record, then `FLUSHDB`. +3. N trials of the load window per point; report median for CPU (memory at rest is + near-deterministic). +4. Run the churn scenario once per arm at N = 10k. + +Only the OFF-vs-ON delta and the shape of the growth curves are reported; absolute +throughput on a single box is not prod scale. + +## Results template + +### Memory at rest, per cardinality (single base queue) + +| keys (N) | used_memory OFF | used_memory ON | delta | MEMORY USAGE ckIndex | MEMORY USAGE ckVtime | ckVtime/ckIndex | ckVtime encoding | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 100 | | | | | | | | +| 1,000 | | | | | | | | +| 10,000 | | | | | | | | +| 50,000 | | | | | | | | + +### Redis CPU under 60s steady load, per cardinality + +| keys (N) | CPU-sec OFF | CPU-sec ON | delta | dequeue usec/call OFF | dequeue usec/call ON | delta | total redis calls OFF/ON | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 100 | | | | | | | | +| 1,000 | | | | | | | | +| 10,000 | | | | | | | | + +### Tombstone drift (N=10k, drain via ack) + +| elapsed | size(ckIndex) | size(ckVtime) | ratio | +| --- | --- | --- | --- | +| 0s | | | | +| 30s | | | | +| 60s | | | | + +## Harness + +Extend the existing micro-benchmark +(`../../src/run-queue/bench/ckMicroBench.bench.test.ts`), which already drives a +real `RunQueue` against an external Redis and reads `INFO commandstats`. Add a +resource/cardinality mode that: builds N variants, snapshots `used_memory` + +`MEMORY USAGE` of the base queue's `ckIndex`/`ckVtime` + `OBJECT ENCODING`, runs a +fixed-duration steady load while sampling `INFO cpu`/`commandstats`, and emits the +tables above. The churn scenario reuses the same enqueue/ack primitives with a +drain-by-ack loop and periodic `ZCARD` sampling of both ZSETs. + +Reuse the same dedicated Redis and the OFF-vs-ON constructor-flag pattern, so this +arm, like the micro-benchmark, needs no webapp or redeploy. diff --git a/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md b/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md new file mode 100644 index 00000000000..e62826e8e2e --- /dev/null +++ b/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md @@ -0,0 +1,82 @@ +# CK virtual-time scheduling: Redis CPU + memory vs cardinality (2026-07-28) + +Answers the review question: how do these changes affect the run-queue Redis CPU +and memory, and how do both react as concurrency-key cardinality grows? + +Run on a **local homelab box, not production**, against a dedicated Redis +configured for measurement (no RDB/AOF in the sampling windows, `maxmemory 0`, +zset listpack thresholds at their defaults so the encoding boundary sits at 128). +All numbers are **relative** (flag OFF vs ON, identical load, same box); absolute +throughput is not prod scale. Method: `2026-07-28-ck-vtime-resource-cardinality-plan.md`. +Server-side metrics (`INFO memory`/`cpu`/`commandstats`, `MEMORY USAGE`, +`OBJECT ENCODING`) are RTT-independent. + +## Memory at rest (single base queue, one queued message per key) + +| keys (N) | used_memory OFF | used_memory ON | ON-OFF delta | ckIndex | ckVtime | ckVtime/ckIndex | ckVtime encoding | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 100 | 1.84 MB | 1.93 MB | +0.09 MB | 6.9 KB | 6.2 KB | 0.89 | listpack | +| 1,000 | 2.42 MB | 2.58 MB | +0.15 MB | 131 KB | 131 KB | 1.00 | skiplist | +| 10,000 | 9.12 MB | 10.4 MB | +1.26 MB | 1.42 MB | 1.42 MB | 1.00 | skiplist | +| 50,000 | 39.1 MB | 45.6 MB | +6.51 MB | 7.55 MB | 7.54 MB | 1.00 | skiplist | + +The `:ckVtime` ZSET is the whole added footprint, and it is essentially a second +copy of `:ckIndex`: same members (the full CK-variant queue names), one extra +8-byte score, so `MEMORY USAGE(ckVtime) ~= MEMORY USAGE(ckIndex)` once past the +listpack boundary. Cost is linear in cardinality at roughly **150 bytes per +concurrency key** on top of the index the queue already keeps: about +1.3 MB for +a queue with 10k keys, +6.5 MB at 50k. It is bounded by live cardinality (entries +are GC'd when a variant drains) and expires on the 24h state TTL. The +`used_memory` delta tracks the direct `MEMORY USAGE(ckVtime)` figure to within +allocator noise. The listpack->skiplist transition lands between 100 and 1,000 +keys as expected. + +## Redis CPU under an identical workload (2,000 rounds, ~42k script calls) + +Same logical workload both arms (enqueue + batched dequeue + ack), so the +`evalsha` call count is identical and the difference is pure vtime overhead. Note +the RunQueue Lua runs as `EVALSHA`, so per-`redis.call` costs inside a script are +not separable in `commandstats`; the reportable signals are total Redis CPU and +aggregate `evalsha` time per call. + +| keys (N) | CPU-sec OFF | CPU-sec ON | delta | overhead | evalsha usec/call OFF | evalsha usec/call ON | +| --- | --- | --- | --- | --- | --- | --- | +| 100 | 1.34 | 1.49 | +0.15 | +12% | 22.3 | 26.2 | +| 1,000 | 1.34 | 1.44 | +0.10 | +7% | 22.2 | 24.8 | +| 10,000 | 1.38 | 1.45 | +0.07 | +5% | 23.8 | 25.8 | + +CPU overhead does not grow with cardinality, it shrinks: +12% at 100 keys down to ++5% at 10k. Per-script cost stays roughly flat (about +2 to +4 usec/call, ~25 +usec/call at every cardinality), which is what the design predicts: the pass-1 +dequeue window is fixed (`maxCount * multiplier`, default 30) and independent of +N, and the added ZSET ops are O(log N), so `log2(10000) ~= 13` adds a negligible +constant. The overhead falls as a percentage because that fixed per-call cost is +amortised over more work as the queue grows. + +## Membership under sustained churn (N = 10,000, flag ON) + +60 rounds of continuous registration + drain (fresh keys enqueued while others +are served and acked), holding cardinality at 10k: + +| round | ckIndex card | ckVtime card | ratio | +| --- | --- | --- | --- | +| 0 | 10,000 | 10,000 | 1.0 | +| 20 | 10,000 | 10,000 | 1.0 | +| 40 | 10,000 | 10,000 | 1.0 | +| 59 | 10,000 | 10,000 | 1.0 | + +`ckVtime` membership tracks `ckIndex` exactly throughout: no tombstone +accumulation, no unbounded growth. The bounded/self-healing drift the limitations +doc calls out (ack/TTL/DLQ draining a variant without a vtime GC) stays reclaimed +by the next vtime pass and the state TTL. + +## Bottom line for the cardinality question + +- **Memory** grows linearly with concurrency-key cardinality, adding one + `ckIndex`-sized ZSET per base queue (~150 B/key): a low-single-digit MB even at + 10k keys on one queue, bounded by live cardinality and TTL-reclaimed. A sudden + 10k-key queue costs about +1.3 MB for that queue. +- **CPU** overhead is small and does not scale with cardinality: ~+5 to +12% on + an identical workload, per-script cost flat at ~+2 to +4 usec/call regardless of + N, because the dequeue scan window is fixed and the ZSET ops are O(log N). +- **No runaway state**: `ckVtime` never outgrows `ckIndex` under sustained churn. diff --git a/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts b/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts new file mode 100644 index 00000000000..c0c0c3e6511 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts @@ -0,0 +1,390 @@ +import { createRedisClient } from "@internal/redis"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { Decimal } from "@trigger.dev/database"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import type { InputPayload } from "../types.js"; + +// CK virtual-time RESOURCE arm: Redis CPU + memory vs concurrency-key cardinality. +// +// Answers "how do these changes affect the run-queue Redis CPU/memory, and how do +// both react as cardinality grows (e.g. 10k concurrency keys on one base queue)". +// Drives a real RunQueue against an EXTERNAL dedicated Redis, flag OFF vs ON on +// identical load, and reads server-side metrics (INFO memory/cpu/commandstats, +// MEMORY USAGE, OBJECT ENCODING) that are unaffected by client<->server RTT. +// +// Inert unless CK_BENCH_REDIS_URL is set. FLUSHALLs the target between points, so +// point it ONLY at a dedicated throwaway store (a redis-bench lab store). +// +// export CK_BENCH_REDIS_URL="$(lab store url ckbench1)" +// pnpm exec vitest run src/run-queue/bench/ckResourceBench.bench.test.ts +// +// Env knobs: +// CK_RES_MEM_CARDS=100,1000,10000,50000 memory-at-rest sweep +// CK_RES_CPU_CARDS=100,1000,10000 cpu-under-load sweep +// CK_RES_LOAD_OPS=8000 load rounds per cpu point +// CK_RES_CONCURRENCY=64 client concurrency (beats RTT) +// CK_RES_CHURN_CARD=10000 churn/tombstone cardinality +// CK_RES_CHURN_ROUNDS=60 churn sample rounds +// CK_BENCH_OUT=./bench-results +// +// NOTE: the RunQueue Lua commands run via EVALSHA, so redis.call() ops inside a +// script do NOT show up as separate cmdstat_* lines; they roll up under evalsha. +// The reportable CPU signals are therefore total used_cpu over an identical +// workload and aggregate evalsha usec_per_call, not a per-Redis-command split. + +const REDIS_URL = process.env.CK_BENCH_REDIS_URL; +const MEM_CARDS = (process.env.CK_RES_MEM_CARDS ?? "100,1000,10000,50000") + .split(",") + .map((s) => +s.trim()); +const CPU_CARDS = (process.env.CK_RES_CPU_CARDS ?? "100,1000,10000") + .split(",") + .map((s) => +s.trim()); +const LOAD_OPS = +(process.env.CK_RES_LOAD_OPS ?? "8000"); +const CONCURRENCY = +(process.env.CK_RES_CONCURRENCY ?? "64"); +const CHURN_CARD = +(process.env.CK_RES_CHURN_CARD ?? "10000"); +const CHURN_ROUNDS = +(process.env.CK_RES_CHURN_ROUNDS ?? "60"); +const OUT_DIR = process.env.CK_BENCH_OUT ?? "./bench-results"; + +const keys = new RunQueueFullKeyProducer(); + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 1_000_000, + logger: new Logger("RunQueue", "error"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys, +}; + +const env = { + id: "e1234", + type: "PRODUCTION" as const, + maximumConcurrencyLimit: 1_000_000, + concurrencyLimitBurstFactor: new Decimal(1.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +function conn() { + const u = new URL(REDIS_URL!); + return { + host: u.hostname, + port: Number(u.port || "6379"), + password: decodeURIComponent(u.password || "") || undefined, + username: decodeURIComponent(u.username || "") || undefined, + }; +} + +function createQueue(keyPrefix: string, vtimeEnabled: boolean) { + const c = conn(); + return new RunQueue({ + ...testOptions, + masterQueueConsumersDisabled: true, + workerOptions: { disabled: true }, + ckVirtualTimeScheduling: { enabled: vtimeEnabled }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ redis: { keyPrefix, ...c }, keys }), + redis: { keyPrefix, ...c }, + }); +} + +function makeMessage(o: Partial = {}): InputPayload { + return { + runId: "r1", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "PRODUCTION", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, + ...o, + }; +} + +// bounded-concurrency runner (beats the ~3.6ms workstation->box RTT) +async function pool(n: number, count: number, fn: (i: number) => Promise) { + let i = 0; + await Promise.all( + Array.from({ length: Math.min(n, count) }, async () => { + while (i < count) { + const idx = i++; + await fn(idx); + } + }) + ); +} + +// ---- server-side metric helpers (separate no-prefix admin client) ---- +function admin() { + return createRedisClient(conn(), { onError: () => {} }); +} +function infoField(info: string, key: string): number { + const line = info.split("\n").find((l) => l.startsWith(key + ":")); + return line ? Number(line.split(":")[1]) : NaN; +} +async function usedMemory(a: any) { + return infoField(await a.info("memory"), "used_memory"); +} +async function usedCpu(a: any) { + const i = await a.info("cpu"); + return infoField(i, "used_cpu_user") + infoField(i, "used_cpu_sys"); +} +function evalsha(info: string) { + const line = info.split("\n").find((l) => l.startsWith("cmdstat_evalsha:")); + if (!line) return { calls: 0, usec: 0, usecPerCall: 0 }; + const g = (k: string) => Number(line.match(new RegExp(`${k}=([0-9.]+)`))?.[1] ?? 0); + return { calls: g("calls"), usec: g("usec"), usecPerCall: g("usec_per_call") }; +} + +// ---- build N distinct concurrency keys (one queued message each) ---- +async function buildCardinality(queue: RunQueue, n: number) { + const t0 = Date.now() - 500_000; + await pool(CONCURRENCY, n, async (i) => { + await queue.enqueueMessage({ + env, + message: makeMessage({ runId: `r-${i}`, concurrencyKey: `k${i}`, timestamp: t0 + i }), + workerQueue: env.id, + skipDequeueProcessing: true, + }); + }); +} + +async function findKey(a: any, prefix: string, suffix: string): Promise { + const found = await a.keys(`${prefix}*:${suffix}`); + return found[0] ?? null; +} + +// ---- the bench ---- +describe.runIf(!!REDIS_URL)("CK virtual-time resource + cardinality benchmark", () => { + it( + "measures Redis memory and CPU vs cardinality, flag OFF vs ON", + { timeout: 60 * 60_000 }, + async () => { + const a = admin(); + const report: any = { generatedAtMs: Date.now(), memory: [], cpu: [], churn: null }; + + // ---------- memory at rest ---------- + for (const n of MEM_CARDS) { + const row: any = { cardinality: n }; + for (const on of [false, true]) { + await a.flushall(); + await a.call("CONFIG", "RESETSTAT"); + const prefix = `ckres:mem:${n}:${on ? "on" : "off"}:`; + const q = createQueue(prefix, on); + try { + await q.updateEnvConcurrencyLimits(env); + await buildCardinality(q, n); + const arm = on ? "on" : "off"; + row[`used_memory_${arm}`] = await usedMemory(a); + const ckIndexKey = await findKey(a, prefix, "ckIndex"); + const ckVtimeKey = await findKey(a, prefix, "ckVtime"); + row[`ckIndex_bytes_${arm}`] = ckIndexKey + ? await a.call("MEMORY", "USAGE", ckIndexKey) + : null; + row[`ckIndex_card_${arm}`] = ckIndexKey ? await a.zcard(ckIndexKey) : 0; + if (on) { + row.ckVtime_bytes = ckVtimeKey ? await a.call("MEMORY", "USAGE", ckVtimeKey) : null; + row.ckVtime_card = ckVtimeKey ? await a.zcard(ckVtimeKey) : 0; + row.ckVtime_encoding = ckVtimeKey + ? await a.call("OBJECT", "ENCODING", ckVtimeKey) + : null; + // ckVtime must mirror ckIndex membership when built via the slow path + expect(row.ckVtime_card).toBe(row.ckIndex_card_on); + } + } finally { + await q.quit(); + } + } + row.used_memory_delta = row.used_memory_on - row.used_memory_off; + row.ckVtime_over_ckIndex = + row.ckIndex_bytes_on && row.ckVtime_bytes + ? +(row.ckVtime_bytes / row.ckIndex_bytes_on).toFixed(2) + : null; + report.memory.push(row); + // eslint-disable-next-line no-console + console.log( + `[ckres] mem N=${n}: used_memory delta=${row.used_memory_delta}B ckVtime=${row.ckVtime_bytes}B (${row.ckVtime_encoding})` + ); + } + + // ---------- CPU under identical load ---------- + for (const n of CPU_CARDS) { + const row: any = { cardinality: n }; + for (const on of [false, true]) { + await a.flushall(); + const prefix = `ckres:cpu:${n}:${on ? "on" : "off"}:`; + const q = createQueue(prefix, on); + try { + await q.updateEnvConcurrencyLimits(env); + await buildCardinality(q, n); + const shard = keys.masterQueueShardForEnvironment(env.id, 2); + + await a.call("CONFIG", "RESETSTAT"); + const cpu0 = await usedCpu(a); + const t0wall = Date.now(); + + // identical workload both arms: LOAD_OPS rounds, each round enqueues + // 10 fresh messages (rotating keys, keeps N populated) and does one + // batched dequeue (maxCount 10) + acks the served set. + let served = 0; + await pool(CONCURRENCY, LOAD_OPS, async (i) => { + for (let j = 0; j < 10; j++) { + await q.enqueueMessage({ + env, + message: makeMessage({ + runId: `L-${i}-${j}`, + concurrencyKey: `k${(i * 10 + j) % n}`, + timestamp: Date.now(), + }), + workerQueue: env.id, + skipDequeueProcessing: true, + }); + } + const msgs = await q.testDequeueFromMasterQueue(shard, env.id, 10); + served += msgs.length; + for (const m of msgs) { + await q.acknowledgeMessage(env.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + }); + + const cpu1 = await usedCpu(a); + const es = evalsha(await a.info("commandstats")); + const arm = on ? "on" : "off"; + row[`cpu_sec_${arm}`] = +(cpu1 - cpu0).toFixed(3); + row[`evalsha_calls_${arm}`] = es.calls; + row[`evalsha_usec_per_call_${arm}`] = +es.usecPerCall.toFixed(2); + row[`wall_ms_${arm}`] = Date.now() - t0wall; + row[`served_${arm}`] = served; + } finally { + await q.quit(); + } + } + row.cpu_sec_delta = +(row.cpu_sec_on - row.cpu_sec_off).toFixed(3); + row.cpu_overhead_pct = row.cpu_sec_off + ? Math.round(((row.cpu_sec_on - row.cpu_sec_off) / row.cpu_sec_off) * 100) + : null; + report.cpu.push(row); + // eslint-disable-next-line no-console + console.log( + `[ckres] cpu N=${n}: cpu OFF=${row.cpu_sec_off}s ON=${row.cpu_sec_on}s (${row.cpu_overhead_pct}%) evalsha usec/call OFF=${row.evalsha_usec_per_call_off} ON=${row.evalsha_usec_per_call_on}` + ); + } + + // ---------- churn: ckVtime membership stays bounded vs ckIndex ---------- + { + await a.flushall(); + const prefix = `ckres:churn:on:`; + const q = createQueue(prefix, true); + const samples: any[] = []; + try { + await q.updateEnvConcurrencyLimits(env); + await buildCardinality(q, CHURN_CARD); + const shard = keys.masterQueueShardForEnvironment(env.id, 2); + const ckIndexKey = (await findKey(a, prefix, "ckIndex"))!; + const ckVtimeKey = (await findKey(a, prefix, "ckVtime"))!; + let nextKey = CHURN_CARD; + for (let r = 0; r < CHURN_ROUNDS; r++) { + // hold cardinality: each iteration enqueues one FRESH key and drains + // one message (maxCount 1), so registration and GC churn continuously + // while total membership stays ~CHURN_CARD. + await pool(CONCURRENCY, 200, async () => { + await q.enqueueMessage({ + env, + message: makeMessage({ + runId: `C-${nextKey}`, + concurrencyKey: `k${nextKey++}`, + timestamp: Date.now(), + }), + workerQueue: env.id, + skipDequeueProcessing: true, + }); + const msgs = await q.testDequeueFromMasterQueue(shard, env.id, 1); + for (const m of msgs) { + await q.acknowledgeMessage(env.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + }); + if (r % 10 === 0 || r === CHURN_ROUNDS - 1) { + const ckIndexCard = await a.zcard(ckIndexKey); + const ckVtimeCard = await a.zcard(ckVtimeKey); + samples.push({ + round: r, + ckIndex: ckIndexCard, + ckVtime: ckVtimeCard, + ratio: ckIndexCard ? +(ckVtimeCard / ckIndexCard).toFixed(2) : null, + }); + } + } + report.churn = { cardinality: CHURN_CARD, rounds: CHURN_ROUNDS, samples }; + // bounded: ckVtime never wildly exceeds ckIndex (allow generous 3x for transient tombstones) + for (const s of samples) if (s.ratio !== null) expect(s.ratio).toBeLessThanOrEqual(3); + } finally { + await q.quit(); + } + } + + await a.flushall(); + await a.quit().catch(() => {}); + + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(`${OUT_DIR}/ck-resource-results.json`, JSON.stringify(report, null, 2)); + writeFileSync(`${OUT_DIR}/ck-resource-results.md`, renderMarkdown(report)); + } + ); +}); + +function renderMarkdown(r: any): string { + const L: string[] = []; + const kb = (b: number) => (b == null ? "n/a" : (b / 1024).toFixed(1) + "KB"); + L.push(`# CK virtual-time resource + cardinality results`, ""); + L.push( + `Redis \`${(process.env.CK_BENCH_REDIS_URL || "").replace(/:[^:@/]*@/, ":***@")}\`. Relative OFF-vs-ON on one box; not prod scale.`, + "" + ); + L.push(`## Memory at rest (single base queue, one message per key)`, ""); + L.push( + `| keys (N) | used_memory OFF | used_memory ON | delta | ckIndex bytes | ckVtime bytes | ckVtime/ckIndex | ckVtime encoding |` + ); + L.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`); + for (const m of r.memory) + L.push( + `| ${m.cardinality} | ${kb(m.used_memory_off)} | ${kb(m.used_memory_on)} | ${kb(m.used_memory_delta)} | ${kb(m.ckIndex_bytes_on)} | ${kb(m.ckVtime_bytes)} | ${m.ckVtime_over_ckIndex ?? "n/a"} | ${m.ckVtime_encoding ?? "n/a"} |` + ); + L.push( + "", + `## Redis CPU under identical workload (${process.env.CK_RES_LOAD_OPS ?? "8000"} rounds)`, + "" + ); + L.push( + `| keys (N) | CPU-sec OFF | CPU-sec ON | delta | overhead | evalsha usec/call OFF | evalsha usec/call ON | evalsha calls OFF/ON |` + ); + L.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`); + for (const c of r.cpu) + L.push( + `| ${c.cardinality} | ${c.cpu_sec_off} | ${c.cpu_sec_on} | ${c.cpu_sec_delta} | ${c.cpu_overhead_pct}% | ${c.evalsha_usec_per_call_off} | ${c.evalsha_usec_per_call_on} | ${c.evalsha_calls_off}/${c.evalsha_calls_on} |` + ); + if (r.churn) { + L.push("", `## Tombstone / membership under churn (N=${r.churn.cardinality}, flag ON)`, ""); + L.push(`| round | ckIndex card | ckVtime card | ratio |`, `| --- | --- | --- | --- |`); + for (const s of r.churn.samples) + L.push(`| ${s.round} | ${s.ckIndex} | ${s.ckVtime} | ${s.ratio} |`); + } + L.push(""); + return L.join("\n"); +} From 9b45a7684c48de76385556209a39f97d0a9fab7e Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Tue, 28 Jul 2026 23:20:33 +0100 Subject: [PATCH 23/26] chore(run-engine): drop benchmark, e2e, and design docs from the branch Keeps the change to the scheduler code, the CI tests, and the server-changes note. The benchmark harnesses, e2e project, results, plans, and design references were only ever local validation aids and don't belong in the repo. --- internal-packages/run-engine/.gitignore | 2 - .../2026-07-26-ck-vtime-benchmark.md | 253 ------ ...7-28-ck-vtime-resource-cardinality-plan.md | 156 ---- .../design/benchmarks/e2e-tasks/.gitignore | 7 - .../design/benchmarks/e2e-tasks/README.md | 72 -- .../design/benchmarks/e2e-tasks/package.json | 19 - .../benchmarks/e2e-tasks/src/collect.ts | 92 -- .../benchmarks/e2e-tasks/src/loadgen.ts | 103 --- .../benchmarks/e2e-tasks/src/preflight.ts | 59 -- .../e2e-tasks/src/trigger/ckBench.ts | 42 - .../benchmarks/e2e-tasks/src/waitdrain.ts | 68 -- .../benchmarks/e2e-tasks/trigger.config.ts | 11 - .../design/benchmarks/e2e-tasks/tsconfig.json | 13 - .../design/benchmarks/results-2026-07-27.md | 80 -- .../results-cardinality-2026-07-28.md | 82 -- ...6-07-23-ck-virtual-time-scheduling-plan.md | 829 ------------------ .../run-engine/design/references/README.md | 43 - .../diagrams/fairness-how-it-works.png | Bin 176541 -> 0 bytes .../diagrams/fairness-problem-and-fix.png | Bin 88028 -> 0 bytes .../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 --- .../run-queue/CK_VTIME_KNOWN_LIMITATIONS.md | 70 -- .../bench/ckMicroBench.bench.test.ts | 555 ------------ .../bench/ckResourceBench.bench.test.ts | 390 -------- 26 files changed, 3567 deletions(-) delete mode 100644 internal-packages/run-engine/.gitignore delete mode 100644 internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md delete mode 100644 internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts delete mode 100644 internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json delete mode 100644 internal-packages/run-engine/design/benchmarks/results-2026-07-27.md delete mode 100644 internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md delete mode 100644 internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md delete mode 100644 internal-packages/run-engine/design/references/README.md delete mode 100644 internal-packages/run-engine/design/references/diagrams/fairness-how-it-works.png delete mode 100644 internal-packages/run-engine/design/references/diagrams/fairness-problem-and-fix.png delete mode 100644 internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md delete mode 100644 internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md delete mode 100644 internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md delete mode 100644 internal-packages/run-engine/design/references/run-queue-fairness-research.md delete mode 100644 internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md delete mode 100644 internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts delete mode 100644 internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts diff --git a/internal-packages/run-engine/.gitignore b/internal-packages/run-engine/.gitignore deleted file mode 100644 index cb5b3f39f4a..00000000000 --- a/internal-packages/run-engine/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Micro-benchmark output (generated by ckMicroBench.bench.test.ts) -bench-results/ diff --git a/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md b/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md deleted file mode 100644 index 72146a88cbc..00000000000 --- a/internal-packages/run-engine/design/benchmarks/2026-07-26-ck-vtime-benchmark.md +++ /dev/null @@ -1,253 +0,0 @@ -# CK virtual-time scheduling: prod-like A/B benchmark plan - -A method for producing defensible A/B numbers for the concurrency-key -virtual-time (SFQ) scheduling change, run on a single prod-shaped box. The two -arms are flag OFF (today's age-ordered CK dequeue) and flag ON (virtual-time -ordering), under identical load. - -## What the change is (grounded in the branch) - -The concurrency-key dequeue used to serve variants of a base queue in head-message -age order. Behind `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` (off by default) it now -orders them by start-time fair queueing (SFQ) virtual time: - -- Each CK variant carries a virtual clock in a parallel `:ckVtime` ZSET; a - monotonic floor lives in `:ckVtimeFloor`. Both sit under the base queue's - `{org}` hash tag, so one atomic Lua script touches all of a queue's state. -- The dequeue runs two passes: pass 1 serves the lowest virtual clocks and - advances each served variant by `quantum / weight` (weight fixed at 1 today); - pass 2 fills any leftover batch slots in today's age order. Pass 2 makes the - new command a strict superset of the old one, so it is work-conserving and can - never serve fewer runs than today. -- Enqueue and nack register a variant into `:ckVtime` at the floor with `NX`, so - a brand-new key is reachable from its first enqueue and cannot be parked behind - a backlog. This is the case a per-key concurrency cap cannot fix: one tenant - sharding work across many keys. -- Flag off is byte-identical: the pre-existing Lua scripts run unchanged and no - vtime keys are created. The behaviour lives only in new command names. - -Tuning knobs (real env var names, all positive integers, re-clamped in the -`RunQueue` constructor): - -| Env var | Default | Meaning | -| --- | --- | --- | -| `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` | off | master flag | -| `RUN_ENGINE_CK_VTIME_QUANTUM` | 1 | virtual-time advance per serve | -| `RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER` | 3 | pass-1 window = `maxCount * this` | -| `RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS` | 86400 | EXPIRE on the vtime keys | - -Stated limitations this benchmark deliberately probes (from -`../../src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md`): bounded tombstone drift on -ack/TTL/DLQ paths; member-name tie-break at equal tags; future-scheduled or -retry-backoff variants occupying pass-1 window slots; and the pass-1 window being -narrower than the live variant cardinality. - -## Validity: the numbers are RELATIVE, not absolute - -The target is one node on nested/slow storage. Absolute throughput here is NOT -prod-scale and must never be reported as such. Every result is a ratio between -flag OFF and flag ON, measured under identical load on the same box in the same -session. That relative signal is what isolates the scheduler. Two guards keep the -arms comparable: - -- Same enqueue set, same timestamps, same step loop / same load generator across - OFF and ON. -- Fresh queue state between arms (the micro arm FLUSHes a dedicated Redis; the - end-to-end arm drains and uses a fresh batch tag), a warmup, and N trials. - -## Two arms - -### Arm 1 (PRIMARY): queue-level micro-benchmark - -Drives the real `RunQueue` directly against a dedicated Redis, comparing OFF vs -ON on identical synthetic load. This isolates the scheduler and is where the -defensible numbers come from. Because the flag is a `RunQueue` constructor -option, one bench process runs both arms in-process: no webapp, no redeploy, no -worker clusters. It reuses the existing fairness harness -(`../../src/run-queue/tests/ckVtimeFairness.test.ts`): same step loop, same -scenario shapes, same conservation checks, plus wall-clock latency, a Redis -op-count, N trials, and file output. - -Harness: `../../src/run-queue/bench/ckMicroBench.bench.test.ts`. It is inert in CI -(only runs when `CK_BENCH_REDIS_URL` is set) and FLUSHes its target Redis between -arms, so it must point only at a dedicated throwaway instance. - -Each step makes one `maxCount = 10` dequeue call, records `(step, key, messageId, -wallMs)` per served message, then acks in-flight messages whose logical hold has -elapsed. Wait per message = the step it was served at (all load is pre-enqueued -at step 0). The logical schedule is deterministic, so step-based metrics are -identical across trials (the harness asserts this); trials exist to stabilise the -wall-clock latency and op-count. - -### Arm 2 (END-TO-END): deployed tasks on the worker clusters - -The realism check on top of arm 1. A deployed task on a shared base queue with -per-run concurrency keys, driven by a noisy-neighbor load generator: tenant A -floods across many keys, tenant B sends a few. Each run carries a per-run region -so the load also spreads across the three managed worker groups -(`trigger-regiona/b/c`), exercising multi-cluster placement. Latency is read back -per run as `startedAt - createdAt`. - -Project: `e2e-tasks/` (deployable, secret-free). Contention is forced by pinning -the environment concurrency ceiling low (so many keys contend for a few slots and -the CK dequeue order decides who starts first); the per-key lane width is 1 in the -task config for reproducibility. - -Because the flag is server-side here, arm 2 is a manual OFF-then-ON: set the flag, -redeploy the control plane, run the load, collect; flip the flag, redeploy, run -the load again, collect. The exact toggle + redeploy belongs to the operator -runbook. - -## Hypotheses (tied to the change) - -1. **Bounded wait behind a backlog.** A light key arriving behind a big backlog - waits O(number of active keys) under vtime, versus O(backlog size) under the - baseline (which drains the backlog first). Micro: `ckSkew`, `ckTrickle` - victim wait p95/p99 drops sharply ON vs OFF. E2E: tenant B start-latency p95 - stays bounded as tenant A's backlog grows. -2. **Sharding across many keys cannot starve others.** A tenant fanning out over - many concurrency keys (the case a per-key cap cannot fix) does not starve a - light key, because the light key registers at the floor and is reachable in - pass 1. Micro: `ckSybil` victim first-serve step is small ON (near-immediate) - and its wait ratio drops; `ckManyKeys` shows no permanent starvation even - when cardinality exceeds the pass-1 window. E2E: tenant B (few keys) is not - starved by tenant A's many-key flood. -3. **Work conservation.** A lone backlogged tenant still drains at full rate; - the fair order adds no idle time when nothing else contends. Micro: - `ckHeavyIdle` drain step ON equals OFF exactly. No-harm corollary: the - symmetric `ckBalanced` case is not made worse. - -## Scenarios - -### Arm 1 (micro), all ported from the fairness-spike shapes - -| scenario | shape | env limit | hold | probes | -| --- | --- | --- | --- | --- | -| `ckSkew` | heavy 120 backlog + 4 light x 10 | 1 | 3 | starvation (H1) | -| `ckTrickle` | bulk 120 + 2 trickle x 15 | 1 | 3 | starvation (H1) | -| `ckSybil` | 20 attacker x 8 + 1 light x 10 | 25 | 3 | sharding/sybil (H2) | -| `ckManyKeys` | 60 attacker x 8 (tied head) + 1 light x 10 | 25 | 3 | window limitation, no permanent starvation (H2) | -| `ckBalanced` | 4 symmetric x 25 | 4 | 3 | no-harm (H3) | -| `ckHeavyIdle` | 1 key x 60 | 25 | 3 | work conservation (H3) | - -### Arm 2 (end-to-end), noisy-neighbor - -- Tenant A: `A_KEYS` (default 40) keys x `A_PER_KEY` (default 5) runs = the flood. -- Tenant B: `B_KEYS` (default 2) keys x `B_PER_KEY` (default 5) runs = the victim. -- Per-run hold `HOLD_MS` (default 1500). Runs round-robined across the three - regions. Environment concurrency ceiling pinned low (e.g. 5) so the keys - actually contend. -- Optional placement variant: pin tenant A to one region and tenant B to another - to separate scheduler effects from cross-cluster effects. - -## Metrics and collection - -| metric | what it shows | source | -| --- | --- | --- | -| victim wait p50/p95/p99 | starvation relief | micro: serve step; e2e: `startedAt - createdAt` per tenant | -| victim first-serve (starvation bound) | reachability at the floor | micro: first serve step for the victim key | -| drain step / total served | work conservation, no loss/dup | micro: last serve step + unique messageId count | -| Jain's fairness index | share fairness during contention | micro: over per-key contention-window serves | -| dequeue call p95 (ms) | scheduler op cost (relative) | micro: wall-clock around each dequeue call | -| redis ops (dequeue+ack) | per-dequeue overhead | micro: `CONFIG RESETSTAT` then `INFO commandstats` | - -Jain's index over per-key served counts `x_i`: `J = (sum x_i)^2 / (n * sum -x_i^2)`. 1.0 is perfectly fair; `1/n` means one key took everything. - -The micro harness writes `ck-micro-results.json` and `ck-micro-results.md` -(the results table below) to `CK_BENCH_OUT`. - -For the end-to-end arm, the primary source is the Runs API by tag -(`startedAt - createdAt`), which `collect.ts` reads. Two alternatives give a -tighter dequeue-only timestamp if the API delta looks noisy: - -- TRQL `runs` (verify column names with the query schema first): per-run - `createdAt` and `startedAt`, filtered by the batch/arm tag. -- The run-engine Postgres `TaskRun` timestamps directly (createdAt and the first - attempt/started timestamp), if API round-trips add too much jitter. - -## Reproducible A/B procedure - -### Arm 1 (micro) - -1. Stand up a dedicated throwaway Redis reachable from the harness host (a local - forward is fine). Nothing else may use it. -2. From the run-engine package: - - ```bash - CK_BENCH_REDIS_URL=redis://127.0.0.1:6399 \ - CK_BENCH_TRIALS=5 CK_BENCH_OUT=./bench-results \ - pnpm exec vitest run src/run-queue/bench/ckMicroBench.bench.test.ts - ``` - - Both arms (OFF, then ON) run in one process per scenario, FLUSHing between - arms. Knob sweep: add `CK_BENCH_QUANTUM` / `CK_BENCH_WINDOW_MULT`. Scenario - subset: `CK_BENCH_SCENARIOS=ckSybil,ckSkew`. -3. Read `bench-results/ck-micro-results.md`. The harness fails the run if either - arm loses or double-serves a message, so a green run means the comparison is - sound. - -### Arm 2 (end-to-end) - -1. Deploy `e2e-tasks/` to the bench project's PROD environment (dev - short-circuits worker-group routing, so it must be prod). Pin the prod env - concurrency ceiling low. -2. Warmup: trigger a handful of runs, confirm they start on each region, discard. -3. **Arm OFF:** ensure the flag is off and the control plane is redeployed; then - `ARM=off BATCH= pnpm loadgen`, wait for drain, `ARM=off BATCH= pnpm - collect`. -4. **Arm ON:** flip the flag on, redeploy the control plane, drain/clear queue - state; then `ARM=on BATCH= pnpm loadgen`, wait for drain, `ARM=on - BATCH= pnpm collect`. -5. Repeat both arms N times with fresh batch ids; compare per-tenant latency. - -The exact deploy, flag-toggle, and secret-key retrieval steps for the specific -box are in the operator runbook (kept out of this repo). - -## Results template (paste into the PR) - -Fill from `ck-micro-results.md` (arm 1) and `e2e-summary.md` (arm 2). Keep the -"relative only" caveat in the PR text. - -### Arm 1 (micro), quantum 1 / window x3, N trials, dedicated Redis on the box - -| scenario | metric | baseline (OFF) | vtime (ON) | delta | -| --- | --- | --- | --- | --- | -| **ckSkew** | victim wait p95 (steps) | | | | -| | victim wait p99 | | | | -| | victim first-serve | | | | -| | drain step | | | | -| | dequeue call p95 (ms) | | | | -| **ckTrickle** | victim wait p95 | | | | -| | victim first-serve | | | | -| **ckSybil** | victim wait p95 | | | | -| | victim first-serve | | | | -| | Jain index (contention) | | | | -| **ckManyKeys** | victim first-serve | | | | -| | drain step | | | | -| **ckBalanced** | worst-key wait p95 | | | | -| **ckHeavyIdle** | drain step | | | | -| (all) | redis ops (dequeue+ack) | | | | - -### Arm 2 (end-to-end), noisy-neighbor across regiona/b/c, env cap N - -| tenant | metric | baseline (OFF) | vtime (ON) | delta | -| --- | --- | --- | --- | --- | -| B (victim, few keys) | start latency p50 (ms) | | | | -| B | start latency p95 | | | | -| B | start latency p99 | | | | -| A (flood, many keys) | start latency p95 | | | | - -Expected direction: B's p95/p99 drop substantially ON; A's is similar or slightly -higher ON (it stops jumping the queue); `ckHeavyIdle` drain step is exactly equal; -`ckBalanced` worst-key wait is within noise. - -## How to reproduce on the box (short) - -1. Dedicated Redis up, forwarded locally. Run the arm-1 vitest command above; - collect `ck-micro-results.md`. -2. Deploy `e2e-tasks/` to prod, pin the env concurrency ceiling, warm up. -3. Flag OFF: redeploy control plane, loadgen + collect. Flag ON: redeploy, - loadgen + collect. N trials. -4. Paste both tables into the PR under a "relative numbers on a single - prod-shaped box" heading. diff --git a/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md b/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md deleted file mode 100644 index 48825d66c0c..00000000000 --- a/internal-packages/run-engine/design/benchmarks/2026-07-28-ck-vtime-resource-cardinality-plan.md +++ /dev/null @@ -1,156 +0,0 @@ -# CK virtual-time scheduling: Redis CPU + memory vs cardinality test plan - -Answers the review question: how do these changes affect the run-queue Redis -CPU and memory, and how do both react as concurrency-key cardinality grows (e.g. -a base queue that suddenly has 10k distinct concurrency keys)? - -This is a cost/scaling plan, separate from the fairness A/B -(`results-2026-07-27.md`). Same environment constraints: run on a single box, all -numbers **relative** (flag OFF vs ON, identical load, same box), against a -dedicated throwaway Redis. - -## What the change adds to Redis (grounded in the branch) - -Per base queue, flag ON adds: - -- `:ckVtime`, a ZSET whose members are the exact same full CK-variant queue-name - strings the existing `:ckIndex` ZSET already holds, each with an 8-byte double - score. So it is effectively a second copy of `ckIndex`'s membership. -- `:ckVtimeFloor`, a STRING holding one number. Negligible. - -Both are GC'd from the dequeue path when a variant drains, carry a 24h TTL, and -live under the base queue's `{org}` hash tag. The per-call dequeue scan window is -`maxCount * windowMultiplier` (default 30) and does **not** grow with cardinality; -the cardinality-sensitive operations are the ZSET writes/reads (`ZADD` `NX`, -`ZSCORE`, `ZRANGE` by rank, `ZRANGE 0 0`), which are O(log N) on the `ckVtime` -skiplist. - -## Hypotheses - -1. **Memory grows linearly with cardinality, adding roughly one `ckIndex`-sized - ZSET per base queue.** Incremental `used_memory` under ON minus OFF should - track `cardinality x per-member cost`, and `MEMORY USAGE :ckVtime` should be - close to `MEMORY USAGE :ckIndex` for the same queue (same members, one extra - double score). At 10k keys on one queue this is a low single-digit MB for that - queue, bounded and TTL-reclaimed. There is a one-step jump at the - listpack->skiplist encoding boundary (128 entries by default). -2. **Redis CPU per operation grows sub-linearly (about O(log cardinality)), not - linearly.** The fixed 30-entry window scan dominates the vtime-specific work - and does not change with N; the ZSET ops add a `log N` term. So dequeue/enqueue - `usec_per_call` should rise only mildly from 100 to 10k keys, and the ON/OFF - `usec_per_call` ratio should stay roughly flat across the sweep. -3. **Tombstone drift stays bounded under high-cardinality churn.** `ack`, TTL - expiry, and DLQ drain a variant without removing it from `ckVtime` (documented - in `CK_VTIME_KNOWN_LIMITATIONS.md`). Under churn where variants drain via those - paths rather than a vtime dequeue, `ckVtime` may transiently exceed `ckIndex`, - but it should self-heal (next vtime pass GCs empties) or expire (24h TTL), so - `size(ckVtime) / size(ckIndex)` stays bounded and does not grow without limit. - -## Scenarios - -All on a single base queue (worst case for one queue's ZSETs), dedicated Redis, -flag OFF then ON with identical load. - -- **Cardinality sweep (memory).** Enqueue N distinct concurrency keys, one - message each, N in {100, 1_000, 10_000, 50_000}. Measure the Redis memory - footprint at rest for each N, OFF vs ON. This isolates the storage cost with no - dequeue activity. -- **Steady-state load (CPU).** At each N, after building cardinality, run a fixed - 60s workload of enqueue + batched dequeue (`maxCount 10`) + ack at a capped - concurrency, so keys are continuously served and re-registered. Measure Redis - CPU and per-command time over the window, OFF vs ON. -- **Churn / tombstone (memory under adversarial drain).** Build 10k keys, then - drain them via `ack` (not via vtime dequeue) while enqueuing new keys, for a - fixed duration. Sample `size(ckVtime)` and `size(ckIndex)` over time and confirm - the ratio stays bounded (self-heal + TTL), not monotonically growing. - -## Metrics and collection (exact commands) - -Use a second plain Redis client for measurement so it does not perturb the -harness. `redis-cli` shown; the harness issues the same via ioredis. - -Memory: - -- Totals: `INFO memory` -> `used_memory`, `used_memory_dataset`. Delta ON vs OFF - at each N is the incremental footprint. -- Per structure (exact bytes): `MEMORY USAGE {org...}:queue::ckIndex` and - `MEMORY USAGE {org...}:queue::ckVtime`. Report both and the ratio. -- Encoding: `OBJECT ENCODING ` (listpack vs skiplist) at each N, to - mark the transition. - -CPU: - -- Process CPU over the load window: `INFO cpu` -> `used_cpu_user` + - `used_cpu_sys`, sampled before and after the fixed 60s load; the delta is Redis - CPU-seconds consumed. Divide by op count for CPU-per-op. -- Per-command time: `CONFIG RESETSTAT` before the window, then `INFO commandstats` - after -> `cmdstat_zadd`, `cmdstat_zrange`, `cmdstat_zrangebyscore`, - `cmdstat_zscore`, `cmdstat_get`, `cmdstat_set`, `cmdstat_expire` - (`calls`, `usec`, `usec_per_call`). Compare OFF vs ON. -- Cross-check (optional, OS-level): `pidstat -p 1` over the window, or - `redis-cli --latency` / `--latency-history` for command latency. - -Derived: - -- Memory vs N curve (expect linear, slope ~ per-member bytes), OFF and ON. -- CPU-per-op vs N curve (expect flat-ish / log), OFF/ON ratio. -- `size(ckVtime)/size(ckIndex)` over time in the churn scenario (expect bounded). - -## A/B procedure - -1. Dedicated throwaway Redis; `FLUSHDB` between arms and between cardinality - points. Warm up once. -2. For each N in the sweep, for each arm (OFF, ON): - - Build N keys (enqueue N distinct concurrency keys). - - Snapshot memory (`used_memory`, `MEMORY USAGE` of both ZSETs, `OBJECT - ENCODING`). - - `CONFIG RESETSTAT`; run the fixed 60s steady load; snapshot `INFO cpu` and - `INFO commandstats`. - - Record, then `FLUSHDB`. -3. N trials of the load window per point; report median for CPU (memory at rest is - near-deterministic). -4. Run the churn scenario once per arm at N = 10k. - -Only the OFF-vs-ON delta and the shape of the growth curves are reported; absolute -throughput on a single box is not prod scale. - -## Results template - -### Memory at rest, per cardinality (single base queue) - -| keys (N) | used_memory OFF | used_memory ON | delta | MEMORY USAGE ckIndex | MEMORY USAGE ckVtime | ckVtime/ckIndex | ckVtime encoding | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 100 | | | | | | | | -| 1,000 | | | | | | | | -| 10,000 | | | | | | | | -| 50,000 | | | | | | | | - -### Redis CPU under 60s steady load, per cardinality - -| keys (N) | CPU-sec OFF | CPU-sec ON | delta | dequeue usec/call OFF | dequeue usec/call ON | delta | total redis calls OFF/ON | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 100 | | | | | | | | -| 1,000 | | | | | | | | -| 10,000 | | | | | | | | - -### Tombstone drift (N=10k, drain via ack) - -| elapsed | size(ckIndex) | size(ckVtime) | ratio | -| --- | --- | --- | --- | -| 0s | | | | -| 30s | | | | -| 60s | | | | - -## Harness - -Extend the existing micro-benchmark -(`../../src/run-queue/bench/ckMicroBench.bench.test.ts`), which already drives a -real `RunQueue` against an external Redis and reads `INFO commandstats`. Add a -resource/cardinality mode that: builds N variants, snapshots `used_memory` + -`MEMORY USAGE` of the base queue's `ckIndex`/`ckVtime` + `OBJECT ENCODING`, runs a -fixed-duration steady load while sampling `INFO cpu`/`commandstats`, and emits the -tables above. The churn scenario reuses the same enqueue/ack primitives with a -drain-by-ack loop and periodic `ZCARD` sampling of both ZSETs. - -Reuse the same dedicated Redis and the OFF-vs-ON constructor-flag pattern, so this -arm, like the micro-benchmark, needs no webapp or redeploy. diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore b/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore deleted file mode 100644 index ebeab39cd7e..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Standalone deploy/run artifacts (this project is installed + run outside the monorepo) -node_modules/ -.trigger/ -e2e-results/ -.env.bench -pnpm-lock.yaml -package-lock.json diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md b/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md deleted file mode 100644 index afa3cfc70fa..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# CK virtual-time end-to-end bench tasks - -A tiny self-contained trigger.dev project used as the END-TO-END arm of the CK -virtual-time A/B (see `../2026-07-26-ck-vtime-benchmark.md`). It deploys to a -self-hosted instance and is not part of the monorepo build. - -- `src/trigger/ckBench.ts` — one `ck-bench` task on a shared base queue; per-run - `concurrencyKey` makes the CK variants; a slot hold forces contention. -- `src/loadgen.ts` — noisy-neighbor load: tenant A floods across many keys, - tenant B sends a few; each run carries a per-run `region`. Captures every run - id at trigger time and writes a manifest (`e2e-results/manifest-.json`). -- `src/waitdrain.ts` — polls the manifest's run ids until all are terminal. -- `src/collect.ts` — reads each run's `createdAt`/`startedAt` by id and reports - per-tenant enqueue->start latency p50/p95/p99. -- `src/preflight.ts` — fires one run per region to validate routing + access. - -Everything reads credentials from the environment (`TRIGGER_API_URL`, -`TRIGGER_SECRET_KEY`); nothing is hard-coded. The feature flag is server-side and -is flipped by the operator between arms, not by this project. Exact instance -coordinates and the toggle live in the operator runbook, kept outside this repo. - -## Deploy from a standalone checkout, not inside the monorepo - -This project must be installed and deployed from OUTSIDE the pnpm monorepo tree -(copy it somewhere with no `pnpm-workspace.yaml` ancestor). Inside the workspace, -`pnpm install` binds to the workspace and links the local SDK instead of the -pinned published one. Use npm for the standalone copy (it avoids pnpm's -build-script policy on esbuild): - -```bash -cp -r ~/ck-vtime-e2e && cd ~/ck-vtime-e2e -npm install # CLI + SDK pinned to the same version (4.5.7) -``` - -## Deploy - -The 4.5.7 CLI has no `--self-hosted` flag; self-hosted is implicit from the -profile's API URL. Do NOT pass `--local-build` (it routes to a cloud-only ECR -credential endpoint and fails on self-hosted). The push then rides the host's -existing docker login to the registry. `--network host` is required so the -in-build indexer step can reach the instance API (e.g. over a tailnet): - -```bash -TRIGGER_PROJECT_REF= \ - ./node_modules/.bin/trigger deploy -e prod --network host --profile -p -``` - -Deploy to the `prod` environment: the `dev` environment short-circuits -worker-group routing, so dev runs never reach the managed regions. - -## Run one A/B arm - -The flag is flipped + redeployed server-side by the operator; run this once per -arm with the matching `ARM`. This instance's `runs.list` (ClickHouse-backed) can -be empty, so the harness enumerates runs from the trigger-time id manifest, not -by tag. - -```bash -export TRIGGER_API_URL= -export TRIGGER_SECRET_KEY= - -# validate region routing once -./node_modules/.bin/tsx src/preflight.ts - -# one arm (env knobs: HOLD_MS, A_KEYS, A_PER_KEY, B_KEYS, B_PER_KEY, REGIONS) -ARM=off BATCH=off-1 ./node_modules/.bin/tsx src/loadgen.ts -BATCH=off-1 ./node_modules/.bin/tsx src/waitdrain.ts -ARM=off BATCH=off-1 ./node_modules/.bin/tsx src/collect.ts -``` - -Results land in `e2e-results/` (`manifest-.json`, `e2e--.json`, -`e2e-summary.md`). diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json b/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json deleted file mode 100644 index 63ec78ff547..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "ck-vtime-e2e-bench", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "End-to-end noisy-neighbor load harness for the CK virtual-time scheduling A/B. Deployed to a self-hosted trigger.dev instance; not part of the monorepo build.", - "scripts": { - "loadgen": "tsx src/loadgen.ts", - "collect": "tsx src/collect.ts" - }, - "dependencies": { - "@trigger.dev/sdk": "4.5.7" - }, - "devDependencies": { - "trigger.dev": "4.5.7", - "tsx": "^4.19.0", - "typescript": "^5.5.0" - } -} diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts deleted file mode 100644 index ed299755d57..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/collect.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Collect per-tenant enqueue->start latency for one END-TO-END arm, from a batch - * manifest (retrieve by id; runs.list is unreliable on this instance). - * - * Metric per run = startedAt - createdAt (run-start latency: queue wait + dequeue - * + worker pickup). Headline is tenant B (few keys): bounded under vtime, grows - * with A's backlog under baseline. - * - * Env: TRIGGER_API_URL, TRIGGER_SECRET_KEY. Args (env): BATCH ARM OUT POLL_CONCURRENCY - */ -import { configure, runs } from "@trigger.dev/sdk"; -import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; - -function pct(sorted: number[], p: number): number { - if (sorted.length === 0) return NaN; - const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); - return sorted[idx]!; -} -function summarize(xs: number[]) { - const s = [...xs].sort((a, b) => a - b); - const mean = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; - return { count: xs.length, mean, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99) }; -} - -async function main() { - configure({ - baseURL: process.env.TRIGGER_API_URL!, - accessToken: process.env.TRIGGER_SECRET_KEY!, - }); - const batch = process.env.BATCH!; - const arm = (process.env.ARM ?? "off") as "off" | "on"; - const outDir = process.env.OUT ?? "./e2e-results"; - const conc = Number(process.env.POLL_CONCURRENCY ?? "20"); - const manifest = JSON.parse(readFileSync(`${outDir}/manifest-${batch}.json`, "utf8")); - const entries: { id: string; tenant: string }[] = manifest.runs; - - const waitsByTenant = new Map(); - let missingStart = 0; - let i = 0; - async function w() { - while (i < entries.length) { - const e = entries[i++]!; - try { - const r = await runs.retrieve(e.id); - const c = r.createdAt?.getTime(); - const s = r.startedAt?.getTime(); - if (c === undefined || s === undefined) { - missingStart++; - continue; - } - const arr = waitsByTenant.get(e.tenant) ?? waitsByTenant.set(e.tenant, []).get(e.tenant)!; - arr.push(s - c); - } catch { - missingStart++; - } - } - } - await Promise.all(Array.from({ length: Math.min(conc, entries.length) }, w)); - - const perTenant: Record> = {}; - for (const [t, xs] of waitsByTenant) perTenant[t] = summarize(xs); - const report = { - arm, - batch, - total: entries.length, - missingStart, - unit: "ms (startedAt - createdAt)", - perTenant, - }; - - mkdirSync(outDir, { recursive: true }); - writeFileSync(`${outDir}/e2e-${batch}-${arm}.json`, JSON.stringify(report, null, 2)); - - const rows = Object.entries(perTenant) - .map( - ([t, s]) => - `| ${batch} | ${arm} | ${t} | ${s.count} | ${s.mean.toFixed(0)} | ${s.p50.toFixed(0)} | ${s.p95.toFixed(0)} | ${s.p99.toFixed(0)} |` - ) - .join("\n"); - appendFileSync( - `${outDir}/e2e-summary.md`, - `\n\n| batch | arm | tenant | runs | mean ms | p50 | p95 | p99 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows}\n` - ); - console.log( - `[collect] arm=${arm} batch=${batch} runs=${entries.length} missingStart=${missingStart}` - ); - console.log(JSON.stringify(perTenant, null, 2)); -} -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts deleted file mode 100644 index ad7e686b084..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/loadgen.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Noisy-neighbor load generator for the CK virtual-time END-TO-END A/B arm. - * - * Tenant A floods the base queue across MANY concurrency keys (the sharding case - * a per-key cap cannot fix); tenant B sends a few runs on a couple of keys. Each - * run carries a per-run `region` so load spreads across the managed worker groups. - * - * This instance's runs.list (ClickHouse-backed) is unreliable, so we capture each - * run id at trigger time via individual tasks.trigger calls (fired with bounded - * concurrency so they still enqueue near-simultaneously as a backlog) and write a - * manifest. collect.ts / waitdrain.ts retrieve by id (the Postgres path). - * - * Auth (env): TRIGGER_API_URL, TRIGGER_SECRET_KEY (prod env secret key). - * Config (env): ARM=off|on BATCH= HOLD_MS A_KEYS A_PER_KEY B_KEYS B_PER_KEY - * REGIONS=trigger-regiona,trigger-regionb,trigger-regionc OUT=./e2e-results - * FIRE_CONCURRENCY=30 - */ -import { configure, tasks } from "@trigger.dev/sdk"; -import { mkdirSync, writeFileSync } from "node:fs"; -import type { ckBenchTask } from "./trigger/ckBench.js"; - -const envInt = (n: string, d: number) => - process.env[n] === undefined ? d : Number(process.env[n]); - -async function main() { - const apiURL = process.env.TRIGGER_API_URL; - const accessToken = process.env.TRIGGER_SECRET_KEY; - if (!apiURL || !accessToken) throw new Error("Set TRIGGER_API_URL and TRIGGER_SECRET_KEY."); - configure({ baseURL: apiURL, accessToken }); - - const arm = (process.env.ARM ?? "off") as "off" | "on"; - const batch = process.env.BATCH ?? `b${arm}`; - const holdMs = envInt("HOLD_MS", 1500); - const aKeys = envInt("A_KEYS", 30); - const aPerKey = envInt("A_PER_KEY", 8); - const bKeys = envInt("B_KEYS", 3); - const bPerKey = envInt("B_PER_KEY", 10); - const outDir = process.env.OUT ?? "./e2e-results"; - const fireConcurrency = envInt("FIRE_CONCURRENCY", 30); - const regions = (process.env.REGIONS ?? "trigger-regiona,trigger-regionb,trigger-regionc") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - type Item = { tenant: "A" | "B"; key: string; region: string }; - const items: Item[] = []; - let n = 0; - const push = (tenant: "A" | "B", key: string) => { - items.push({ tenant, key, region: regions[n % regions.length]! }); - n++; - }; - for (let k = 0; k < aKeys; k++) for (let i = 0; i < aPerKey; i++) push("A", `A-${k}`); - for (let k = 0; k < bKeys; k++) for (let i = 0; i < bPerKey; i++) push("B", `B-${k}`); - // interleave so B does not all arrive first - items.sort((x, y) => x.key.localeCompare(y.key)); - - console.log( - `[loadgen] arm=${arm} batch=${batch} total=${items.length} (A=${aKeys}x${aPerKey}, B=${bKeys}x${bPerKey}) regions=${regions.join(",")} holdMs=${holdMs}` - ); - - const manifest: { - batch: string; - arm: string; - triggeredAt: number; - runs: { id: string; tenant: string; key: string; region: string }[]; - } = { batch, arm, triggeredAt: Date.now(), runs: [] }; - - let idx = 0; - let failed = 0; - async function worker() { - while (idx < items.length) { - const it = items[idx++]!; - try { - const h = await tasks.trigger( - "ck-bench", - { holdMs, tenant: it.tenant, key: it.key, arm, batch }, - { - concurrencyKey: it.key, - region: it.region, - tags: ["ckbench", `arm:${arm}`, `tenant:${it.tenant}`, `batch:${batch}`], - } - ); - manifest.runs.push({ id: h.id, tenant: it.tenant, key: it.key, region: it.region }); - } catch (e) { - failed++; - if (failed <= 3) console.error(`[loadgen] trigger failed:`, (e as Error).message); - } - } - } - await Promise.all(Array.from({ length: Math.min(fireConcurrency, items.length) }, worker)); - - mkdirSync(outDir, { recursive: true }); - const path = `${outDir}/manifest-${batch}.json`; - writeFileSync(path, JSON.stringify(manifest, null, 2)); - console.log( - `[loadgen] triggered ${manifest.runs.length}/${items.length} (failed ${failed}). manifest: ${path}` - ); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts deleted file mode 100644 index cc7b1bf8e0d..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/preflight.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Region preflight: trigger one ck-bench run per worker group and confirm each - * starts (validates region routing + isWorkerGroupAllowedForProject before the - * real load). Reads TRIGGER_API_URL / TRIGGER_SECRET_KEY from env. - */ -import { configure, runs, tasks } from "@trigger.dev/sdk"; -import type { ckBenchTask } from "./trigger/ckBench.js"; - -const regions = ["trigger-regiona", "trigger-regionb", "trigger-regionc"]; - -async function main() { - configure({ - baseURL: process.env.TRIGGER_API_URL!, - accessToken: process.env.TRIGGER_SECRET_KEY!, - }); - - const handles: { region: string; id: string }[] = []; - for (const region of regions) { - const h = await tasks.trigger( - "ck-bench", - { holdMs: 2000, tenant: "preflight", key: `pf-${region}`, arm: "on", batch: "preflight" }, - { concurrencyKey: `pf-${region}`, region, tags: ["ckbench", "preflight", `region:${region}`] } - ); - handles.push({ region, id: h.id }); - console.log(`[preflight] triggered ${region}: ${h.id}`); - } - - // Poll up to ~90s for each to leave the queue and reach a terminal/executing state. - const deadline = Date.now() + 90_000; - const seen = new Map(); - while (Date.now() < deadline && seen.size < handles.length) { - for (const { region, id } of handles) { - if (seen.has(id)) continue; - const r = await runs.retrieve(id); - if (["EXECUTING", "COMPLETED", "FAILED", "CRASHED", "SYSTEM_FAILURE"].includes(r.status)) { - seen.set(id, r.status); - const started = r.startedAt ? r.startedAt.getTime() - r.createdAt.getTime() : undefined; - console.log( - `[preflight] ${region} ${id} -> ${r.status}${started !== undefined ? ` (start wait ${started}ms)` : ""}` - ); - } - } - if (seen.size < handles.length) await new Promise((r) => setTimeout(r, 3000)); - } - - const stuck = handles.filter((h) => !seen.has(h.id)); - if (stuck.length) { - console.log( - `[preflight] STILL QUEUED after 90s (possible access/routing issue): ${stuck.map((s) => `${s.region}:${s.id}`).join(", ")}` - ); - process.exit(2); - } - console.log("[preflight] all regions accepted + started. Routing + access OK."); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts deleted file mode 100644 index 397061dfdcb..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/trigger/ckBench.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { logger, queue, task } from "@trigger.dev/sdk"; - -// One shared base queue. Per-run concurrencyKey (set at trigger time) creates the -// concurrency-key variants whose dequeue ORDER the change under test governs. -// -// concurrencyLimit is the PER-KEY lane width. Set it to 1 so each key holds one -// slot at a time; cross-key contention is then forced by the ENVIRONMENT -// concurrency ceiling (pin RuntimeEnvironment.maximumConcurrencyLimit low, e.g. -// 5, on the prod env of the bench project). With N keys all wanting to run and -// only a few env slots, the CK dequeue decides who starts first: that ordering -// is exactly OFF (age) vs ON (virtual time). -export const ckBenchQueue = queue({ - name: "ck-bench", - concurrencyLimit: 1, -}); - -export type CkBenchPayload = { - // logical hold: how long the run occupies its slot, in ms - holdMs: number; - // carried through for grouping in analysis (also set as a tag by the loadgen) - tenant: string; - key: string; - arm: "off" | "on"; - batch: string; -}; - -export const ckBenchTask = task({ - id: "ck-bench", - queue: ckBenchQueue, - run: async (payload: CkBenchPayload) => { - logger.info("ck-bench start", { - tenant: payload.tenant, - key: payload.key, - arm: payload.arm, - batch: payload.batch, - }); - // Occupy the slot for the hold so concurrency actually contends. A plain - // timer is enough: this task exists only to hold a slot, not to do work. - await new Promise((resolve) => setTimeout(resolve, payload.holdMs)); - return { tenant: payload.tenant, key: payload.key, arm: payload.arm }; - }, -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts deleted file mode 100644 index 38851517ed9..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/src/waitdrain.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Wait until every run in a batch manifest reaches a terminal state (so startedAt - * is populated), by retrieving each run id (Postgres path; runs.list is unreliable - * on this instance). Env: TRIGGER_API_URL, TRIGGER_SECRET_KEY. - * Args (env): BATCH= OUT=./e2e-results TIMEOUT_S=420 POLL_CONCURRENCY=20 - */ -import { configure, runs } from "@trigger.dev/sdk"; -import { readFileSync } from "node:fs"; - -const TERMINAL = [ - "COMPLETED", - "FAILED", - "CRASHED", - "SYSTEM_FAILURE", - "CANCELED", - "TIMED_OUT", - "EXPIRED", -]; - -async function statusMap(ids: string[], conc: number): Promise> { - const out = new Map(); - let i = 0; - async function w() { - while (i < ids.length) { - const id = ids[i++]!; - try { - const r = await runs.retrieve(id); - out.set(id, r.status); - } catch { - out.set(id, "ERR_RETRIEVE"); - } - } - } - await Promise.all(Array.from({ length: Math.min(conc, ids.length) }, w)); - return out; -} - -async function main() { - configure({ - baseURL: process.env.TRIGGER_API_URL!, - accessToken: process.env.TRIGGER_SECRET_KEY!, - }); - const batch = process.env.BATCH!; - const outDir = process.env.OUT ?? "./e2e-results"; - const timeoutMs = Number(process.env.TIMEOUT_S ?? "420") * 1000; - const conc = Number(process.env.POLL_CONCURRENCY ?? "20"); - const manifest = JSON.parse(readFileSync(`${outDir}/manifest-${batch}.json`, "utf8")); - const ids: string[] = manifest.runs.map((r: any) => r.id); - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const sm = await statusMap(ids, conc); - let terminal = 0; - for (const s of sm.values()) if (TERMINAL.includes(s)) terminal++; - console.log(`[waitdrain] ${batch}: terminal ${terminal}/${ids.length}`); - if (terminal >= ids.length) { - console.log("[waitdrain] drained."); - return; - } - await new Promise((r) => setTimeout(r, 5000)); - } - console.log("[waitdrain] TIMEOUT before full drain."); - process.exit(2); -} -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts b/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts deleted file mode 100644 index 36f3572ee09..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/trigger.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "@trigger.dev/sdk"; - -// The project ref is passed on the CLI (`-p `) at deploy time, so it is not -// hard-coded here. This keeps the harness portable and secret-free in the repo. -export default defineConfig({ - project: process.env.TRIGGER_PROJECT_REF ?? "proj_REPLACE_ME", - runtime: "node", - logLevel: "info", - maxDuration: 120, - dirs: ["./src/trigger"], -}); diff --git a/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json b/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json deleted file mode 100644 index 282634969b0..00000000000 --- a/internal-packages/run-engine/design/benchmarks/e2e-tasks/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "types": ["node"], - "noEmit": true - }, - "include": ["src/**/*.ts", "trigger.config.ts"] -} diff --git a/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md b/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md deleted file mode 100644 index 4369e45d0d8..00000000000 --- a/internal-packages/run-engine/design/benchmarks/results-2026-07-27.md +++ /dev/null @@ -1,80 +0,0 @@ -# CK virtual-time scheduling: A/B benchmark results (2026-07-27) - -Run on a **local homelab box, not production**. It is a *production-like* -topology only in shape: one shared control plane (Postgres + one shared run-queue -Redis + ClickHouse + the webapp/engine on this branch) and three separate managed -worker clusters registered as distinct worker groups, all as containers on a -single machine. No production data, traffic, or infrastructure was involved. -Method and harness: `2026-07-26-ck-vtime-benchmark.md`. - -All numbers are **relative** (flag OFF vs ON, identical load, same box). Absolute -throughput on this single-box homelab is nowhere near prod scale and is not -reported as such; only the OFF-vs-ON delta is meaningful. - -## Arm 1: queue-level micro-benchmark (the isolated, defensible numbers) - -Real `RunQueue` driven directly against a dedicated Redis, flag OFF (age-ordered -CK dequeue) vs ON (virtual time), 5 trials, quantum 1 / window multiplier 3. -Every arm served every message exactly once (conservation checked), so the -comparison is sound. Wait is in logical dequeue steps. - -| scenario | metric | baseline (OFF) | vtime (ON) | delta | -| --- | --- | --- | --- | --- | -| **ckSkew** (heavy backlog + light keys) | light-key first-serve step | 480 | 4 | **−99%** | -| | light-key wait p50 | 556 | 96 | −83% | -| | light-key wait p95 | 628 | 188 | −70% | -| | light-key wait p99 | 636 | 196 | −69% | -| | Jain fairness (contention) | 0.34 | 1.00 | +192% | -| | drain step (work conservation) | 636 | 636 | 0% | -| **ckTrickle** (bulk + trickle keys) | trickle first-serve step | 480 | 4 | −99% | -| | trickle wait p95 | 592 | 172 | −71% | -| | Jain fairness | 0.50 | 1.00 | +100% | -| **ckSybil** (20 keys flood + 1 light; caps can't fix) | light first-serve step | 25 | 2 | −92% | -| | light wait p95 | 34 | 27 | −21% | -| **ckManyKeys** (61 variants > pass-1 window) | light first-serve step | 72 | 9 | −88% | -| | drain step (no starvation) | 81 | 79 | drains fully | -| **ckBalanced** (4 symmetric keys, no-harm) | worst-key wait p95 | 92 | 92 | 0% | -| **ckHeavyIdle** (lone key, work conservation) | drain step | 59 | 59 | 0% (exact) | -| (all scenarios) | Redis ops per dequeue+ack | baseline | +7% to +23% | cost | - -Reading it: a light key behind a backlog goes from waiting out the whole backlog -(first-serve step 480) to being served almost immediately (4), wait p95 drops -~70%, and contention fairness goes from lopsided (Jain 0.34) to even (1.00). The -symmetric and lone-key cases are untouched, and drain steps are identical, so the -fair order is work-conserving and does no harm. The cost is a modest per-dequeue -Redis op increase. - -## Arm 2: end-to-end on the three worker regions (realism check) - -Deployed task on one shared base queue, per-run concurrency keys, environment -concurrency ceiling pinned to 5 so the CK dequeue order is the bottleneck. -Noisy-neighbor load: tenant A floods across 30 keys (240 runs), tenant B sends 30 -runs across 3 keys, runs spread across the three regions, 1.5s hold. Metric is -per-run start latency (`startedAt - createdAt`, ms). Flag flipped OFF vs ON with a -control-plane redeploy between arms; identical load each arm. - -| tenant | metric | baseline (OFF) | vtime (ON) | delta | -| --- | --- | --- | --- | --- | -| **B (victim, 3 keys)** | start latency mean | 221,476 | 136,344 | **−38%** | -| | start latency p50 | 219,901 | 133,930 | **−39%** | -| | start latency p95 | 259,092 | 221,061 | −15% | -| | start latency p99 | 259,136 | 221,078 | −15% | -| A (flood, 30 keys) | start latency mean | 95,130 | 100,136 | +5% | -| A | start latency p50 | 95,049 | 95,689 | ~0% | - -Reading it: under age order the light tenant waits behind the flood (mean ~221s); -with virtual time it takes fair turns and drops to ~136s (mean −38%, p50 −39%), -while the flood tenant is essentially unchanged (+5%, it stops jumping the queue). -The smaller p95/p99 gain reflects per-**key** fairness: tenant B's keys carry -deeper per-key backlogs here (10 runs/key vs the flood's 8), so B's last runs -stay in the fair rotation longer. Absolute latencies are the cap-5 serialization -plus per-run worker startup on a single box; only the OFF-vs-ON delta is the -signal. - -## Bottom line - -Both arms agree: the fair order removes the starvation of a light concurrency key -behind a large or sharded backlog, does not harm the balanced or lone-key cases, -stays work-conserving, and costs a small, bounded per-dequeue Redis overhead. The -micro-benchmark isolates the scheduler (the defensible numbers); the end-to-end -run confirms the same effect on real deployed runs across the worker regions. diff --git a/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md b/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md deleted file mode 100644 index e62826e8e2e..00000000000 --- a/internal-packages/run-engine/design/benchmarks/results-cardinality-2026-07-28.md +++ /dev/null @@ -1,82 +0,0 @@ -# CK virtual-time scheduling: Redis CPU + memory vs cardinality (2026-07-28) - -Answers the review question: how do these changes affect the run-queue Redis CPU -and memory, and how do both react as concurrency-key cardinality grows? - -Run on a **local homelab box, not production**, against a dedicated Redis -configured for measurement (no RDB/AOF in the sampling windows, `maxmemory 0`, -zset listpack thresholds at their defaults so the encoding boundary sits at 128). -All numbers are **relative** (flag OFF vs ON, identical load, same box); absolute -throughput is not prod scale. Method: `2026-07-28-ck-vtime-resource-cardinality-plan.md`. -Server-side metrics (`INFO memory`/`cpu`/`commandstats`, `MEMORY USAGE`, -`OBJECT ENCODING`) are RTT-independent. - -## Memory at rest (single base queue, one queued message per key) - -| keys (N) | used_memory OFF | used_memory ON | ON-OFF delta | ckIndex | ckVtime | ckVtime/ckIndex | ckVtime encoding | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 100 | 1.84 MB | 1.93 MB | +0.09 MB | 6.9 KB | 6.2 KB | 0.89 | listpack | -| 1,000 | 2.42 MB | 2.58 MB | +0.15 MB | 131 KB | 131 KB | 1.00 | skiplist | -| 10,000 | 9.12 MB | 10.4 MB | +1.26 MB | 1.42 MB | 1.42 MB | 1.00 | skiplist | -| 50,000 | 39.1 MB | 45.6 MB | +6.51 MB | 7.55 MB | 7.54 MB | 1.00 | skiplist | - -The `:ckVtime` ZSET is the whole added footprint, and it is essentially a second -copy of `:ckIndex`: same members (the full CK-variant queue names), one extra -8-byte score, so `MEMORY USAGE(ckVtime) ~= MEMORY USAGE(ckIndex)` once past the -listpack boundary. Cost is linear in cardinality at roughly **150 bytes per -concurrency key** on top of the index the queue already keeps: about +1.3 MB for -a queue with 10k keys, +6.5 MB at 50k. It is bounded by live cardinality (entries -are GC'd when a variant drains) and expires on the 24h state TTL. The -`used_memory` delta tracks the direct `MEMORY USAGE(ckVtime)` figure to within -allocator noise. The listpack->skiplist transition lands between 100 and 1,000 -keys as expected. - -## Redis CPU under an identical workload (2,000 rounds, ~42k script calls) - -Same logical workload both arms (enqueue + batched dequeue + ack), so the -`evalsha` call count is identical and the difference is pure vtime overhead. Note -the RunQueue Lua runs as `EVALSHA`, so per-`redis.call` costs inside a script are -not separable in `commandstats`; the reportable signals are total Redis CPU and -aggregate `evalsha` time per call. - -| keys (N) | CPU-sec OFF | CPU-sec ON | delta | overhead | evalsha usec/call OFF | evalsha usec/call ON | -| --- | --- | --- | --- | --- | --- | --- | -| 100 | 1.34 | 1.49 | +0.15 | +12% | 22.3 | 26.2 | -| 1,000 | 1.34 | 1.44 | +0.10 | +7% | 22.2 | 24.8 | -| 10,000 | 1.38 | 1.45 | +0.07 | +5% | 23.8 | 25.8 | - -CPU overhead does not grow with cardinality, it shrinks: +12% at 100 keys down to -+5% at 10k. Per-script cost stays roughly flat (about +2 to +4 usec/call, ~25 -usec/call at every cardinality), which is what the design predicts: the pass-1 -dequeue window is fixed (`maxCount * multiplier`, default 30) and independent of -N, and the added ZSET ops are O(log N), so `log2(10000) ~= 13` adds a negligible -constant. The overhead falls as a percentage because that fixed per-call cost is -amortised over more work as the queue grows. - -## Membership under sustained churn (N = 10,000, flag ON) - -60 rounds of continuous registration + drain (fresh keys enqueued while others -are served and acked), holding cardinality at 10k: - -| round | ckIndex card | ckVtime card | ratio | -| --- | --- | --- | --- | -| 0 | 10,000 | 10,000 | 1.0 | -| 20 | 10,000 | 10,000 | 1.0 | -| 40 | 10,000 | 10,000 | 1.0 | -| 59 | 10,000 | 10,000 | 1.0 | - -`ckVtime` membership tracks `ckIndex` exactly throughout: no tombstone -accumulation, no unbounded growth. The bounded/self-healing drift the limitations -doc calls out (ack/TTL/DLQ draining a variant without a vtime GC) stays reclaimed -by the next vtime pass and the state TTL. - -## Bottom line for the cardinality question - -- **Memory** grows linearly with concurrency-key cardinality, adding one - `ckIndex`-sized ZSET per base queue (~150 B/key): a low-single-digit MB even at - 10k keys on one queue, bounded by live cardinality and TTL-reclaimed. A sudden - 10k-key queue costs about +1.3 MB for that queue. -- **CPU** overhead is small and does not scale with cardinality: ~+5 to +12% on - an identical workload, per-script cost flat at ~+2 to +4 usec/call regardless of - N, because the dequeue scan window is fixed and the ZSET ops are O(log N). -- **No runaway state**: `ckVtime` never outgrows `ckIndex` under sustained churn. 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 deleted file mode 100644 index 26a8311d45e..00000000000 --- a/internal-packages/run-engine/design/plans/2026-07-23-ck-virtual-time-scheduling-plan.md +++ /dev/null @@ -1,829 +0,0 @@ -# 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: -`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`. - -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-07-24-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 internal-packages/run-engine/design/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-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). -- `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/internal-packages/run-engine/design/references/README.md b/internal-packages/run-engine/design/references/README.md deleted file mode 100644 index 2cd163408cc..00000000000 --- a/internal-packages/run-engine/design/references/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Run-queue multi-tenant fairness: spike references - -Reference material for the implementation plan -`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 -`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/internal-packages/run-engine/design/references/diagrams/fairness-how-it-works.png b/internal-packages/run-engine/design/references/diagrams/fairness-how-it-works.png deleted file mode 100644 index 98e33bb88a65c01c76f0c9327dd12739fc71cf96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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{% 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 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 deleted file mode 100644 index c360f78328f..00000000000 --- a/internal-packages/run-engine/design/references/run-queue-fairness-base-queue-findings.md +++ /dev/null @@ -1,168 +0,0 @@ -# Fair-queueing spike: findings - -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 -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/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 deleted file mode 100644 index f89257709d3..00000000000 --- a/internal-packages/run-engine/design/references/run-queue-fairness-caps-vs-scheduling-findings.md +++ /dev/null @@ -1,198 +0,0 @@ -# Caps vs scheduling: reconciliation findings - -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. - -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/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 deleted file mode 100644 index 04f615c9023..00000000000 --- a/internal-packages/run-engine/design/references/run-queue-fairness-ck-findings.md +++ /dev/null @@ -1,122 +0,0 @@ -# Per-concurrency-key fairness spike: findings - -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 -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/internal-packages/run-engine/design/references/run-queue-fairness-research.md b/internal-packages/run-engine/design/references/run-queue-fairness-research.md deleted file mode 100644 index afb831de568..00000000000 --- a/internal-packages/run-engine/design/references/run-queue-fairness-research.md +++ /dev/null @@ -1,133 +0,0 @@ -# Queue-fairness research (grounding for the caps-vs-scheduling reconciliation) - -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 -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. 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 deleted file mode 100644 index 43a6cd43308..00000000000 --- a/internal-packages/run-engine/src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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. diff --git a/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts b/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts deleted file mode 100644 index 88051f71ae0..00000000000 --- a/internal-packages/run-engine/src/run-queue/bench/ckMicroBench.bench.test.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { createRedisClient } from "@internal/redis"; -import { trace } from "@internal/tracing"; -import { Logger } from "@trigger.dev/core/logger"; -import { Decimal } from "@trigger.dev/database"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { performance } from "node:perf_hooks"; -import { describe, expect, it } from "vitest"; -import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; -import { RunQueue } from "../index.js"; -import { RunQueueFullKeyProducer } from "../keyProducer.js"; -import type { InputPayload } from "../types.js"; - -// CK virtual-time PRIMARY arm: a queue-level A/B micro-benchmark. -// -// This drives the REAL RunQueue against an EXTERNAL Redis (not a testcontainer) -// and compares flag OFF (age-ordered CK dequeue) vs flag ON (SFQ virtual time) -// under identical load. The flag is a RunQueue constructor option, so one bench -// process runs both arms in-process: no webapp, no redeploy. -// -// It is a deliberate, defensible A/B: the two arms enqueue the exact same -// messages with the exact same timestamps and drive the exact same step loop. -// The isolation and reuse are inherited from tests/ckVtimeFairness.test.ts (the -// step loop, scenario shapes, conservation checks are the same); this file adds -// wall-clock dequeue latency, a Redis op-count, N trials, and file output. -// -// It is INERT in CI: the suite only runs when CK_BENCH_REDIS_URL is set, so -// `pnpm run test` collects it as a skipped describe and never touches a network. -// -// Run it (from the run-engine package, pointed at a dedicated throwaway Redis): -// CK_BENCH_REDIS_URL=redis://127.0.0.1:6399 \ -// CK_BENCH_TRIALS=5 CK_BENCH_OUT=./bench-results \ -// pnpm exec vitest run src/run-queue/bench/ckMicroBench.bench.test.ts -// -// Knob sweep (optional, defaults match production defaults 1 / 3): -// CK_BENCH_QUANTUM=1 CK_BENCH_WINDOW_MULT=3 -// -// WARNING: the bench FLUSHDBs the target Redis between arms. Point it ONLY at a -// dedicated throwaway instance, never at a shared or production Redis. - -const REDIS_URL = process.env.CK_BENCH_REDIS_URL; -const TRIALS = Math.max(1, Number(process.env.CK_BENCH_TRIALS ?? "5")); -const OUT_DIR = process.env.CK_BENCH_OUT ?? "./bench-results"; -const QUANTUM = Math.max(1, Number(process.env.CK_BENCH_QUANTUM ?? "1")); -const WINDOW_MULT = Math.max(1, Number(process.env.CK_BENCH_WINDOW_MULT ?? "3")); -const SCENARIO_FILTER = process.env.CK_BENCH_SCENARIOS?.split(",").map((s) => s.trim()); - -const keys = new RunQueueFullKeyProducer(); - -const testOptions = { - name: "rq", - tracer: trace.getTracer("rq"), - workers: 1, - defaultEnvConcurrency: 25, - logger: new Logger("RunQueue", "error"), - retryOptions: { - maxAttempts: 5, - factor: 1.1, - minTimeoutInMs: 100, - maxTimeoutInMs: 1_000, - randomize: true, - }, - keys, -}; - -const authenticatedEnvDev = { - id: "e1234", - type: "DEVELOPMENT" as const, - maximumConcurrencyLimit: 10, - concurrencyLimitBurstFactor: new Decimal(2.0), - project: { id: "p1234" }, - organization: { id: "o1234" }, -}; - -function redisConn() { - const u = new URL(REDIS_URL!); - return { host: u.hostname, port: Number(u.port || "6379") }; -} - -function createQueue(keyPrefix: string, vtimeEnabled: boolean) { - const conn = redisConn(); - return new RunQueue({ - ...testOptions, - masterQueueConsumersDisabled: true, - workerOptions: { disabled: true }, - ckVirtualTimeScheduling: { - enabled: vtimeEnabled, - quantum: QUANTUM, - scanWindowMultiplier: WINDOW_MULT, - }, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { keyPrefix, host: conn.host, port: conn.port }, - keys, - }), - redis: { keyPrefix, host: conn.host, port: conn.port }, - }); -} - -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; - // Human label for the "victim" the fairness hypothesis is about. - victimLabel: string; - // Classifies a concurrency key as the victim (the light/starved tenant). - isVictim: (ck: string) => boolean; - messages: ScenarioMessage[]; - envConcurrencyLimit: number; - holdSteps: number; - maxSteps: number; -}; - -type ServeRecord = { step: number; ck: string; messageId: string; wallMs: number }; - -type ArmResult = { - serves: ServeRecord[]; - drainStep: number; - contentionByCk: Map; - contentionTotal: number; - callLatenciesMs: number[]; - redisCalls: number; -}; - -// ---- scenario shapes (ported values from the fairness spike, same as the -// tests/ckVtimeFairness.test.ts scenarios; nothing imported from the spike) ---- - -function buildScenarios(): Scenario[] { - const t0 = Date.now() - 500_000; - const all: Scenario[] = []; - - // ckSkew (starvation): heavy 120-msg backlog on an old shared head, 4 light - // keys x 10 on later heads. Serialized contention (env limit 1) is where the - // baseline's age order starves the light keys. - { - 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, - }); - all.push({ - name: "ckSkew", - victimLabel: "light keys", - isVictim: (ck) => ck.startsWith("light"), - messages, - envConcurrencyLimit: 1, - holdSteps: 3, - maxSteps: 1_000, - }); - } - - // ckTrickle (starvation): bulk 120 + 2 trickle keys x 15. - { - 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, - }); - all.push({ - name: "ckTrickle", - victimLabel: "trickle keys", - isVictim: (ck) => ck.startsWith("trickle"), - messages, - envConcurrencyLimit: 1, - holdSteps: 3, - maxSteps: 1_000, - }); - } - - // ckSybil (noisy-neighbor caps cannot fix): 20 attacker keys x 8 (older - // heads) + 1 light key x 10 (newer). 21 variants against a batch of 10. - { - 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 }); - all.push({ - name: "ckSybil", - victimLabel: "light key", - isVictim: (ck) => ck === "light", - messages, - envConcurrencyLimit: 25, - holdSteps: 3, - maxSteps: 300, - }); - } - - // ckManyKeys (cardinality ABOVE the pass-1 window): 60 attacker keys x 8 on a - // tied old head + 1 light key x 10. Probes the stated window limitation: the - // light key must still drain (no permanent starvation), even though 61 - // variants exceed the 30-wide pass-1 window. - { - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 8; i++) - for (let k = 0; k < 60; k++) { - const ck = `att${String(k).padStart(2, "0")}`; - 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 }); - all.push({ - name: "ckManyKeys", - victimLabel: "light key", - isVictim: (ck) => ck === "light", - messages, - envConcurrencyLimit: 25, - holdSteps: 3, - maxSteps: 1_000, - }); - } - - // ckBalanced (no-harm mixed multi-tenant): 4 symmetric keys x 25. - { - 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 }); - all.push({ - name: "ckBalanced", - victimLabel: "worst symmetric key", - isVictim: (ck) => ck.startsWith("bal"), - messages, - envConcurrencyLimit: 4, - holdSteps: 3, - maxSteps: 500, - }); - } - - // ckHeavyIdle (work conservation): a lone key with 60 msgs, nothing else - // contending. Drain-step ON must equal OFF exactly. - { - const messages: ScenarioMessage[] = []; - for (let i = 0; i < 60; i++) - messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i }); - all.push({ - name: "ckHeavyIdle", - victimLabel: "lone key", - isVictim: (ck) => ck === "solo", - messages, - envConcurrencyLimit: 25, - holdSteps: 3, - maxSteps: 300, - }); - } - - return SCENARIO_FILTER ? all.filter((s) => SCENARIO_FILTER.includes(s.name)) : all; -} - -// ---- one arm of one scenario ---- - -async function runArm( - scenario: Scenario, - vtimeEnabled: boolean, - trial: number -): Promise { - const keyPrefix = `ckbench:${scenario.name}:${vtimeEnabled ? "on" : "off"}:t${trial}:`; - const queue = createQueue(keyPrefix, vtimeEnabled); - const conn = redisConn(); - const admin = createRedisClient({ host: conn.host, port: conn.port }, { onError: () => {} }); - - 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, - }); - } - - // Count only steady-state (dequeue + ack) Redis ops, not enqueue. - await admin.call("CONFIG", "RESETSTAT"); - - const shard = 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 contentionByCk = new Map(); - let contentionTotal = 0; - let drainStep = -1; - const callLatenciesMs: number[] = []; - const armStart = performance.now(); - - for (let step = 0; step < scenario.maxSteps && serves.length < total; step++) { - let keysWithBacklog = 0; - for (const count of remaining.values()) if (count > 0) keysWithBacklog++; - - const before = performance.now(); - const messages = await queue.testDequeueFromMasterQueue(shard, env.id, 10); - callLatenciesMs.push(performance.now() - before); - - for (const m of messages) { - const ck = m.message.concurrencyKey ?? ""; - serves.push({ step, ck, messageId: m.messageId, wallMs: performance.now() - armStart }); - remaining.set(ck, (remaining.get(ck) ?? 0) - 1); - inFlight.push({ messageId: m.messageId, servedAtStep: step }); - if (keysWithBacklog >= 2) { - contentionTotal++; - contentionByCk.set(ck, (contentionByCk.get(ck) ?? 0) + 1); - } - if (serves.length === total) drainStep = step; - } - - for (let i = inFlight.length - 1; i >= 0; i--) { - const entry = inFlight[i]!; - if (entry.servedAtStep + scenario.holdSteps <= step) { - await queue.acknowledgeMessage(env.organization.id, entry.messageId, { - skipDequeueProcessing: true, - }); - inFlight.splice(i, 1); - } - } - } - - const stats = await admin.call("INFO", "commandstats"); - const redisCalls = sumRedisCalls(String(stats)); - - return { serves, drainStep, contentionByCk, contentionTotal, callLatenciesMs, redisCalls }; - } finally { - await admin.quit().catch(() => {}); - await queue.quit(); - // Clean slate for the next arm: this is a dedicated throwaway Redis. - const admin2 = createRedisClient(redisConn(), { onError: () => {} }); - await admin2.flushdb().catch(() => {}); - await admin2.quit().catch(() => {}); - } -} - -// ---- metrics ---- - -function sumRedisCalls(info: string): number { - // lines look like: cmdstat_zadd:calls=123,usec=...,... - let total = 0; - for (const line of info.split("\n")) { - const m = line.match(/cmdstat_[^:]+:calls=(\d+)/); - if (m) total += Number(m[1]); - } - return total; -} - -function pct(sorted: number[], p: number): number { - if (sorted.length === 0) return NaN; - const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); - return sorted[idx]!; -} - -function stats(xs: number[]) { - const s = [...xs].sort((a, b) => a - b); - const mean = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; - return { mean, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99) }; -} - -// Jain's fairness index over per-key served counts during contention windows. -// 1.0 = perfectly fair; 1/n = one key took everything. -function jain(counts: number[]): number { - const nonzero = counts.filter((c) => c > 0); - if (nonzero.length === 0) return NaN; - const sum = nonzero.reduce((a, b) => a + b, 0); - const sumSq = nonzero.reduce((a, b) => a + b * b, 0); - return (sum * sum) / (nonzero.length * sumSq); -} - -function victimWaits(arm: ArmResult, s: Scenario): number[] { - return arm.serves.filter((r) => s.isVictim(r.ck)).map((r) => r.step); -} - -function firstServe(arm: ArmResult, s: Scenario): number { - const first = arm.serves.find((r) => s.isVictim(r.ck)); - return first ? first.step : -1; -} - -// ---- the bench ---- - -describe.runIf(!!REDIS_URL)("CK virtual-time micro-benchmark (A/B, external Redis)", () => { - it("runs OFF vs ON across scenarios and writes results", { timeout: 30 * 60_000 }, async () => { - const scenarios = buildScenarios(); - const report: any = { - generatedAtMs: Date.now(), - redisUrl: REDIS_URL, - trials: TRIALS, - knobs: { quantum: QUANTUM, scanWindowMultiplier: WINDOW_MULT }, - scenarios: [] as any[], - }; - - for (const s of scenarios) { - // Wall-clock latency and op-count are pooled/aggregated across trials. - // Step-based metrics are deterministic, so trial 0 is authoritative and - // later trials only assert determinism. - const offCalls: number[] = []; - const onCalls: number[] = []; - const offOps: number[] = []; - const onOps: number[] = []; - let off0: ArmResult | null = null; - let on0: ArmResult | null = null; - - for (let t = 0; t < TRIALS; t++) { - const off = await runArm(s, false, t); - const on = await runArm(s, true, t); - - // Correctness gate: identical load must serve every message exactly - // once in BOTH arms, else the comparison is meaningless. - expect(off.serves.length, `${s.name} OFF served != enqueued`).toBe(s.messages.length); - expect(on.serves.length, `${s.name} ON served != enqueued`).toBe(s.messages.length); - expect(new Set(off.serves.map((r) => r.messageId)).size).toBe(s.messages.length); - expect(new Set(on.serves.map((r) => r.messageId)).size).toBe(s.messages.length); - - offCalls.push(...off.callLatenciesMs); - onCalls.push(...on.callLatenciesMs); - offOps.push(off.redisCalls); - onOps.push(on.redisCalls); - - if (t === 0) { - off0 = off; - on0 = on; - } else { - // determinism of the logical schedule across trials - expect(firstServe(off, s), `${s.name} OFF first-serve not deterministic`).toBe( - firstServe(off0!, s) - ); - expect(firstServe(on, s), `${s.name} ON first-serve not deterministic`).toBe( - firstServe(on0!, s) - ); - expect(on.drainStep).toBe(on0!.drainStep); - } - } - - const offWait = stats(victimWaits(off0!, s)); - const onWait = stats(victimWaits(on0!, s)); - const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; - - const scenarioReport = { - name: s.name, - victim: s.victimLabel, - config: { - envConcurrencyLimit: s.envConcurrencyLimit, - holdSteps: s.holdSteps, - variants: new Set(s.messages.map((m) => m.ck)).size, - messages: s.messages.length, - }, - off: { - victimWait: offWait, - victimFirstServe: firstServe(off0!, s), - drainStep: off0!.drainStep, - jain: jain([...off0!.contentionByCk.values()]), - callLatencyMs: stats(offCalls), - redisOpsMedian: median(offOps), - }, - on: { - victimWait: onWait, - victimFirstServe: firstServe(on0!, s), - drainStep: on0!.drainStep, - jain: jain([...on0!.contentionByCk.values()]), - callLatencyMs: stats(onCalls), - redisOpsMedian: median(onOps), - }, - }; - report.scenarios.push(scenarioReport); - // eslint-disable-next-line no-console - console.log( - `[ckbench] ${s.name}: victim p95 wait OFF=${offWait.p95} ON=${onWait.p95} | drain OFF=${off0!.drainStep} ON=${on0!.drainStep}` - ); - } - - mkdirSync(OUT_DIR, { recursive: true }); - writeFileSync(`${OUT_DIR}/ck-micro-results.json`, JSON.stringify(report, null, 2)); - writeFileSync(`${OUT_DIR}/ck-micro-results.md`, renderMarkdown(report)); - }); -}); - -function fmt(n: number): string { - if (Number.isNaN(n)) return "n/a"; - return Number.isInteger(n) ? String(n) : n.toFixed(2); -} -function delta(off: number, on: number): string { - if (Number.isNaN(off) || Number.isNaN(on)) return "n/a"; - if (off === 0) return on === 0 ? "0" : "+inf"; - const pctChange = ((on - off) / off) * 100; - return `${pctChange >= 0 ? "+" : ""}${pctChange.toFixed(0)}%`; -} - -function renderMarkdown(report: any): string { - const lines: string[] = []; - lines.push(`# CK virtual-time micro-benchmark results`); - lines.push(""); - lines.push( - `Redis \`${report.redisUrl}\`, ${report.trials} trial(s), quantum ${report.knobs.quantum}, window multiplier ${report.knobs.scanWindowMultiplier}.` - ); - lines.push(""); - lines.push( - `Numbers are RELATIVE (same box, same load, flag OFF vs ON). Wait is in logical dequeue steps. Latency is wall-clock per dequeue call on this box and is NOT prod-scale absolute throughput.` - ); - lines.push(""); - lines.push(`| scenario | metric | baseline (OFF) | vtime (ON) | delta |`); - lines.push(`| --- | --- | --- | --- | --- |`); - for (const s of report.scenarios) { - const rows: [string, number, number][] = [ - [`victim wait p50 (${s.victim})`, s.off.victimWait.p50, s.on.victimWait.p50], - [`victim wait p95`, s.off.victimWait.p95, s.on.victimWait.p95], - [`victim wait p99`, s.off.victimWait.p99, s.on.victimWait.p99], - [`victim first-serve step (starvation bound)`, s.off.victimFirstServe, s.on.victimFirstServe], - [`drain step (work conservation)`, s.off.drainStep, s.on.drainStep], - [`Jain fairness index (contention)`, s.off.jain, s.on.jain], - [`dequeue call p95 (ms)`, s.off.callLatencyMs.p95, s.on.callLatencyMs.p95], - [`redis ops (dequeue+ack)`, s.off.redisOpsMedian, s.on.redisOpsMedian], - ]; - rows.forEach(([metric, off, on], i) => { - lines.push( - `| ${i === 0 ? `**${s.name}**` : ""} | ${metric} | ${fmt(off)} | ${fmt(on)} | ${delta(off, on)} |` - ); - }); - } - lines.push(""); - return lines.join("\n"); -} diff --git a/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts b/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts deleted file mode 100644 index c0c0c3e6511..00000000000 --- a/internal-packages/run-engine/src/run-queue/bench/ckResourceBench.bench.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { createRedisClient } from "@internal/redis"; -import { trace } from "@internal/tracing"; -import { Logger } from "@trigger.dev/core/logger"; -import { Decimal } from "@trigger.dev/database"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; -import { RunQueue } from "../index.js"; -import { RunQueueFullKeyProducer } from "../keyProducer.js"; -import type { InputPayload } from "../types.js"; - -// CK virtual-time RESOURCE arm: Redis CPU + memory vs concurrency-key cardinality. -// -// Answers "how do these changes affect the run-queue Redis CPU/memory, and how do -// both react as cardinality grows (e.g. 10k concurrency keys on one base queue)". -// Drives a real RunQueue against an EXTERNAL dedicated Redis, flag OFF vs ON on -// identical load, and reads server-side metrics (INFO memory/cpu/commandstats, -// MEMORY USAGE, OBJECT ENCODING) that are unaffected by client<->server RTT. -// -// Inert unless CK_BENCH_REDIS_URL is set. FLUSHALLs the target between points, so -// point it ONLY at a dedicated throwaway store (a redis-bench lab store). -// -// export CK_BENCH_REDIS_URL="$(lab store url ckbench1)" -// pnpm exec vitest run src/run-queue/bench/ckResourceBench.bench.test.ts -// -// Env knobs: -// CK_RES_MEM_CARDS=100,1000,10000,50000 memory-at-rest sweep -// CK_RES_CPU_CARDS=100,1000,10000 cpu-under-load sweep -// CK_RES_LOAD_OPS=8000 load rounds per cpu point -// CK_RES_CONCURRENCY=64 client concurrency (beats RTT) -// CK_RES_CHURN_CARD=10000 churn/tombstone cardinality -// CK_RES_CHURN_ROUNDS=60 churn sample rounds -// CK_BENCH_OUT=./bench-results -// -// NOTE: the RunQueue Lua commands run via EVALSHA, so redis.call() ops inside a -// script do NOT show up as separate cmdstat_* lines; they roll up under evalsha. -// The reportable CPU signals are therefore total used_cpu over an identical -// workload and aggregate evalsha usec_per_call, not a per-Redis-command split. - -const REDIS_URL = process.env.CK_BENCH_REDIS_URL; -const MEM_CARDS = (process.env.CK_RES_MEM_CARDS ?? "100,1000,10000,50000") - .split(",") - .map((s) => +s.trim()); -const CPU_CARDS = (process.env.CK_RES_CPU_CARDS ?? "100,1000,10000") - .split(",") - .map((s) => +s.trim()); -const LOAD_OPS = +(process.env.CK_RES_LOAD_OPS ?? "8000"); -const CONCURRENCY = +(process.env.CK_RES_CONCURRENCY ?? "64"); -const CHURN_CARD = +(process.env.CK_RES_CHURN_CARD ?? "10000"); -const CHURN_ROUNDS = +(process.env.CK_RES_CHURN_ROUNDS ?? "60"); -const OUT_DIR = process.env.CK_BENCH_OUT ?? "./bench-results"; - -const keys = new RunQueueFullKeyProducer(); - -const testOptions = { - name: "rq", - tracer: trace.getTracer("rq"), - workers: 1, - defaultEnvConcurrency: 1_000_000, - logger: new Logger("RunQueue", "error"), - retryOptions: { - maxAttempts: 5, - factor: 1.1, - minTimeoutInMs: 100, - maxTimeoutInMs: 1_000, - randomize: true, - }, - keys, -}; - -const env = { - id: "e1234", - type: "PRODUCTION" as const, - maximumConcurrencyLimit: 1_000_000, - concurrencyLimitBurstFactor: new Decimal(1.0), - project: { id: "p1234" }, - organization: { id: "o1234" }, -}; - -function conn() { - const u = new URL(REDIS_URL!); - return { - host: u.hostname, - port: Number(u.port || "6379"), - password: decodeURIComponent(u.password || "") || undefined, - username: decodeURIComponent(u.username || "") || undefined, - }; -} - -function createQueue(keyPrefix: string, vtimeEnabled: boolean) { - const c = conn(); - return new RunQueue({ - ...testOptions, - masterQueueConsumersDisabled: true, - workerOptions: { disabled: true }, - ckVirtualTimeScheduling: { enabled: vtimeEnabled }, - queueSelectionStrategy: new FairQueueSelectionStrategy({ redis: { keyPrefix, ...c }, keys }), - redis: { keyPrefix, ...c }, - }); -} - -function makeMessage(o: Partial = {}): InputPayload { - return { - runId: "r1", - taskIdentifier: "task/my-task", - orgId: "o1234", - projectId: "p1234", - environmentId: "e1234", - environmentType: "PRODUCTION", - queue: "task/my-task", - timestamp: Date.now(), - attempt: 0, - ...o, - }; -} - -// bounded-concurrency runner (beats the ~3.6ms workstation->box RTT) -async function pool(n: number, count: number, fn: (i: number) => Promise) { - let i = 0; - await Promise.all( - Array.from({ length: Math.min(n, count) }, async () => { - while (i < count) { - const idx = i++; - await fn(idx); - } - }) - ); -} - -// ---- server-side metric helpers (separate no-prefix admin client) ---- -function admin() { - return createRedisClient(conn(), { onError: () => {} }); -} -function infoField(info: string, key: string): number { - const line = info.split("\n").find((l) => l.startsWith(key + ":")); - return line ? Number(line.split(":")[1]) : NaN; -} -async function usedMemory(a: any) { - return infoField(await a.info("memory"), "used_memory"); -} -async function usedCpu(a: any) { - const i = await a.info("cpu"); - return infoField(i, "used_cpu_user") + infoField(i, "used_cpu_sys"); -} -function evalsha(info: string) { - const line = info.split("\n").find((l) => l.startsWith("cmdstat_evalsha:")); - if (!line) return { calls: 0, usec: 0, usecPerCall: 0 }; - const g = (k: string) => Number(line.match(new RegExp(`${k}=([0-9.]+)`))?.[1] ?? 0); - return { calls: g("calls"), usec: g("usec"), usecPerCall: g("usec_per_call") }; -} - -// ---- build N distinct concurrency keys (one queued message each) ---- -async function buildCardinality(queue: RunQueue, n: number) { - const t0 = Date.now() - 500_000; - await pool(CONCURRENCY, n, async (i) => { - await queue.enqueueMessage({ - env, - message: makeMessage({ runId: `r-${i}`, concurrencyKey: `k${i}`, timestamp: t0 + i }), - workerQueue: env.id, - skipDequeueProcessing: true, - }); - }); -} - -async function findKey(a: any, prefix: string, suffix: string): Promise { - const found = await a.keys(`${prefix}*:${suffix}`); - return found[0] ?? null; -} - -// ---- the bench ---- -describe.runIf(!!REDIS_URL)("CK virtual-time resource + cardinality benchmark", () => { - it( - "measures Redis memory and CPU vs cardinality, flag OFF vs ON", - { timeout: 60 * 60_000 }, - async () => { - const a = admin(); - const report: any = { generatedAtMs: Date.now(), memory: [], cpu: [], churn: null }; - - // ---------- memory at rest ---------- - for (const n of MEM_CARDS) { - const row: any = { cardinality: n }; - for (const on of [false, true]) { - await a.flushall(); - await a.call("CONFIG", "RESETSTAT"); - const prefix = `ckres:mem:${n}:${on ? "on" : "off"}:`; - const q = createQueue(prefix, on); - try { - await q.updateEnvConcurrencyLimits(env); - await buildCardinality(q, n); - const arm = on ? "on" : "off"; - row[`used_memory_${arm}`] = await usedMemory(a); - const ckIndexKey = await findKey(a, prefix, "ckIndex"); - const ckVtimeKey = await findKey(a, prefix, "ckVtime"); - row[`ckIndex_bytes_${arm}`] = ckIndexKey - ? await a.call("MEMORY", "USAGE", ckIndexKey) - : null; - row[`ckIndex_card_${arm}`] = ckIndexKey ? await a.zcard(ckIndexKey) : 0; - if (on) { - row.ckVtime_bytes = ckVtimeKey ? await a.call("MEMORY", "USAGE", ckVtimeKey) : null; - row.ckVtime_card = ckVtimeKey ? await a.zcard(ckVtimeKey) : 0; - row.ckVtime_encoding = ckVtimeKey - ? await a.call("OBJECT", "ENCODING", ckVtimeKey) - : null; - // ckVtime must mirror ckIndex membership when built via the slow path - expect(row.ckVtime_card).toBe(row.ckIndex_card_on); - } - } finally { - await q.quit(); - } - } - row.used_memory_delta = row.used_memory_on - row.used_memory_off; - row.ckVtime_over_ckIndex = - row.ckIndex_bytes_on && row.ckVtime_bytes - ? +(row.ckVtime_bytes / row.ckIndex_bytes_on).toFixed(2) - : null; - report.memory.push(row); - // eslint-disable-next-line no-console - console.log( - `[ckres] mem N=${n}: used_memory delta=${row.used_memory_delta}B ckVtime=${row.ckVtime_bytes}B (${row.ckVtime_encoding})` - ); - } - - // ---------- CPU under identical load ---------- - for (const n of CPU_CARDS) { - const row: any = { cardinality: n }; - for (const on of [false, true]) { - await a.flushall(); - const prefix = `ckres:cpu:${n}:${on ? "on" : "off"}:`; - const q = createQueue(prefix, on); - try { - await q.updateEnvConcurrencyLimits(env); - await buildCardinality(q, n); - const shard = keys.masterQueueShardForEnvironment(env.id, 2); - - await a.call("CONFIG", "RESETSTAT"); - const cpu0 = await usedCpu(a); - const t0wall = Date.now(); - - // identical workload both arms: LOAD_OPS rounds, each round enqueues - // 10 fresh messages (rotating keys, keeps N populated) and does one - // batched dequeue (maxCount 10) + acks the served set. - let served = 0; - await pool(CONCURRENCY, LOAD_OPS, async (i) => { - for (let j = 0; j < 10; j++) { - await q.enqueueMessage({ - env, - message: makeMessage({ - runId: `L-${i}-${j}`, - concurrencyKey: `k${(i * 10 + j) % n}`, - timestamp: Date.now(), - }), - workerQueue: env.id, - skipDequeueProcessing: true, - }); - } - const msgs = await q.testDequeueFromMasterQueue(shard, env.id, 10); - served += msgs.length; - for (const m of msgs) { - await q.acknowledgeMessage(env.organization.id, m.messageId, { - skipDequeueProcessing: true, - }); - } - }); - - const cpu1 = await usedCpu(a); - const es = evalsha(await a.info("commandstats")); - const arm = on ? "on" : "off"; - row[`cpu_sec_${arm}`] = +(cpu1 - cpu0).toFixed(3); - row[`evalsha_calls_${arm}`] = es.calls; - row[`evalsha_usec_per_call_${arm}`] = +es.usecPerCall.toFixed(2); - row[`wall_ms_${arm}`] = Date.now() - t0wall; - row[`served_${arm}`] = served; - } finally { - await q.quit(); - } - } - row.cpu_sec_delta = +(row.cpu_sec_on - row.cpu_sec_off).toFixed(3); - row.cpu_overhead_pct = row.cpu_sec_off - ? Math.round(((row.cpu_sec_on - row.cpu_sec_off) / row.cpu_sec_off) * 100) - : null; - report.cpu.push(row); - // eslint-disable-next-line no-console - console.log( - `[ckres] cpu N=${n}: cpu OFF=${row.cpu_sec_off}s ON=${row.cpu_sec_on}s (${row.cpu_overhead_pct}%) evalsha usec/call OFF=${row.evalsha_usec_per_call_off} ON=${row.evalsha_usec_per_call_on}` - ); - } - - // ---------- churn: ckVtime membership stays bounded vs ckIndex ---------- - { - await a.flushall(); - const prefix = `ckres:churn:on:`; - const q = createQueue(prefix, true); - const samples: any[] = []; - try { - await q.updateEnvConcurrencyLimits(env); - await buildCardinality(q, CHURN_CARD); - const shard = keys.masterQueueShardForEnvironment(env.id, 2); - const ckIndexKey = (await findKey(a, prefix, "ckIndex"))!; - const ckVtimeKey = (await findKey(a, prefix, "ckVtime"))!; - let nextKey = CHURN_CARD; - for (let r = 0; r < CHURN_ROUNDS; r++) { - // hold cardinality: each iteration enqueues one FRESH key and drains - // one message (maxCount 1), so registration and GC churn continuously - // while total membership stays ~CHURN_CARD. - await pool(CONCURRENCY, 200, async () => { - await q.enqueueMessage({ - env, - message: makeMessage({ - runId: `C-${nextKey}`, - concurrencyKey: `k${nextKey++}`, - timestamp: Date.now(), - }), - workerQueue: env.id, - skipDequeueProcessing: true, - }); - const msgs = await q.testDequeueFromMasterQueue(shard, env.id, 1); - for (const m of msgs) { - await q.acknowledgeMessage(env.organization.id, m.messageId, { - skipDequeueProcessing: true, - }); - } - }); - if (r % 10 === 0 || r === CHURN_ROUNDS - 1) { - const ckIndexCard = await a.zcard(ckIndexKey); - const ckVtimeCard = await a.zcard(ckVtimeKey); - samples.push({ - round: r, - ckIndex: ckIndexCard, - ckVtime: ckVtimeCard, - ratio: ckIndexCard ? +(ckVtimeCard / ckIndexCard).toFixed(2) : null, - }); - } - } - report.churn = { cardinality: CHURN_CARD, rounds: CHURN_ROUNDS, samples }; - // bounded: ckVtime never wildly exceeds ckIndex (allow generous 3x for transient tombstones) - for (const s of samples) if (s.ratio !== null) expect(s.ratio).toBeLessThanOrEqual(3); - } finally { - await q.quit(); - } - } - - await a.flushall(); - await a.quit().catch(() => {}); - - mkdirSync(OUT_DIR, { recursive: true }); - writeFileSync(`${OUT_DIR}/ck-resource-results.json`, JSON.stringify(report, null, 2)); - writeFileSync(`${OUT_DIR}/ck-resource-results.md`, renderMarkdown(report)); - } - ); -}); - -function renderMarkdown(r: any): string { - const L: string[] = []; - const kb = (b: number) => (b == null ? "n/a" : (b / 1024).toFixed(1) + "KB"); - L.push(`# CK virtual-time resource + cardinality results`, ""); - L.push( - `Redis \`${(process.env.CK_BENCH_REDIS_URL || "").replace(/:[^:@/]*@/, ":***@")}\`. Relative OFF-vs-ON on one box; not prod scale.`, - "" - ); - L.push(`## Memory at rest (single base queue, one message per key)`, ""); - L.push( - `| keys (N) | used_memory OFF | used_memory ON | delta | ckIndex bytes | ckVtime bytes | ckVtime/ckIndex | ckVtime encoding |` - ); - L.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`); - for (const m of r.memory) - L.push( - `| ${m.cardinality} | ${kb(m.used_memory_off)} | ${kb(m.used_memory_on)} | ${kb(m.used_memory_delta)} | ${kb(m.ckIndex_bytes_on)} | ${kb(m.ckVtime_bytes)} | ${m.ckVtime_over_ckIndex ?? "n/a"} | ${m.ckVtime_encoding ?? "n/a"} |` - ); - L.push( - "", - `## Redis CPU under identical workload (${process.env.CK_RES_LOAD_OPS ?? "8000"} rounds)`, - "" - ); - L.push( - `| keys (N) | CPU-sec OFF | CPU-sec ON | delta | overhead | evalsha usec/call OFF | evalsha usec/call ON | evalsha calls OFF/ON |` - ); - L.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`); - for (const c of r.cpu) - L.push( - `| ${c.cardinality} | ${c.cpu_sec_off} | ${c.cpu_sec_on} | ${c.cpu_sec_delta} | ${c.cpu_overhead_pct}% | ${c.evalsha_usec_per_call_off} | ${c.evalsha_usec_per_call_on} | ${c.evalsha_calls_off}/${c.evalsha_calls_on} |` - ); - if (r.churn) { - L.push("", `## Tombstone / membership under churn (N=${r.churn.cardinality}, flag ON)`, ""); - L.push(`| round | ckIndex card | ckVtime card | ratio |`, `| --- | --- | --- | --- |`); - for (const s of r.churn.samples) - L.push(`| ${s.round} | ${s.ckIndex} | ${s.ckVtime} | ${s.ratio} |`); - } - L.push(""); - return L.join("\n"); -} From 32d976aeb6aa3baa814e76df5450c186b004f40f Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 31 Jul 2026 04:20:52 +0100 Subject: [PATCH 24/26] fix(run-engine): stop an unservable variant pinning the ck virtual-time floor A concurrency-key variant that has queued work but nothing ready yet, which is what every nack with a retry backoff produces, stayed registered in ckVtime holding its old low tag. The floor is the lowest stored tag, so it froze there while the keys actually being served advanced. New keys register at the floor, so a key that arrived later started well below the established ones and won every pass-1 slot until it caught up, which is the starvation the feature is meant to remove. The dequeue path now de-registers a variant when it has work but none of it is ready, alongside the existing GC for variants with no work at all. It stays in ckIndex, so pass 2 still serves it in age order once its head is ready, and it rejoins the fair order at the current floor on its next enqueue, nack or serve. Idle keys no longer hoard priority credit either. Measured with a control against a treatment on the real dequeue path: with one future-headed variant present the floor stayed at 0 while served keys reached 25, and a newcomer took 20 of the next 20 serves. With the fix the same run matches the control, newcomer 5 of 20. Reported by Devin on #4367. --- .../run-engine/src/run-queue/index.ts | 7 + .../src/run-queue/tests/ckVtime.test.ts | 150 ++++++++++++++---- 2 files changed, 124 insertions(+), 33 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 498d494bf50..ce98cd05e78 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -5202,6 +5202,13 @@ local function tryServe(ckQueueName) redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW else redis.call('ZADD', ckIndexKey, any[2], ckQueueName) + -- The variant has work but none of it is ready yet (a nack backoff, say), so it + -- is not competing for service and must not hold the floor down. While it sat in + -- ckVtime its low tag pinned the floor, and new keys register at the floor, so a + -- key arriving later started far below the established ones and took every pass-1 + -- slot until it caught up. It re-registers at the floor of the day on its next + -- enqueue/nack, or when pass 2 serves it after its head becomes ready. + redis.call('ZREM', ckVtimeKey, ckQueueName) end end end 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 98b0ac73d17..f439041f287 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 @@ -661,45 +661,129 @@ describe("CK virtual-time (SFQ) dequeue", () => { } ); - redisTest("future-scheduled variants are skipped without advance", async ({ redisContainer }) => { - const queue = createQueue(redisContainer); - try { - const t0 = Date.now() - 100_000; + redisTest( + "future-scheduled variants are skipped, not advanced, and de-registered", + 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, - }); + // 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 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); + 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); + 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(); + // Not served, so never charged a quantum: its tag is not advanced past the 5 it + // was seeded with. It is de-registered instead, because a variant with no ready + // work is not competing and must not hold the floor down (a pinned floor is what + // let a later arrival register underneath the established keys and take every + // pass-1 slot). It rejoins at the floor of the day once it has ready work. + const futureTag = await queue.redis.zscore(ckVtimeKey, futureVariant); + expect(futureTag).toBeNull(); + } finally { + await queue.quit(); + } } - }); + ); + + redisTest( + "an unservable variant does not pin the floor for later arrivals", + async ({ redisContainer }) => { + // Regression: a variant with work but nothing ready (a nack backoff is the common + // case) used to sit in ckVtime holding the lowest tag. The floor is the minimum + // stored tag, so it froze at that value while served keys advanced, and because new + // keys register at the floor, a key arriving later started far below the established + // ones and won every pass-1 slot until it caught up. That is the starvation this + // feature exists to prevent, inverted. + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // Stalled: registered on enqueue, but its head never becomes ready during the test. + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: "r-stalled", + concurrencyKey: "stalled", + timestamp: Date.now() + 60 * 60 * 1000, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + for (let i = 0; i < 12; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-busy-${i}`, + concurrencyKey: "busy", + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + 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 busyVariant = variantName("busy"); + const floorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(busyVariant); + const floor = Number((await queue.redis.get(floorKey)) ?? "0"); + + // The floor tracked the key that was actually being served. + expect(floor).toBeGreaterThan(0); + + // A key arriving now joins level with the established keys rather than underneath + // them, so it gets its turn instead of monopolising the fair pass. + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r-newcomer", concurrencyKey: "newcomer", timestamp: t0 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(busyVariant); + const newcomerTag = Number(await queue.redis.zscore(ckVtimeKey, variantName("newcomer"))); + const busyTag = Number(await queue.redis.zscore(ckVtimeKey, busyVariant)); + + expect(newcomerTag).toBe(floor); + expect(busyTag - newcomerTag).toBeLessThanOrEqual(1); + } finally { + await queue.quit(); + } + } + ); redisTest( "enqueue registers the variant at the current floor with NX", From a1d38ef200cb5a9ef90488b7b30303fc8072f671 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 31 Jul 2026 10:24:54 +0100 Subject: [PATCH 25/26] fix(run-engine): advance the ck virtual-time floor from servable variants The floor only tracked the lowest tag on record, so any registered variant that could not be served held it there: one sitting at its own concurrency ceiling, or one whose head is not ready yet. The keys actually being served advanced past it, and since new keys register at the floor, a later arrival started underneath the incumbents and took the fair pass until it caught up. The floor now also rises to the lowest tag that was servable on the call. Pass 1 walks candidates in ascending tag order, so that is a safe lower bound. The repair from the lowest tag on record stays, because both routes only ever raise it and it still recovers a floor that was lost while ckVtime survived. Also adds the fairness scenario the suite was missing. None of the six scenarios nacked or used future-scored messages, so a stalled variant never existed and this class of bug could not show up. In the window after it lands the latecomer now takes 5 of 20 serves, against 12 of 20 without the fix. --- .../run-engine/src/run-queue/index.ts | 21 ++-- .../src/run-queue/tests/ckVtime.test.ts | 69 +++++++++++++ .../run-queue/tests/ckVtimeFairness.test.ts | 99 +++++++++++++++++-- 3 files changed, 175 insertions(+), 14 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index ce98cd05e78..a33b1c977a9 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -5117,7 +5117,10 @@ end local window = actualMaxCount * windowMultiplier --- Monotonic floor, advanced to the minimum stored virtual-time tag +-- Floor only ever rises, by two independent routes: to the lowest tag on record (repairs +-- a floor that was lost while ckVtime survived), and to the lowest tag actually servable +-- this call (minServableTag). The second route matters because an unservable variant +-- keeps a stale low tag, which left the first route unable to advance at all. local floor = tonumber(redis.call('GET', ckVtimeFloorKey) or '0') local minEntry = redis.call('ZRANGE', ckVtimeKey, 0, 0, 'WITHSCORES') if #minEntry > 0 then @@ -5126,6 +5129,7 @@ if #minEntry > 0 then floor = minTag end end +local minServableTag = nil local results = {} local dequeuedCount = 0 @@ -5180,6 +5184,10 @@ local function tryServe(ckQueueName) local weight = 1 local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor) if tag < floor then tag = floor end + -- Pass 1 walks in ascending tag order, so anything unvisited is above this. + if minServableTag == nil or tag < minServableTag then + minServableTag = tag + end redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName) end else @@ -5202,12 +5210,8 @@ local function tryServe(ckQueueName) redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW else redis.call('ZADD', ckIndexKey, any[2], ckQueueName) - -- The variant has work but none of it is ready yet (a nack backoff, say), so it - -- is not competing for service and must not hold the floor down. While it sat in - -- ckVtime its low tag pinned the floor, and new keys register at the floor, so a - -- key arriving later started far below the established ones and took every pass-1 - -- slot until it caught up. It re-registers at the floor of the day on its next - -- enqueue/nack, or when pass 2 serves it after its head becomes ready. + -- Work but nothing ready (a nack backoff): not competing, so drop it from the fair + -- order rather than let it hoard credit. Rejoins at the floor when next served. redis.call('ZREM', ckVtimeKey, ckQueueName) end end @@ -5236,6 +5240,9 @@ if dequeuedCount < actualMaxCount then end -- NEW: persist floor and refresh TTLs +if minServableTag ~= nil and minServableTag > floor then + floor = minServableTag +end redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl) if redis.call('EXISTS', ckVtimeKey) == 1 then redis.call('EXPIRE', ckVtimeKey, stateTtl) 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 f439041f287..13d2b865d8f 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 @@ -709,6 +709,75 @@ describe("CK virtual-time (SFQ) dequeue", () => { } ); + redisTest( + "a variant at its concurrency ceiling does not pin the floor", + async ({ redisContainer }) => { + // A saturated variant stops advancing but keeps its tag, which used to hold the + // floor down for everyone arriving later. + const queue = createQueue(redisContainer); + try { + const t0 = Date.now() - 100_000; + + // Per-key ceiling of 1, well under the env limit, so hog gates on its own account + // rather than by exhausting env capacity. + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, QUEUE, 1); + + for (let i = 0; i < 12; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r-hog-${i}`, concurrencyKey: "hog", timestamp: t0 + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + for (let i = 0; i < 12; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `r-busy-${i}`, + concurrencyKey: "busy", + timestamp: t0 + 500 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2); + const hogVariant = variantName("hog"); + const busyVariant = variantName("busy"); + const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(busyVariant); + const floorKey = testOptions.keys.ckVtimeFloorKeyFromQueue(busyVariant); + + // Ack only busy, so hog accumulates in-flight messages until it is gated. + for (let call = 0; call < 10; call++) { + const messages = await queue.testDequeueFromMasterQueue(shard, authenticatedEnvDev.id, 2); + for (const m of messages) { + if (m.message.concurrencyKey === "busy") { + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + } + + const hogTag = Number(await queue.redis.zscore(ckVtimeKey, hogVariant)); + const busyTag = Number(await queue.redis.zscore(ckVtimeKey, busyVariant)); + const floor = Number((await queue.redis.get(floorKey)) ?? "0"); + + // hog is still registered (it has ready work and will be served when a slot + // frees), it has simply stopped advancing while saturated. + expect(hogTag).not.toBeNaN(); + expect(busyTag).toBeGreaterThan(hogTag); + + // The floor followed the key that was actually being served, not the stalled one. + expect(floor).toBeGreaterThan(hogTag); + } finally { + await queue.quit(); + } + } + ); + redisTest( "an unservable variant does not pin the floor for later arrivals", async ({ redisContainer }) => { 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 622b2cdd247..c5cf3eba6d6 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 @@ -93,7 +93,15 @@ function makeMessage(overrides: Partial = {}): InputPayload { }; } -type ScenarioMessage = { runId: string; ck: string; timestamp: number }; +type ScenarioMessage = { + runId: string; + ck: string; + timestamp: number; + // Enqueued at the top of this step instead of before step 0, for late arrivals. + enqueueAtStep?: number; + // Head never becomes ready during the run, so it is not expected to drain. + neverReady?: boolean; +}; type Scenario = { name: string; @@ -138,7 +146,7 @@ async function runScenario( }; await queue.updateEnvConcurrencyLimits(env); - for (const msg of scenario.messages) { + const enqueue = async (msg: ScenarioMessage) => { await queue.enqueueMessage({ env, message: makeMessage({ @@ -149,13 +157,18 @@ async function runScenario( workerQueue: env.id, skipDequeueProcessing: true, }); + }; + + for (const msg of scenario.messages) { + if (msg.enqueueAtStep === undefined) await enqueue(msg); } const shard = testOptions.keys.masterQueueShardForEnvironment(env.id, 2); - const total = scenario.messages.length; + const total = scenario.messages.filter((m) => !m.neverReady).length; const remaining = new Map(); for (const m of scenario.messages) { + if (m.neverReady) continue; remaining.set(m.ck, (remaining.get(m.ck) ?? 0) + 1); } @@ -165,6 +178,10 @@ async function runScenario( let drainStep = -1; for (let step = 0; step < scenario.maxSteps && serves.length < total; step++) { + for (const msg of scenario.messages) { + if (msg.enqueueAtStep === step) await enqueue(msg); + } + // evaluated before the dequeue: does this step have cross-key contention? let keysWithBacklog = 0; for (const count of remaining.values()) { @@ -221,10 +238,11 @@ function firstServeStep(result: ScenarioResult, matches: (ck: string) => boolean // 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); + const expected = scenario.messages.filter((m) => !m.neverReady).length; + expect(on.serves.length).toBe(expected); + expect(off.serves.length).toBe(expected); + expect(new Set(on.serves.map((s) => s.messageId)).size).toBe(expected); + expect(new Set(off.serves.map((s) => s.messageId)).size).toBe(expected); } function debugLog(name: string, data: Record) { @@ -528,4 +546,71 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => { expect(on.drainStep).toBe(off.drainStep); } ); + + redisTest( + "ckStalledNewcomer: a stalled variant does not let a late arrival starve the incumbents", + { timeout: 120_000 }, + async ({ redisContainer }) => { + // The case the other five scenarios cannot express: a variant that is registered but + // never servable (its head stays in the future, which is what a nack backoff leaves + // behind) used to freeze the virtual-time floor, so the late arrival registered far + // below the incumbents and took every fair-pass slot until it caught up. + const t0 = Date.now() - 100_000; + const messages: ScenarioMessage[] = []; + + messages.push({ + runId: "stalled-0", + ck: "stalled", + timestamp: Date.now() + 60 * 60 * 1000, + neverReady: true, + }); + + for (let k = 0; k < 3; k++) { + for (let i = 0; i < 40; i++) { + messages.push({ runId: `inc-${k}-${i}`, ck: `incumbent-${k}`, timestamp: t0 + i }); + } + } + + // Arrives once the incumbents have advanced well past the stalled variant's tag. + for (let i = 0; i < 20; i++) { + messages.push({ + runId: `late-${i}`, + ck: "latecomer", + timestamp: t0 + 5_000 + i, + enqueueAtStep: 30, + }); + } + + const scenario: Scenario = { + name: "ckStalledNewcomer", + messages, + envConcurrencyLimit: 1, + holdSteps: 0, + maxSteps: 600, + }; + + const on = await runScenario(redisContainer, scenario, true); + const off = await runScenario(redisContainer, scenario, false); + + assertConservation(scenario, on, off); + + // Over the 20 steps after it lands, the latecomer must not monopolise service. + const windowServes = (r: ScenarioResult) => + r.serves.filter((s) => s.step >= 30 && s.step < 50); + const onWindow = windowServes(on); + const onLate = onWindow.filter((s) => s.ck === "latecomer").length; + + debugLog("ckStalledNewcomer", { + onWindowTotal: onWindow.length, + onLate, + offLate: windowServes(off).filter((s) => s.ck === "latecomer").length, + }); + + // Four keys compete in that window, so a fair share is a quarter of it. Before the + // floor fix the latecomer took 12 of 20 here; it now takes its 5. + const fairShare = Math.ceil(onWindow.length / 4); + expect(onWindow.length).toBeGreaterThan(0); + expect(onLate).toBeLessThanOrEqual(fairShare + 2); + } + ); }); From df8f1a9aa03d8ebdd4e787b228401cfbec81cdc1 Mon Sep 17 00:00:00 2001 From: Wes Mason Date: Fri, 31 Jul 2026 10:56:41 +0100 Subject: [PATCH 26/26] fix(run-engine): keep unservable ck variants in the fair order Reverts the de-registration added earlier on this branch. Dropping a variant from ckVtime when it had work but nothing ready stranded it: pass 1 is the only reader of ckVtime, and pass 2 is skipped whenever pass 1 fills the batch, so on a queue busy enough to keep filling it the variant was never looked at again. A blind review measured one sitting unserved for over two thousand calls after its head became ready, and every nack backoff produces exactly that shape, so a single steady key could hold up another key's retries indefinitely. That is worse than the floor pinning it was meant to address. The floor advance from servable variants handles both cases on its own, so the de-registration bought nothing. It now also only takes its bound from pass 1. Pass 2 picks candidates by message age, so its tag implies nothing about the entries it skipped, and letting it move the floor stepped over registered variants that were servable and simply never visited, confiscating their credit on the next serve. Adds the regression test the earlier tests were missing. They proved the variant was evicted but never that it came back, which is the half that was broken. --- .../run-engine/src/run-queue/index.ts | 17 ++- .../src/run-queue/tests/ckVtime.test.ts | 112 ++++++++++++++---- 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index a33b1c977a9..ae46ff122ef 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -5060,7 +5060,7 @@ return __qmret(results) // :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). + // lines added (tag advance on serve, ZREM ckVtime on GC, floor advance from pass 1). this.redis.defineCommand("dequeueMessagesFromCkQueueVtimeTracked", { numberOfKeys: 13, lua: ` @@ -5137,7 +5137,7 @@ local attempted = {} -- Per-candidate serve. Body is dequeueMessagesFromCkQueueTracked's per-candidate -- block, verbatim, with the marked NEW lines added. -local function tryServe(ckQueueName) +local function tryServe(ckQueueName, mayRaiseFloor) attempted[ckQueueName] = true local fullQueueKey = keyPrefix .. ckQueueName @@ -5184,8 +5184,10 @@ local function tryServe(ckQueueName) local weight = 1 local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor) if tag < floor then tag = floor end - -- Pass 1 walks in ascending tag order, so anything unvisited is above this. - if minServableTag == nil or tag < minServableTag then + -- Pass 1 only: it walks in ascending tag order, so anything it has not visited + -- sits above this. Pass 2 goes by message age, so its tag says nothing about the + -- entries it skipped and must not move the floor over them. + if mayRaiseFloor and (minServableTag == nil or tag < minServableTag) then minServableTag = tag end redis.call('ZADD', ckVtimeKey, tostring(tag + (quantum / weight)), ckQueueName) @@ -5210,9 +5212,6 @@ local function tryServe(ckQueueName) redis.call('ZREM', ckVtimeKey, ckQueueName) -- NEW else redis.call('ZADD', ckIndexKey, any[2], ckQueueName) - -- Work but nothing ready (a nack backoff): not competing, so drop it from the fair - -- order rather than let it hoard credit. Rejoins at the floor when next served. - redis.call('ZREM', ckVtimeKey, ckQueueName) end end end @@ -5222,7 +5221,7 @@ end local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1) for _, ckQueueName in ipairs(vtimeCandidates) do if dequeuedCount >= actualMaxCount then break end - tryServe(ckQueueName) + tryServe(ckQueueName, true) end -- Pass 2: fill + discovery in age order (work conservation, mixed-deploy safety). @@ -5234,7 +5233,7 @@ if dequeuedCount < actualMaxCount then for _, ckQueueName in ipairs(ckQueues) do if dequeuedCount >= actualMaxCount then break end if not attempted[ckQueueName] then - tryServe(ckQueueName) + tryServe(ckQueueName, false) end end end 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 13d2b865d8f..0fd96615e83 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 @@ -661,48 +661,110 @@ describe("CK virtual-time (SFQ) dequeue", () => { } ); + 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); + + // Not served, so not charged a quantum. It stays registered: pass 1 is the only + // path that can reach it, so de-registering it would strand it while pass 1 is + // busy. It no longer holds the floor down, which the floor tests cover. + const futureTag = Number(await queue.redis.zscore(ckVtimeKey, futureVariant)); + expect(futureTag).toBe(5); + } finally { + await queue.quit(); + } + }); + redisTest( - "future-scheduled variants are skipped, not advanced, and de-registered", + "a variant whose backoff elapses is served even while pass 1 stays full", + { timeout: 120_000 }, async ({ redisContainer }) => { + // Pass 1 is the only path that reads ckVtime, and pass 2 is skipped whenever pass 1 + // fills the batch. Dropping a not-ready variant from ckVtime therefore stranded it + // for as long as any other key kept the batch full: measured at over 2000 calls. 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 + for (let k = 0; k < 3; k++) { + for (let i = 0; i < 40; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `b${k}-${i}`, + concurrencyKey: `b${k}`, + timestamp: t0 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + } await queue.enqueueMessage({ env: authenticatedEnvDev, message: makeMessage({ - runId: "r-future", - concurrencyKey: "future", - timestamp: Date.now() + 60_000, + runId: "stalled-1", + concurrencyKey: "stalled", + timestamp: Date.now() + 400, }), 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); + const drain = async (calls: number) => { + let servedStalled = false; + for (let call = 0; call < calls; call++) { + const messages = await queue.testDequeueFromMasterQueue( + shard, + authenticatedEnvDev.id, + 1 + ); + for (const m of messages) { + if (m.message.concurrencyKey === "stalled") servedStalled = true; + await queue.acknowledgeMessage(authenticatedEnvDev.organization.id, m.messageId, { + skipDequeueProcessing: true, + }); + } + } + return servedStalled; + }; - expect(messages.some((m) => m.message.concurrencyKey === "future")).toBe(false); + // Let the incumbents advance so the stalled variant holds the lowest tag, which is + // what pulls it into the pass-1 window and onto the skip path. + await drain(6); + await new Promise((resolve) => setTimeout(resolve, 700)); - // Not served, so never charged a quantum: its tag is not advanced past the 5 it - // was seeded with. It is de-registered instead, because a variant with no ready - // work is not competing and must not hold the floor down (a pinned floor is what - // let a later arrival register underneath the established keys and take every - // pass-1 slot). It rejoins at the floor of the day once it has ready work. - const futureTag = await queue.redis.zscore(ckVtimeKey, futureVariant); - expect(futureTag).toBeNull(); + expect(await drain(60)).toBe(true); } finally { await queue.quit(); }