Skip to content

Commit f90585f

Browse files
committed
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.
1 parent 1520939 commit f90585f

9 files changed

Lines changed: 1082 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# CK virtual-time end-to-end bench tasks
2+
3+
A tiny self-contained trigger.dev project used as the END-TO-END arm of the CK
4+
virtual-time A/B (see `../2026-07-26-ck-vtime-benchmark.md`). It is deployed to a
5+
self-hosted instance and is not part of the monorepo build.
6+
7+
- `src/trigger/ckBench.ts` — one `ck-bench` task on a shared base queue; per-run
8+
`concurrencyKey` makes the CK variants; a slot hold forces contention.
9+
- `src/loadgen.ts` — noisy-neighbor load: tenant A floods across many keys,
10+
tenant B sends a few; each run is tagged and carries a per-run `region`.
11+
- `src/collect.ts` — reads per-run `createdAt`/`startedAt` by tag and reports
12+
per-tenant enqueue->start latency p50/p95/p99.
13+
14+
Everything reads credentials from the environment (`TRIGGER_API_URL`,
15+
`TRIGGER_SECRET_KEY`); nothing is hard-coded. Deploy with the CLI (`-p <ref>`).
16+
The feature flag is server-side and is flipped by the operator between arms, not
17+
by this project. Exact instance coordinates and the toggle live in the operator
18+
runbook, kept outside this public repo.
19+
20+
```bash
21+
pnpm install
22+
# deploy (project ref on the CLI)
23+
TRIGGER_PROJECT_REF=<ref> npx trigger.dev@latest deploy --self-hosted --profile <profile> -p <ref>
24+
# generate load for one arm (flag already set + redeployed server-side)
25+
ARM=off BATCH=run1 pnpm loadgen
26+
# collect that arm
27+
ARM=off BATCH=run1 pnpm collect
28+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "ck-vtime-e2e-bench",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"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.",
7+
"scripts": {
8+
"loadgen": "tsx src/loadgen.ts",
9+
"collect": "tsx src/collect.ts"
10+
},
11+
"dependencies": {
12+
"@trigger.dev/sdk": "latest"
13+
},
14+
"devDependencies": {
15+
"trigger.dev": "latest",
16+
"tsx": "^4.19.0",
17+
"typescript": "^5.5.0"
18+
}
19+
}

0 commit comments

Comments
 (0)