Skip to content

Commit fc936c4

Browse files
committed
perf(bench): baseline sorted reservation scaling (v0.1.25.53)
1 parent b0db4fc commit fc936c4

13 files changed

Lines changed: 317 additions & 172 deletions

File tree

AUDIT.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,44 @@
55

66
---
77

8+
### 2026-07-14 — v0.1.25.53: sorted-list scaling baseline
9+
10+
This benchmark-only release is Phase A of #240. The sorted reservation path
11+
still performs its correctness-preserving global SCAN, tenant/filter hydration,
12+
in-memory sort, and cursor slice; no production repository or Lua behavior is
13+
changed.
14+
15+
`CyclesProtocolReadBenchmarkTest` now seeds 1,000- and 10,000-row populations
16+
directly through pipelined Redis hashes, with half the rows belonging to the
17+
authenticated tenant and half to an unrelated tenant. Each measured request is
18+
the default sorted query (`created_at_ms desc`, `limit=20`). Three same-host
19+
trials produced median p50/p95/p99 of 22.5/40.9/48.3 ms at 1,000 total rows and
20+
164.9/210.2/232.0 ms at 10,000. The 10k path hydrates 5,000 matching rows to
21+
return 20, confirming the next read optimization target without relying on the
22+
old truncating 2,000-row cap.
23+
24+
The parser, median aggregator, regression checker, and benchmark-data format
25+
now carry `list_sorted_1k_p50_ms` and `list_sorted_10k_p50_ms` as gating latency
26+
metrics. Shipping this measurement before the runtime index gives Phase B a
27+
real preceding-release baseline. The design note was rewritten around one
28+
default-sort ZSET with completeness count validation, explicit post-rollout
29+
readiness, restartable backfill, stale-member cleanup, bounded hydration, and a
30+
full-SCAN fallback; it explicitly rejects the permanent-marker failure modes
31+
removed from #235.
32+
33+
Version/revision 0.1.25.52 → 0.1.25.53. Both production compose variants pin
34+
the matching image. There is no public schema, successful-response, or Redis
35+
storage change.
36+
37+
**Validation.** `mvn clean verify -Pintegration-tests` completed 1,144 tests
38+
(31 model, 534 data, 579 API) with zero failures, errors, or skips; the
39+
authoritative protocol coverage check remained 11/11. JaCoCo line coverage is
40+
95.12% data and 95.56% API. The exact release benchmark command completed all
41+
17 cases with zero errors, emitted both new metrics, and parsed all nine
42+
headline values successfully. Focused three-run medians are recorded in
43+
`BENCHMARKS.md`. Both production Compose files pass `docker compose config
44+
--quiet` with their required variables supplied.
45+
846
### 2026-07-14 — v0.1.25.52: replay-storage and ledger-helper follow-up
947

1048
This non-behavioral follow-up closes the two intentionally deferred cleanup

BENCHMARKS.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ running them would only measure environmental noise. Skipped releases:
2727
list endpoint. The sorted path is opt-in (clients must pass the
2828
new params to activate it); legacy list behaviour is byte-for-byte
2929
unchanged and all existing benchmarks exercise the legacy path.
30-
Benchmarks for the sorted path are worth adding once real tenant
31-
populations exercise the O(N) full-SCAN; see OPERATIONS.md.
30+
Population benchmarks for the sorted path were added after v0.1.25.52 under
31+
#240; see the frozen pre-index baseline below.
3232
- **v0.1.25.13** — hydration cap + enum wire annotations on the sorted
3333
list path. Write-path unchanged.
3434
- **v0.1.25.14** — trace_id (W3C Trace Context) correlation. The new
@@ -39,7 +39,34 @@ running them would only measure environmental noise. Skipped releases:
3939
Benchmarks deliberately skipped — they would only measure
4040
environmental noise. [benchmark-skip]
4141

42-
Last benchmarked release: **v0.1.25.52**.
42+
Last benchmarked release: **v0.1.25.53**.
43+
44+
---
45+
46+
## v0.1.25.53 — Sorted-list scaling baseline
47+
48+
**Date:** 2026-07-14
49+
50+
**Tag:** `v0.1.25.53` *(benchmark-only follow-up; tag after merge)*
51+
52+
**Environment:** Windows 11 Pro for Workstations, AMD Ryzen Threadripper 3990X
53+
64-Core, Java 21, Docker + Redis 7 (Testcontainers)
54+
55+
**Method:** The runtime is unchanged from v0.1.25.52. Each fixture contains
56+
equal numbers of authenticated-tenant and unrelated-tenant reservation hashes,
57+
requests `limit=20&sort_by=created_at_ms&sort_dir=desc`, and measures 200
58+
requests after 50 warmups. Fixture writes are pipelined and complete before
59+
timing starts. Values are medians of three same-host trials.
60+
61+
| Total rows | Target-tenant rows | p50 | p95 | p99 |
62+
|---:|---:|---:|---:|---:|
63+
| 1,000 | 500 | 22.5ms | 40.9ms | 48.3ms |
64+
| 10,000 | 5,000 | 164.9ms | 210.2ms | 232.0ms |
65+
66+
**Assessment:** The 7.3x p50 increase for a 10x population confirms the
67+
global-SCAN/hydrate/sort path is the next material read bottleneck. These two
68+
p50s are parsed into nightly and release benchmark data so #240's planned
69+
indexed path has a machine-readable preceding-release comparison point.
4370

