|
| 1 | +# CK virtual-time scheduling: prod-like A/B benchmark plan |
| 2 | + |
| 3 | +A method for producing defensible A/B numbers for the concurrency-key |
| 4 | +virtual-time (SFQ) scheduling change, run on a single prod-shaped box. The two |
| 5 | +arms are flag OFF (today's age-ordered CK dequeue) and flag ON (virtual-time |
| 6 | +ordering), under identical load. |
| 7 | + |
| 8 | +## What the change is (grounded in the branch) |
| 9 | + |
| 10 | +The concurrency-key dequeue used to serve variants of a base queue in head-message |
| 11 | +age order. Behind `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` (off by default) it now |
| 12 | +orders them by start-time fair queueing (SFQ) virtual time: |
| 13 | + |
| 14 | +- Each CK variant carries a virtual clock in a parallel `:ckVtime` ZSET; a |
| 15 | + monotonic floor lives in `:ckVtimeFloor`. Both sit under the base queue's |
| 16 | + `{org}` hash tag, so one atomic Lua script touches all of a queue's state. |
| 17 | +- The dequeue runs two passes: pass 1 serves the lowest virtual clocks and |
| 18 | + advances each served variant by `quantum / weight` (weight fixed at 1 today); |
| 19 | + pass 2 fills any leftover batch slots in today's age order. Pass 2 makes the |
| 20 | + new command a strict superset of the old one, so it is work-conserving and can |
| 21 | + never serve fewer runs than today. |
| 22 | +- Enqueue and nack register a variant into `:ckVtime` at the floor with `NX`, so |
| 23 | + a brand-new key is reachable from its first enqueue and cannot be parked behind |
| 24 | + a backlog. This is the case a per-key concurrency cap cannot fix: one tenant |
| 25 | + sharding work across many keys. |
| 26 | +- Flag off is byte-identical: the pre-existing Lua scripts run unchanged and no |
| 27 | + vtime keys are created. The behaviour lives only in new command names. |
| 28 | + |
| 29 | +Tuning knobs (real env var names, all positive integers, re-clamped in the |
| 30 | +`RunQueue` constructor): |
| 31 | + |
| 32 | +| Env var | Default | Meaning | |
| 33 | +| --- | --- | --- | |
| 34 | +| `RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` | off | master flag | |
| 35 | +| `RUN_ENGINE_CK_VTIME_QUANTUM` | 1 | virtual-time advance per serve | |
| 36 | +| `RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER` | 3 | pass-1 window = `maxCount * this` | |
| 37 | +| `RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS` | 86400 | EXPIRE on the vtime keys | |
| 38 | + |
| 39 | +Stated limitations this benchmark deliberately probes (from |
| 40 | +`../../src/run-queue/CK_VTIME_KNOWN_LIMITATIONS.md`): bounded tombstone drift on |
| 41 | +ack/TTL/DLQ paths; member-name tie-break at equal tags; future-scheduled or |
| 42 | +retry-backoff variants occupying pass-1 window slots; and the pass-1 window being |
| 43 | +narrower than the live variant cardinality. |
| 44 | + |
| 45 | +## Validity: the numbers are RELATIVE, not absolute |
| 46 | + |
| 47 | +The target is one node on nested/slow storage. Absolute throughput here is NOT |
| 48 | +prod-scale and must never be reported as such. Every result is a ratio between |
| 49 | +flag OFF and flag ON, measured under identical load on the same box in the same |
| 50 | +session. That relative signal is what isolates the scheduler. Two guards keep the |
| 51 | +arms comparable: |
| 52 | + |
| 53 | +- Same enqueue set, same timestamps, same step loop / same load generator across |
| 54 | + OFF and ON. |
| 55 | +- Fresh queue state between arms (the micro arm FLUSHes a dedicated Redis; the |
| 56 | + end-to-end arm drains and uses a fresh batch tag), a warmup, and N trials. |
| 57 | + |
| 58 | +## Two arms |
| 59 | + |
| 60 | +### Arm 1 (PRIMARY): queue-level micro-benchmark |
| 61 | + |
| 62 | +Drives the real `RunQueue` directly against a dedicated Redis, comparing OFF vs |
| 63 | +ON on identical synthetic load. This isolates the scheduler and is where the |
| 64 | +defensible numbers come from. Because the flag is a `RunQueue` constructor |
| 65 | +option, one bench process runs both arms in-process: no webapp, no redeploy, no |
| 66 | +worker clusters. It reuses the existing fairness harness |
| 67 | +(`../../src/run-queue/tests/ckVtimeFairness.test.ts`): same step loop, same |
| 68 | +scenario shapes, same conservation checks, plus wall-clock latency, a Redis |
| 69 | +op-count, N trials, and file output. |
| 70 | + |
| 71 | +Harness: `../../src/run-queue/bench/ckMicroBench.bench.test.ts`. It is inert in CI |
| 72 | +(only runs when `CK_BENCH_REDIS_URL` is set) and FLUSHes its target Redis between |
| 73 | +arms, so it must point only at a dedicated throwaway instance. |
| 74 | + |
| 75 | +Each step makes one `maxCount = 10` dequeue call, records `(step, key, messageId, |
| 76 | +wallMs)` per served message, then acks in-flight messages whose logical hold has |
| 77 | +elapsed. Wait per message = the step it was served at (all load is pre-enqueued |
| 78 | +at step 0). The logical schedule is deterministic, so step-based metrics are |
| 79 | +identical across trials (the harness asserts this); trials exist to stabilise the |
| 80 | +wall-clock latency and op-count. |
| 81 | + |
| 82 | +### Arm 2 (END-TO-END): deployed tasks on the worker clusters |
| 83 | + |
| 84 | +The realism check on top of arm 1. A deployed task on a shared base queue with |
| 85 | +per-run concurrency keys, driven by a noisy-neighbor load generator: tenant A |
| 86 | +floods across many keys, tenant B sends a few. Each run carries a per-run region |
| 87 | +so the load also spreads across the three managed worker groups |
| 88 | +(`trigger-regiona/b/c`), exercising multi-cluster placement. Latency is read back |
| 89 | +per run as `startedAt - createdAt`. |
| 90 | + |
| 91 | +Project: `e2e-tasks/` (deployable, secret-free). Contention is forced by pinning |
| 92 | +the environment concurrency ceiling low (so many keys contend for a few slots and |
| 93 | +the CK dequeue order decides who starts first); the per-key lane width is 1 in the |
| 94 | +task config for reproducibility. |
| 95 | + |
| 96 | +Because the flag is server-side here, arm 2 is a manual OFF-then-ON: set the flag, |
| 97 | +redeploy the control plane, run the load, collect; flip the flag, redeploy, run |
| 98 | +the load again, collect. The exact toggle + redeploy belongs to the operator |
| 99 | +runbook. |
| 100 | + |
| 101 | +## Hypotheses (tied to the change) |
| 102 | + |
| 103 | +1. **Bounded wait behind a backlog.** A light key arriving behind a big backlog |
| 104 | + waits O(number of active keys) under vtime, versus O(backlog size) under the |
| 105 | + baseline (which drains the backlog first). Micro: `ckSkew`, `ckTrickle` |
| 106 | + victim wait p95/p99 drops sharply ON vs OFF. E2E: tenant B start-latency p95 |
| 107 | + stays bounded as tenant A's backlog grows. |
| 108 | +2. **Sharding across many keys cannot starve others.** A tenant fanning out over |
| 109 | + many concurrency keys (the case a per-key cap cannot fix) does not starve a |
| 110 | + light key, because the light key registers at the floor and is reachable in |
| 111 | + pass 1. Micro: `ckSybil` victim first-serve step is small ON (near-immediate) |
| 112 | + and its wait ratio drops; `ckManyKeys` shows no permanent starvation even |
| 113 | + when cardinality exceeds the pass-1 window. E2E: tenant B (few keys) is not |
| 114 | + starved by tenant A's many-key flood. |
| 115 | +3. **Work conservation.** A lone backlogged tenant still drains at full rate; |
| 116 | + the fair order adds no idle time when nothing else contends. Micro: |
| 117 | + `ckHeavyIdle` drain step ON equals OFF exactly. No-harm corollary: the |
| 118 | + symmetric `ckBalanced` case is not made worse. |
| 119 | + |
| 120 | +## Scenarios |
| 121 | + |
| 122 | +### Arm 1 (micro), all ported from the fairness-spike shapes |
| 123 | + |
| 124 | +| scenario | shape | env limit | hold | probes | |
| 125 | +| --- | --- | --- | --- | --- | |
| 126 | +| `ckSkew` | heavy 120 backlog + 4 light x 10 | 1 | 3 | starvation (H1) | |
| 127 | +| `ckTrickle` | bulk 120 + 2 trickle x 15 | 1 | 3 | starvation (H1) | |
| 128 | +| `ckSybil` | 20 attacker x 8 + 1 light x 10 | 25 | 3 | sharding/sybil (H2) | |
| 129 | +| `ckManyKeys` | 60 attacker x 8 (tied head) + 1 light x 10 | 25 | 3 | window limitation, no permanent starvation (H2) | |
| 130 | +| `ckBalanced` | 4 symmetric x 25 | 4 | 3 | no-harm (H3) | |
| 131 | +| `ckHeavyIdle` | 1 key x 60 | 25 | 3 | work conservation (H3) | |
| 132 | + |
| 133 | +### Arm 2 (end-to-end), noisy-neighbor |
| 134 | + |
| 135 | +- Tenant A: `A_KEYS` (default 40) keys x `A_PER_KEY` (default 5) runs = the flood. |
| 136 | +- Tenant B: `B_KEYS` (default 2) keys x `B_PER_KEY` (default 5) runs = the victim. |
| 137 | +- Per-run hold `HOLD_MS` (default 1500). Runs round-robined across the three |
| 138 | + regions. Environment concurrency ceiling pinned low (e.g. 5) so the keys |
| 139 | + actually contend. |
| 140 | +- Optional placement variant: pin tenant A to one region and tenant B to another |
| 141 | + to separate scheduler effects from cross-cluster effects. |
| 142 | + |
| 143 | +## Metrics and collection |
| 144 | + |
| 145 | +| metric | what it shows | source | |
| 146 | +| --- | --- | --- | |
| 147 | +| victim wait p50/p95/p99 | starvation relief | micro: serve step; e2e: `startedAt - createdAt` per tenant | |
| 148 | +| victim first-serve (starvation bound) | reachability at the floor | micro: first serve step for the victim key | |
| 149 | +| drain step / total served | work conservation, no loss/dup | micro: last serve step + unique messageId count | |
| 150 | +| Jain's fairness index | share fairness during contention | micro: over per-key contention-window serves | |
| 151 | +| dequeue call p95 (ms) | scheduler op cost (relative) | micro: wall-clock around each dequeue call | |
| 152 | +| redis ops (dequeue+ack) | per-dequeue overhead | micro: `CONFIG RESETSTAT` then `INFO commandstats` | |
| 153 | + |
| 154 | +Jain's index over per-key served counts `x_i`: `J = (sum x_i)^2 / (n * sum |
| 155 | +x_i^2)`. 1.0 is perfectly fair; `1/n` means one key took everything. |
| 156 | + |
| 157 | +The micro harness writes `ck-micro-results.json` and `ck-micro-results.md` |
| 158 | +(the results table below) to `CK_BENCH_OUT`. |
| 159 | + |
| 160 | +For the end-to-end arm, the primary source is the Runs API by tag |
| 161 | +(`startedAt - createdAt`), which `collect.ts` reads. Two alternatives give a |
| 162 | +tighter dequeue-only timestamp if the API delta looks noisy: |
| 163 | + |
| 164 | +- TRQL `runs` (verify column names with the query schema first): per-run |
| 165 | + `createdAt` and `startedAt`, filtered by the batch/arm tag. |
| 166 | +- The run-engine Postgres `TaskRun` timestamps directly (createdAt and the first |
| 167 | + attempt/started timestamp), if API round-trips add too much jitter. |
| 168 | + |
| 169 | +## Reproducible A/B procedure |
| 170 | + |
| 171 | +### Arm 1 (micro) |
| 172 | + |
| 173 | +1. Stand up a dedicated throwaway Redis reachable from the harness host (a local |
| 174 | + forward is fine). Nothing else may use it. |
| 175 | +2. From the run-engine package: |
| 176 | + |
| 177 | + ```bash |
| 178 | + CK_BENCH_REDIS_URL=redis://127.0.0.1:6399 \ |
| 179 | + CK_BENCH_TRIALS=5 CK_BENCH_OUT=./bench-results \ |
| 180 | + pnpm exec vitest run src/run-queue/bench/ckMicroBench.bench.test.ts |
| 181 | + ``` |
| 182 | + |
| 183 | + Both arms (OFF, then ON) run in one process per scenario, FLUSHing between |
| 184 | + arms. Knob sweep: add `CK_BENCH_QUANTUM` / `CK_BENCH_WINDOW_MULT`. Scenario |
| 185 | + subset: `CK_BENCH_SCENARIOS=ckSybil,ckSkew`. |
| 186 | +3. Read `bench-results/ck-micro-results.md`. The harness fails the run if either |
| 187 | + arm loses or double-serves a message, so a green run means the comparison is |
| 188 | + sound. |
| 189 | + |
| 190 | +### Arm 2 (end-to-end) |
| 191 | + |
| 192 | +1. Deploy `e2e-tasks/` to the bench project's PROD environment (dev |
| 193 | + short-circuits worker-group routing, so it must be prod). Pin the prod env |
| 194 | + concurrency ceiling low. |
| 195 | +2. Warmup: trigger a handful of runs, confirm they start on each region, discard. |
| 196 | +3. **Arm OFF:** ensure the flag is off and the control plane is redeployed; then |
| 197 | + `ARM=off BATCH=<id1> pnpm loadgen`, wait for drain, `ARM=off BATCH=<id1> pnpm |
| 198 | + collect`. |
| 199 | +4. **Arm ON:** flip the flag on, redeploy the control plane, drain/clear queue |
| 200 | + state; then `ARM=on BATCH=<id2> pnpm loadgen`, wait for drain, `ARM=on |
| 201 | + BATCH=<id2> pnpm collect`. |
| 202 | +5. Repeat both arms N times with fresh batch ids; compare per-tenant latency. |
| 203 | + |
| 204 | +The exact deploy, flag-toggle, and secret-key retrieval steps for the specific |
| 205 | +box are in the operator runbook (kept out of this repo). |
| 206 | + |
| 207 | +## Results template (paste into the PR) |
| 208 | + |
| 209 | +Fill from `ck-micro-results.md` (arm 1) and `e2e-summary.md` (arm 2). Keep the |
| 210 | +"relative only" caveat in the PR text. |
| 211 | + |
| 212 | +### Arm 1 (micro), quantum 1 / window x3, N trials, dedicated Redis on the box |
| 213 | + |
| 214 | +| scenario | metric | baseline (OFF) | vtime (ON) | delta | |
| 215 | +| --- | --- | --- | --- | --- | |
| 216 | +| **ckSkew** | victim wait p95 (steps) | | | | |
| 217 | +| | victim wait p99 | | | | |
| 218 | +| | victim first-serve | | | | |
| 219 | +| | drain step | | | | |
| 220 | +| | dequeue call p95 (ms) | | | | |
| 221 | +| **ckTrickle** | victim wait p95 | | | | |
| 222 | +| | victim first-serve | | | | |
| 223 | +| **ckSybil** | victim wait p95 | | | | |
| 224 | +| | victim first-serve | | | | |
| 225 | +| | Jain index (contention) | | | | |
| 226 | +| **ckManyKeys** | victim first-serve | | | | |
| 227 | +| | drain step | | | | |
| 228 | +| **ckBalanced** | worst-key wait p95 | | | | |
| 229 | +| **ckHeavyIdle** | drain step | | | | |
| 230 | +| (all) | redis ops (dequeue+ack) | | | | |
| 231 | + |
| 232 | +### Arm 2 (end-to-end), noisy-neighbor across regiona/b/c, env cap N |
| 233 | + |
| 234 | +| tenant | metric | baseline (OFF) | vtime (ON) | delta | |
| 235 | +| --- | --- | --- | --- | --- | |
| 236 | +| B (victim, few keys) | start latency p50 (ms) | | | | |
| 237 | +| B | start latency p95 | | | | |
| 238 | +| B | start latency p99 | | | | |
| 239 | +| A (flood, many keys) | start latency p95 | | | | |
| 240 | + |
| 241 | +Expected direction: B's p95/p99 drop substantially ON; A's is similar or slightly |
| 242 | +higher ON (it stops jumping the queue); `ckHeavyIdle` drain step is exactly equal; |
| 243 | +`ckBalanced` worst-key wait is within noise. |
| 244 | + |
| 245 | +## How to reproduce on the box (short) |
| 246 | + |
| 247 | +1. Dedicated Redis up, forwarded locally. Run the arm-1 vitest command above; |
| 248 | + collect `ck-micro-results.md`. |
| 249 | +2. Deploy `e2e-tasks/` to prod, pin the env concurrency ceiling, warm up. |
| 250 | +3. Flag OFF: redeploy control plane, loadgen + collect. Flag ON: redeploy, |
| 251 | + loadgen + collect. N trials. |
| 252 | +4. Paste both tables into the PR under a "relative numbers on a single |
| 253 | + prod-shaped box" heading. |
0 commit comments