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.
This commit is contained in:
@@ -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=<id1> pnpm loadgen`, wait for drain, `ARM=off BATCH=<id1> pnpm
|
||||
collect`.
|
||||
4. **Arm ON:** flip the flag on, redeploy the control plane, drain/clear queue
|
||||
state; then `ARM=on BATCH=<id2> pnpm loadgen`, wait for drain, `ARM=on
|
||||
BATCH=<id2> 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.
|
||||
@@ -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 <ref>`).
|
||||
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=<ref> npx trigger.dev@latest deploy --self-hosted --profile <profile> -p <ref>
|
||||
# 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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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=<id> 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=<id> to the loadgen batch id.");
|
||||
|
||||
const waitsByTenant = new Map<string, number[]>();
|
||||
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<string, ReturnType<typeof summarize>> = {};
|
||||
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<!-- batch ${batch} arm ${arm} -->\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);
|
||||
});
|
||||
@@ -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://<instance>
|
||||
* 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=<id> 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<typeof ckBenchTask>(
|
||||
"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);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
// The project ref is passed on the CLI (`-p <ref>`) 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"],
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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> = {}): 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<string, number>;
|
||||
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<ArmResult> {
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user