4471
---
4572

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ changes to request/response bodies or Lua-script semantics would require a
1414
minor bump. "Internal signature changes" (e.g. Java method parameters) are
1515
called out but are not breaking to API clients.
1616

17+
## [0.1.25.53] — 2026-07-14
18+
19+
### Performance measurement
20+
21+
- **Sorted reservation listing has population baselines.** The benchmark suite
22+
now measures the default `created_at_ms desc` sorted-list query at 1,000 and
23+
10,000 total reservation hashes, split evenly between the authenticated and
24+
an unrelated tenant. Both p50 values are tracked by nightly and release
25+
regression checks, establishing the pre-index comparison point for #240.
26+
- Benchmark fixture writes are pipelined and complete before timing begins.
27+
Expected large-hydration warnings are suppressed only in the benchmark
28+
profile so console I/O does not distort the measurement.
29+
30+
### Compatibility
31+
32+
- No production request-path, Redis-data, or wire-format behavior changes.
33+
Production and full-stack compose defaults self-pin
34+
`ghcr.io/runcycles/cycles-server:0.1.25.53`.
35+
1736
## [0.1.25.52] — 2026-07-14
1837

1938
### Performance

benchmarks/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ https://raw.githubusercontent.com/runcycles/cycles-server/benchmark-data/benchma
4444

4545
## Headline metrics tracked
4646

47-
Seven numbers chosen for signal density, not exhaustiveness. More metrics
47+
Nine numbers chosen for signal density, not exhaustiveness. More metrics
4848
means more noise and a higher false-positive rate on the gate.
4949

5050
| Metric | Source test | Why |
@@ -53,14 +53,16 @@ means more noise and a higher false-positive rate on the gate.
5353
| `commit_p50_ms`, `commit_p99_ms` | `CyclesProtocolBenchmarkTest` | Second write path |
5454
| `release_p50_ms` | `CyclesProtocolBenchmarkTest` | Cleanup path |
5555
| `event_p50_ms` | `CyclesProtocolBenchmarkTest` | Direct-debit path |
56+
| `list_sorted_1k_p50_ms` | `CyclesProtocolReadBenchmarkTest` | Sorted-list scaling at moderate population |
57+
| `list_sorted_10k_p50_ms` | `CyclesProtocolReadBenchmarkTest` | Sorted-list scaling trigger / indexed-path payoff |
5658
| `concurrent_throughput_32t` | `CyclesProtocolConcurrentBenchmarkTest` | Scaling signal |
5759

5860
## Entry format (`history.jsonl`)
5961

6062
Each line is a standalone JSON object:
6163

6264
```json
63-
{"timestamp":"2026-04-15T07:00:00Z","commit":"abc1234","tag":null,"reserve_p50_ms":5.3,"reserve_p99_ms":18.2,"commit_p50_ms":4.6,"commit_p99_ms":15.1,"release_p50_ms":4.8,"event_p50_ms":4.3,"concurrent_throughput_32t":2632}
65+
{"timestamp":"2026-07-15T07:00:00Z","commit":"abc1234","tag":null,"reserve_p50_ms":5.3,"reserve_p99_ms":18.2,"commit_p50_ms":4.6,"commit_p99_ms":15.1,"release_p50_ms":4.8,"event_p50_ms":4.3,"list_sorted_1k_p50_ms":22.5,"list_sorted_10k_p50_ms":164.9,"concurrent_throughput_32t":2632}
6466
```
6567

6668
- `timestamp` — UTC, ISO 8601

cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/CyclesProtocolReadBenchmarkTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import org.junit.jupiter.api.*;
44
import org.springframework.http.ResponseEntity;
55
import org.springframework.test.context.ActiveProfiles;
6+
import redis.clients.jedis.Jedis;
7+
import redis.clients.jedis.Pipeline;
68

79
import java.util.*;
810
import java.util.stream.LongStream;
@@ -78,6 +80,18 @@ void benchmarkListReservations() {
7880
timings[0], timings[timings.length - 1], mean(timings)));
7981
}
8082

