Skip to content

Commit d5d67a9

Browse files
authored
Merge pull request #105 from runcycles/docs/deferred-sorted-list-zset-indices
docs: defer-design — per-tenant ZSET indices for sorted list
2 parents 93f26c8 + 417fd45 commit d5d67a9

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

OPERATIONS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,11 @@ p99: if it climbs above 500 ms under real load, a tenant has grown
380380
past the in-memory threshold and per-tenant ZSET indexing becomes
381381
worthwhile. Track the top `tenant` tag on that metric to spot who.
382382

383+
The deferred ZSET-indexed design is fully written up in
384+
[`docs/deferred-optimizations/sorted-list-zset-indices.md`](docs/deferred-optimizations/sorted-list-zset-indices.md)
385+
— cost/benefit, trigger conditions, benchmark impact, rollback.
386+
Pull it out when any of the triggers listed there fires.
387+
383388
Legacy clients (no sort params) stay on the existing Redis-SCAN
384389
cursor path and are unaffected by this concern.
385390

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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 (~`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

Comments
 (0)