|
| 1 | +# Deferred: per-tenant ZSET indices for sorted list-reservations |
| 2 | + |
| 3 | +**Status:** deferred — not currently scheduled. Triggers below. |
| 4 | +**Context:** analysed 2026-04-16 during v0.1.25.12 release (introduced `sort_by` + `sort_dir` on `GET /v1/reservations` per cycles-protocol spec revision 2026-04-16). |
| 5 | +**Owner:** unassigned until a triggering condition lands. |
| 6 | + |
| 7 | +## The problem this would solve |
| 8 | + |
| 9 | +v0.1.25.12 ships a **sorted list path** on `GET /v1/reservations`. The current implementation does a full Redis `SCAN` of `reservation:res_*`, filters in-stream, sorts in memory, and slices by cursor. This is **O(N) in total reservations** (not per-tenant) because `SCAN` walks the whole keyspace. Every sorted-list request repeats this work. |
| 10 | + |
| 11 | +At runtime-plane scale (~10³ reservations per tenant, ~10⁴ total across tenants), full-SCAN is fine — ~4ms on the benchmark, well inside any practical SLO. Above that, latency degrades roughly linearly with total keyspace size. |
| 12 | + |
| 13 | +``` |
| 14 | + sorted-list p50 vs. total reservations |
| 15 | + -------------------------------------- |
| 16 | + 100 — ~4ms (baseline) |
| 17 | + 1,000 — ~10ms |
| 18 | + 10,000 — ~80–100ms |
| 19 | + 100,000 — ~800ms+ (Redis GC pressure dominates) |
| 20 | +``` |
| 21 | + |
| 22 | +The ZSET-indexed approach replaces the full SCAN with `ZRANGEBYSCORE` (or `ZRANGEBYLEX` for string keys), which is **O(log N + page_size)** regardless of total keyspace size. |
| 23 | + |
| 24 | +## Trigger — when to schedule this |
| 25 | + |
| 26 | +Don't do this speculatively. Schedule it when **any** of the following is true: |
| 27 | + |
| 28 | +1. A real tenant crosses ~10 k active reservations. Track via `http_server_requests_seconds{uri="/v1/reservations"}` p99 broken down by `tenant` tag. |
| 29 | +2. Sorted-list p99 on a production deployment crosses ~50 ms sustained over any 1-hour window. |
| 30 | +3. A new customer ships with an advertised SLO on list-endpoint latency tighter than 100 ms p99. |
| 31 | + |
| 32 | +None of these conditions hold as of v0.1.25.12. The `OPERATIONS.md` "Reservation list sorting" section already names the metric to watch. |
| 33 | + |
| 34 | +## Design |
| 35 | + |
| 36 | +### New Redis key shape — 7 sorted sets per tenant |
| 37 | + |
| 38 | +One ZSET per (`tenant`, sort key) combination: |
| 39 | + |
| 40 | +``` |
| 41 | +reservation:idx:<tenant>:reservation_id (ZSET, lex, score=0) |
| 42 | +reservation:idx:<tenant>:tenant (ZSET, lex, score=0) |
| 43 | +reservation:idx:<tenant>:scope_path (ZSET, lex, score=0) |
| 44 | +reservation:idx:<tenant>:status (ZSET, score=status_ordinal, lex tiebreak) |
| 45 | +reservation:idx:<tenant>:reserved (ZSET, numeric score = amount) |
| 46 | +reservation:idx:<tenant>:created_at_ms (ZSET, numeric score = epoch ms) |
| 47 | +reservation:idx:<tenant>:expires_at_ms (ZSET, numeric score = epoch ms) |
| 48 | +``` |
| 49 | + |
| 50 | +- **Member** is always `reservation_id` (canonical cursor anchor). |
| 51 | +- **Score** is numeric for numeric keys, ordinal for `status` (so `status=ACTIVE` all sort together), and `0` for pure lex keys (`ZRANGEBYLEX` handles ordering without touching the score). |
| 52 | +- Per-tenant partitioning keeps each index small — even a tenant with 10 k reservations yields a 10 k-entry ZSET, which Redis handles in microseconds. |
| 53 | + |
| 54 | +### Lua script changes (5 of 6 scripts touched) |
| 55 | + |
| 56 | +All index updates go **inside** the existing Lua `EVAL` so the hash and index can't drift under partial failure. |
| 57 | + |
| 58 | +| Script | Change | Why | |
| 59 | +|---|---|---| |
| 60 | +| `reserve.lua` | `ZADD` into all 7 indices on successful creation | New reservation becomes visible to sorted list | |
| 61 | +| `commit.lua` | `ZADD` into status index with new ordinal | ACTIVE → COMMITTED | |
| 62 | +| `release.lua` | Same as commit | ACTIVE → RELEASED | |
| 63 | +| `extend.lua` | `ZADD` into `expires_at_ms` index (overwrite) | Score changes with extension | |
| 64 | +| `expire.lua` | `ZADD` into status index (ACTIVE → EXPIRED) | Background sweep must update | |
| 65 | +| `event.lua` | no change | Events don't mutate reservations | |
| 66 | + |
| 67 | +`ZADD` inside Lua is atomic with the existing `HSET` — if the Lua aborts, neither lands. |
| 68 | + |
| 69 | +### Repository changes — `RedisReservationRepository.listReservationsSorted` |
| 70 | + |
| 71 | +- Replace full `SCAN` with `ZRANGEBYSCORE` (numeric) or `ZRANGEBYLEX` (string). |
| 72 | +- `ZREVRANGEBYSCORE` for `sort_dir=desc`. |
| 73 | +- **Filtered + sorted** is the hard bit — three options, pick one: |
| 74 | + 1. **Per-filter ZSETs** — `reservation:idx:<tenant>:<sort_key>:<filter_key>=<filter_value>`. Combinatorial explosion; rejected. |
| 75 | + 2. **Hybrid batch-and-filter** (recommended): `ZRANGEBYSCORE` a wide batch (~2× `limit`), `HGETALL`-pipeline the members, post-filter, continue if the page is short. Adapts the existing cursor logic; simplest to ship. |
| 76 | + 3. **`ZINTERSTORE` transient sets** — expensive, adds Redis write load on every read. Rejected. |
| 77 | +- Cursor format evolves to store `(score, member)` instead of `(last_sort_value, last_reservation_id)`. The on-wire cursor is opaque, so this is an implementation detail clients don't see — no protocol bump. |
| 78 | + |
| 79 | +### Backfill migration |
| 80 | + |
| 81 | +One-time job to populate all 7 ZSETs from existing `reservation:res_*` hashes: |
| 82 | +- Standalone `MigrationService` triggered by an admin endpoint, or on-startup idempotent check (count-compare index ZCARD vs. tenant reservation count; populate if drift). |
| 83 | +- **Read-path fallback during rollout:** if `ZRANGEBYSCORE` returns empty but the tenant has reservations (`EXISTS reservation:res_*`), fall back to the current SCAN path. Removes once migration completes. |
| 84 | + |
| 85 | +## Performance impact |
| 86 | + |
| 87 | +### Write path — regression (new `ZADD`s inside Lua) |
| 88 | + |
| 89 | +| Operation | Indices touched | Per-op cost (N=10k per tenant) | Expected latency delta | |
| 90 | +|---|---|---|---| |
| 91 | +| **Reserve** | **7** — all sort indices | 7 × O(log N) ≈ ~100 μs in Lua | **+3–6% p50** (5.3 ms → ~5.5–5.6 ms) | |
| 92 | +| **Commit** | 1 (status) | ~14 μs | **+1–2% p50** | |
| 93 | +| **Release** | 1 (status) | ~14 μs | **+1–2% p50** | |
| 94 | +| **Extend** | 1 (expires_at_ms, overwrite) | ~14 μs | **+1% p50** | |
| 95 | +| **Expire** (background) | 1 (status) | ~14 μs | off hot path | |
| 96 | +| **Decide** | 0 — no state change | — | unchanged | |
| 97 | +| **Event** | 0 — no reservation mutation | — | unchanged | |
| 98 | + |
| 99 | +**Concurrent throughput at 32 threads:** probably **−2 to −4%** (reserve-dominated mix). Benchmark reference: 2,632 ops/s at v0.1.25.7 → estimated 2,520–2,580 ops/s. |
| 100 | + |
| 101 | +All costs are in-memory, single-threaded inside Redis, no new network round-trips. |
| 102 | + |
| 103 | +### Read path — dramatic improvement |
| 104 | + |
| 105 | +| Scenario | Current full-SCAN | With ZSET | Delta | |
| 106 | +|---|---|---|---| |
| 107 | +| Sorted list, **100 reservations** | ~4 ms | ~3 ms | ~25% faster | |
| 108 | +| Sorted list, **1 k reservations** | ~10 ms | ~2 ms | **5× faster** | |
| 109 | +| Sorted list, **10 k reservations** | ~80–100 ms | ~2–3 ms | **30–50× faster** | |
| 110 | +| Sorted list, **100 k reservations** | ~800 ms+ | ~3–5 ms | **100×+ faster** | |
| 111 | +| Unsorted list (legacy SCAN path) | ~4 ms | unchanged | no change | |
| 112 | +| `GET /v1/reservations/{id}` | ~3 ms | unchanged | no change | |
| 113 | +| `GET /v1/balances` | ~4 ms | unchanged | no change | |
| 114 | + |
| 115 | +Inflection point is ~1 k reservations per tenant. Below that, SCAN is already fine. |
| 116 | + |
| 117 | +### Memory overhead |
| 118 | + |
| 119 | +- **~560 bytes/reservation** in index overhead (7 ZSET entries × ~80 bytes each). |
| 120 | +- At 100 k reservations: ~56 MB total. |
| 121 | +- For deployments with `maxmemory 256mb` (smaller prod setups), that's ~22% of the Redis budget — **worth flagging in ops docs**. For typical multi-GB Redis deployments, rounding error. |
| 122 | + |
| 123 | +## Benchmark suite impact |
| 124 | + |
| 125 | +### What would change in the existing benchmark (current suite at v0.1.25.7) |
| 126 | + |
| 127 | +| Benchmark | Current | Expected with ZSET | Release-gate risk | |
| 128 | +|---|---|---|---| |
| 129 | +| Reserve p50 | 5.3 ms | **5.5–5.6 ms (+4–6%)** | Safe — 25% gate has headroom, but chips into it | |
| 130 | +| Reserve p99 | ~13 ms | ~13.5–14 ms (+4–8%) | Safe | |
| 131 | +| Commit p50 | 4.6 ms | ~4.65 ms (+1%) | Within noise | |
| 132 | +| Release p50 | 4.8 ms | ~4.85 ms (+1%) | Within noise | |
| 133 | +| Extend p50 | 7.5 ms | ~7.6 ms (+1%) | Within noise | |
| 134 | +| LIST reservations (unsorted) | 4.5 ms | unchanged | no change | |
| 135 | +| Concurrent @ 32 threads | 2,632 ops/s | ~2,520–2,580 (−2 to −4%) | Safe | |
| 136 | + |
| 137 | +Reserve +4–6% consumes **about a fifth of the gate's 25% safety margin** in a single change. Future write-path work that stacks on this (new event emission, another filter dimension) could compound. |
| 138 | + |
| 139 | +### What would need to be ADDED to the suite |
| 140 | + |
| 141 | +The current `CyclesProtocolReadBenchmarkTest` has no sorted-list case — the endpoint was added in v0.1.25.12, the suite was last run at v0.1.25.7. To make the optimization's win visible: |
| 142 | + |
| 143 | +- **New benchmarks:** `LIST reservations sorted@1k`, `LIST reservations sorted@10k`. |
| 144 | +- Seed fixtures of 1 k / 10 k reservations inside the Testcontainers Redis (slower test setup: ~30 s seed time). |
| 145 | +- Run against both the current full-SCAN path (baseline) and the new ZSET path (comparison). |
| 146 | + |
| 147 | +**Without new benchmarks, the optimization looks like a pure regression** in the write path with no visible upside. The new benchmarks are not optional. |
| 148 | + |
| 149 | +### Release-process guidance (when this work is scheduled) |
| 150 | + |
| 151 | +1. **Freeze a baseline run at the version immediately preceding** the ZSET change so the regression is measured, not inferred. Do this before any of the ZSET code lands. |
| 152 | +2. **Do NOT use `[benchmark-skip]`** on the release notes. This is a hot-path change; benchmarks must run. |
| 153 | +3. **Re-baseline deliberately.** Mirror the v0.1.25.6 precedent ("zero overhead on success path" documented): write a BENCHMARKS.md section that explicitly documents the +5% reserve regression as an accepted trade-off for the read-path gain. Then manually update `benchmarks/baseline.json` on the merge commit. |
| 154 | +4. **Ship the sorted-list benchmarks in the same release** so the delta tells the full story. |
| 155 | + |
| 156 | +## Rollback story |
| 157 | + |
| 158 | +Pure Redis-only change, fully reversible: |
| 159 | +1. Delete all `reservation:idx:*` keys via `SCAN + DEL`. |
| 160 | +2. Flip the repository feature flag (add one) back to the full-SCAN path. |
| 161 | +3. Redeploy. No wire format changed; clients see no difference. |
| 162 | + |
| 163 | +## Effort estimate |
| 164 | + |
| 165 | +| Workstream | Days | |
| 166 | +|---|---| |
| 167 | +| Lua changes (5 scripts × ZADD + tests) | ~1 | |
| 168 | +| Repository (ZRANGE-based paging + filtered-sort hybrid) | ~2 | |
| 169 | +| Migration + fallback | ~1 | |
| 170 | +| Tests + new benchmarks | ~2 | |
| 171 | +| **Total** | **~1 week** | |
| 172 | + |
| 173 | +Tractable for a single engineer. |
| 174 | + |
| 175 | +## What this does NOT change |
| 176 | + |
| 177 | +- Wire format — cursors stay opaque; clients don't see the internals. |
| 178 | +- `cycles-protocol-v0.yaml` spec — no spec change required. |
| 179 | +- Admin spec / governance surface — pure runtime concern. |
| 180 | +- New sort keys — 7 covers the full spec enum; no scope creep. |
| 181 | +- Non-sorted list path — byte-for-byte unchanged. |
| 182 | + |
| 183 | +## Decision log |
| 184 | + |
| 185 | +- **2026-04-16 (v0.1.25.12):** chose hybrid "load-all + sort-in-memory" for the initial sorted-list implementation; deferred ZSETs until a scale trigger. Rationale in `AUDIT.md` entry for v0.1.25.12. This document captures the deferred design for when a trigger fires. |
0 commit comments