83+
@Test
84+
@DisplayName("GET /v1/reservations sorted at 1k total rows")
85+
void benchmarkSortedReservationsAt1k() {
86+
benchmarkSortedReservations(1_000, "LIST sorted @1k");
87+
}
88+
89+
@Test
90+
@DisplayName("GET /v1/reservations sorted at 10k total rows")
91+
void benchmarkSortedReservationsAt10k() {
92+
benchmarkSortedReservations(10_000, "LIST sorted @10k");
93+
}
94+
8195
@Test
8296
@DisplayName("GET /v1/balances")
8397
void benchmarkGetBalances() {
@@ -152,4 +166,50 @@ private Map<String, Object> decideBody(String tenant, long amount) {
152166
body.put("estimate", Map.of("unit", "TOKENS", "amount", amount));
153167
return body;
154168
}
169+
170+
/**
171+
* Seed half the population for the authenticated tenant and half for an unrelated
172+
* tenant. The current sorted path scans the global reservation keyspace before
173+
* filtering by tenant, so this fixture exposes both population size and tenant
174+
* selectivity. Direct pipelined hashes keep setup time out of the measurement.
175+
*/
176+
private void benchmarkSortedReservations(int totalRows, String benchmarkName) {
177+
seedReservationPopulation(totalRows);
178+
179+
long[] timings = runBenchmark(benchmarkName, () -> {
180+
ResponseEntity<Map> resp = get(
181+
"/v1/reservations?limit=20&sort_by=created_at_ms&sort_dir=desc",
182+
API_KEY_SECRET_A);
183+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
184+
assertThat((List<?>) resp.getBody().get("reservations")).hasSize(20);
185+
});
186+
record(new BenchmarkResult(benchmarkName, p(timings, 50), p(timings, 95), p(timings, 99),
187+
timings[0], timings[timings.length - 1], mean(timings)));
188+
}
189+
190+
private void seedReservationPopulation(int totalRows) {
191+
final long createdAtBase = 1_700_000_000_000L;
192+
try (Jedis jedis = jedisPool.getResource(); Pipeline pipeline = jedis.pipelined()) {
193+
for (int i = 0; i < totalRows; i++) {
194+
String tenant = i % 2 == 0 ? TENANT_A : TENANT_B;
195+
String reservationId = String.format("bench-%05d", i);
196+
pipeline.hset("reservation:res_" + reservationId, Map.ofEntries(
197+
Map.entry("reservation_id", reservationId),
198+
Map.entry("tenant", tenant),
199+
Map.entry("state", "ACTIVE"),
200+
Map.entry("subject_json", "{\"tenant\":\"" + tenant + "\"}"),
201+
Map.entry("action_json", "{\"kind\":\"llm.completion\",\"name\":\"benchmark\"}"),
202+
Map.entry("estimate_amount", "100"),
203+
Map.entry("estimate_unit", "TOKENS"),
204+
Map.entry("scope_path", "tenant:" + tenant),
205+
Map.entry("affected_scopes", "[\"tenant:" + tenant + "\"]"),
206+
Map.entry("created_at", String.valueOf(createdAtBase + i)),
207+
Map.entry("expires_at", String.valueOf(createdAtBase + 86_400_000L + i))));
208+
if ((i + 1) % 500 == 0) {
209+
pipeline.sync();
210+
}
211+
}
212+
pipeline.sync();
213+
}
214+
}
155215
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Benchmark profile — suppress all logging so only benchmark result tables are visible
22
logging.level.root=WARN
33
logging.level.io.runcycles.protocol=WARN
4+
# Large sorted-list fixtures intentionally cross the repository's hydration
5+
# warning threshold on every measured request. Suppress that expected warning
6+
# so console I/O does not become part of the latency measurement.
7+
logging.level.io.runcycles.protocol.data.repository.RedisReservationRepository=ERROR
48
logging.level.org.springframework=WARN
59
logging.level.org.testcontainers=WARN
610
logging.level.com.github.dockerjava=WARN

cycles-protocol-service/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<module>cycles-protocol-service-api</module>
1919
</modules>
2020
<properties>
21-
<revision>0.1.25.52</revision>
21+
<revision>0.1.25.53</revision>
2222
<java.version>21</java.version>
2323
<maven.compiler.source>21</maven.compiler.source>
2424
<maven.compiler.target>21</maven.compiler.target>

docker-compose.full-stack.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ services:
2424

2525
cycles-server:
2626
logging: *default-logging
27-
image: ghcr.io/runcycles/cycles-server:0.1.25.52
27+
image: ghcr.io/runcycles/cycles-server:0.1.25.53
2828
restart: unless-stopped
2929
ports:
3030
- "7878:7878"

docker-compose.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ services:
2424

2525
cycles-server:
2626
logging: *default-logging
27-
image: ghcr.io/runcycles/cycles-server:0.1.25.52
27+
image: ghcr.io/runcycles/cycles-server:0.1.25.53
2828
restart: unless-stopped
2929
ports:
3030
- "7878:7878"

0 commit comments

Comments
 (0)