|
| 1 | +# CK virtual-time scheduling: Redis CPU + memory vs cardinality test plan |
| 2 | + |
| 3 | +Answers the review question: how do these changes affect the run-queue Redis |
| 4 | +CPU and memory, and how do both react as concurrency-key cardinality grows (e.g. |
| 5 | +a base queue that suddenly has 10k distinct concurrency keys)? |
| 6 | + |
| 7 | +This is a cost/scaling plan, separate from the fairness A/B |
| 8 | +(`results-2026-07-27.md`). Same environment constraints: run on a single box, all |
| 9 | +numbers **relative** (flag OFF vs ON, identical load, same box), against a |
| 10 | +dedicated throwaway Redis. |
| 11 | + |
| 12 | +## What the change adds to Redis (grounded in the branch) |
| 13 | + |
| 14 | +Per base queue, flag ON adds: |
| 15 | + |
| 16 | +- `:ckVtime`, a ZSET whose members are the exact same full CK-variant queue-name |
| 17 | + strings the existing `:ckIndex` ZSET already holds, each with an 8-byte double |
| 18 | + score. So it is effectively a second copy of `ckIndex`'s membership. |
| 19 | +- `:ckVtimeFloor`, a STRING holding one number. Negligible. |
| 20 | + |
| 21 | +Both are GC'd from the dequeue path when a variant drains, carry a 24h TTL, and |
| 22 | +live under the base queue's `{org}` hash tag. The per-call dequeue scan window is |
| 23 | +`maxCount * windowMultiplier` (default 30) and does **not** grow with cardinality; |
| 24 | +the cardinality-sensitive operations are the ZSET writes/reads (`ZADD` `NX`, |
| 25 | +`ZSCORE`, `ZRANGE` by rank, `ZRANGE 0 0`), which are O(log N) on the `ckVtime` |
| 26 | +skiplist. |
| 27 | + |
| 28 | +## Hypotheses |
| 29 | + |
| 30 | +1. **Memory grows linearly with cardinality, adding roughly one `ckIndex`-sized |
| 31 | + ZSET per base queue.** Incremental `used_memory` under ON minus OFF should |
| 32 | + track `cardinality x per-member cost`, and `MEMORY USAGE :ckVtime` should be |
| 33 | + close to `MEMORY USAGE :ckIndex` for the same queue (same members, one extra |
| 34 | + double score). At 10k keys on one queue this is a low single-digit MB for that |
| 35 | + queue, bounded and TTL-reclaimed. There is a one-step jump at the |
| 36 | + listpack->skiplist encoding boundary (128 entries by default). |
| 37 | +2. **Redis CPU per operation grows sub-linearly (about O(log cardinality)), not |
| 38 | + linearly.** The fixed 30-entry window scan dominates the vtime-specific work |
| 39 | + and does not change with N; the ZSET ops add a `log N` term. So dequeue/enqueue |
| 40 | + `usec_per_call` should rise only mildly from 100 to 10k keys, and the ON/OFF |
| 41 | + `usec_per_call` ratio should stay roughly flat across the sweep. |
| 42 | +3. **Tombstone drift stays bounded under high-cardinality churn.** `ack`, TTL |
| 43 | + expiry, and DLQ drain a variant without removing it from `ckVtime` (documented |
| 44 | + in `CK_VTIME_KNOWN_LIMITATIONS.md`). Under churn where variants drain via those |
| 45 | + paths rather than a vtime dequeue, `ckVtime` may transiently exceed `ckIndex`, |
| 46 | + but it should self-heal (next vtime pass GCs empties) or expire (24h TTL), so |
| 47 | + `size(ckVtime) / size(ckIndex)` stays bounded and does not grow without limit. |
| 48 | + |
| 49 | +## Scenarios |
| 50 | + |
| 51 | +All on a single base queue (worst case for one queue's ZSETs), dedicated Redis, |
| 52 | +flag OFF then ON with identical load. |
| 53 | + |
| 54 | +- **Cardinality sweep (memory).** Enqueue N distinct concurrency keys, one |
| 55 | + message each, N in {100, 1_000, 10_000, 50_000}. Measure the Redis memory |
| 56 | + footprint at rest for each N, OFF vs ON. This isolates the storage cost with no |
| 57 | + dequeue activity. |
| 58 | +- **Steady-state load (CPU).** At each N, after building cardinality, run a fixed |
| 59 | + 60s workload of enqueue + batched dequeue (`maxCount 10`) + ack at a capped |
| 60 | + concurrency, so keys are continuously served and re-registered. Measure Redis |
| 61 | + CPU and per-command time over the window, OFF vs ON. |
| 62 | +- **Churn / tombstone (memory under adversarial drain).** Build 10k keys, then |
| 63 | + drain them via `ack` (not via vtime dequeue) while enqueuing new keys, for a |
| 64 | + fixed duration. Sample `size(ckVtime)` and `size(ckIndex)` over time and confirm |
| 65 | + the ratio stays bounded (self-heal + TTL), not monotonically growing. |
| 66 | + |
| 67 | +## Metrics and collection (exact commands) |
| 68 | + |
| 69 | +Use a second plain Redis client for measurement so it does not perturb the |
| 70 | +harness. `redis-cli` shown; the harness issues the same via ioredis. |
| 71 | + |
| 72 | +Memory: |
| 73 | + |
| 74 | +- Totals: `INFO memory` -> `used_memory`, `used_memory_dataset`. Delta ON vs OFF |
| 75 | + at each N is the incremental footprint. |
| 76 | +- Per structure (exact bytes): `MEMORY USAGE {org...}:queue:<base>:ckIndex` and |
| 77 | + `MEMORY USAGE {org...}:queue:<base>:ckVtime`. Report both and the ratio. |
| 78 | +- Encoding: `OBJECT ENCODING <ckVtime key>` (listpack vs skiplist) at each N, to |
| 79 | + mark the transition. |
| 80 | + |
| 81 | +CPU: |
| 82 | + |
| 83 | +- Process CPU over the load window: `INFO cpu` -> `used_cpu_user` + |
| 84 | + `used_cpu_sys`, sampled before and after the fixed 60s load; the delta is Redis |
| 85 | + CPU-seconds consumed. Divide by op count for CPU-per-op. |
| 86 | +- Per-command time: `CONFIG RESETSTAT` before the window, then `INFO commandstats` |
| 87 | + after -> `cmdstat_zadd`, `cmdstat_zrange`, `cmdstat_zrangebyscore`, |
| 88 | + `cmdstat_zscore`, `cmdstat_get`, `cmdstat_set`, `cmdstat_expire` |
| 89 | + (`calls`, `usec`, `usec_per_call`). Compare OFF vs ON. |
| 90 | +- Cross-check (optional, OS-level): `pidstat -p <redis-pid> 1` over the window, or |
| 91 | + `redis-cli --latency` / `--latency-history` for command latency. |
| 92 | + |
| 93 | +Derived: |
| 94 | + |
| 95 | +- Memory vs N curve (expect linear, slope ~ per-member bytes), OFF and ON. |
| 96 | +- CPU-per-op vs N curve (expect flat-ish / log), OFF/ON ratio. |
| 97 | +- `size(ckVtime)/size(ckIndex)` over time in the churn scenario (expect bounded). |
| 98 | + |
| 99 | +## A/B procedure |
| 100 | + |
| 101 | +1. Dedicated throwaway Redis; `FLUSHDB` between arms and between cardinality |
| 102 | + points. Warm up once. |
| 103 | +2. For each N in the sweep, for each arm (OFF, ON): |
| 104 | + - Build N keys (enqueue N distinct concurrency keys). |
| 105 | + - Snapshot memory (`used_memory`, `MEMORY USAGE` of both ZSETs, `OBJECT |
| 106 | + ENCODING`). |
| 107 | + - `CONFIG RESETSTAT`; run the fixed 60s steady load; snapshot `INFO cpu` and |
| 108 | + `INFO commandstats`. |
| 109 | + - Record, then `FLUSHDB`. |
| 110 | +3. N trials of the load window per point; report median for CPU (memory at rest is |
| 111 | + near-deterministic). |
| 112 | +4. Run the churn scenario once per arm at N = 10k. |
| 113 | + |
| 114 | +Only the OFF-vs-ON delta and the shape of the growth curves are reported; absolute |
| 115 | +throughput on a single box is not prod scale. |
| 116 | + |
| 117 | +## Results template |
| 118 | + |
| 119 | +### Memory at rest, per cardinality (single base queue) |
| 120 | + |
| 121 | +| keys (N) | used_memory OFF | used_memory ON | delta | MEMORY USAGE ckIndex | MEMORY USAGE ckVtime | ckVtime/ckIndex | ckVtime encoding | |
| 122 | +| --- | --- | --- | --- | --- | --- | --- | --- | |
| 123 | +| 100 | | | | | | | | |
| 124 | +| 1,000 | | | | | | | | |
| 125 | +| 10,000 | | | | | | | | |
| 126 | +| 50,000 | | | | | | | | |
| 127 | + |
| 128 | +### Redis CPU under 60s steady load, per cardinality |
| 129 | + |
| 130 | +| keys (N) | CPU-sec OFF | CPU-sec ON | delta | dequeue usec/call OFF | dequeue usec/call ON | delta | total redis calls OFF/ON | |
| 131 | +| --- | --- | --- | --- | --- | --- | --- | --- | |
| 132 | +| 100 | | | | | | | | |
| 133 | +| 1,000 | | | | | | | | |
| 134 | +| 10,000 | | | | | | | | |
| 135 | + |
| 136 | +### Tombstone drift (N=10k, drain via ack) |
| 137 | + |
| 138 | +| elapsed | size(ckIndex) | size(ckVtime) | ratio | |
| 139 | +| --- | --- | --- | --- | |
| 140 | +| 0s | | | | |
| 141 | +| 30s | | | | |
| 142 | +| 60s | | | | |
| 143 | + |
| 144 | +## Harness |
| 145 | + |
| 146 | +Extend the existing micro-benchmark |
| 147 | +(`../../src/run-queue/bench/ckMicroBench.bench.test.ts`), which already drives a |
| 148 | +real `RunQueue` against an external Redis and reads `INFO commandstats`. Add a |
| 149 | +resource/cardinality mode that: builds N variants, snapshots `used_memory` + |
| 150 | +`MEMORY USAGE` of the base queue's `ckIndex`/`ckVtime` + `OBJECT ENCODING`, runs a |
| 151 | +fixed-duration steady load while sampling `INFO cpu`/`commandstats`, and emits the |
| 152 | +tables above. The churn scenario reuses the same enqueue/ack primitives with a |
| 153 | +drain-by-ack loop and periodic `ZCARD` sampling of both ZSETs. |
| 154 | + |
| 155 | +Reuse the same dedicated Redis and the OFF-vs-ON constructor-flag pattern, so this |
| 156 | +arm, like the micro-benchmark, needs no webapp or redeploy. |
0 commit comments