Skip to content

Commit c74f510

Browse files
authored
Merge pull request #262 from runcycles/agent/benchmark-latency-context
Benchmark reserve fan-out from 1 to 200 clients
2 parents c9b88a8 + 3048646 commit c74f510

4 files changed

Lines changed: 187 additions & 63 deletions

File tree

AUDIT.md

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

66
---
77

8+
### 2026-07-30 — controlled 1/10/50/200-client reserve evidence (no version bump)
9+
10+
Benchmark-only correction and expansion. External review correctly noted that
11+
the 200-client homepage number lacked a same-harness low-concurrency
12+
denominator. The reserve fan-out now runs at 1, 10, 50, and 200 clients for
13+
both a shared tenant ledger and independent agent leaf ledgers. A system
14+
property can select one client level so every published cell is measured in a
15+
fresh Maven, Spring, and Testcontainers process. Warmup now performs at least
16+
50 requests, reaches every logical client, and uses a controlled ramp capped
17+
at 50 concurrent requests before resetting ledger state and starting the
18+
five-second measured window.
19+
20+
Three fresh-process trials per shape and level completed 70,046 reservations
21+
with zero request errors and zero Redis ledger mismatches. Shared-ledger p99
22+
medians were 32.4ms at 1 client, 40.6ms at 10, 113.8ms at 50, and 1,325.9ms
23+
at 200-client saturation. Independent-ledger medians were 32.8ms, 42.6ms,
24+
117.0ms, and 929.1ms respectively. The earlier same-day 531.7ms shared and
25+
446.8ms isolated 200-client figures are superseded: review found they inherited
26+
warm state from preceding suite tests and their sequential warmups did not
27+
control wide-fan-out connection state. Production Java, Lua, Redis behavior,
28+
wire formats, dependencies, and release version remain unchanged.
29+
`[benchmark-skip]`
30+
831
### 2026-07-30 — 200-client reserve fan-out benchmark (no version bump)
932

1033
Benchmark-only change. The concurrent suite now measures the synchronous

BENCHMARKS.md

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,57 @@ Performance benchmark history across versions. All benchmarks use `CyclesProtoco
55

66
Results are environment-dependent. Use for relative comparison across versions on the same hardware, not as absolute SLA targets. Latencies include the full HTTP round-trip: Spring Boot request handling, auth filter, JSON serialization, Redis EVALSHA, Lua execution, and response building.
77

8-
The concurrent suite also runs two sustained 200-client reserve fan-outs after
9-
50 warmups: one contending on a shared tenant budget and one sharded across
10-
independent agent-level leaf budgets. It records reserve p99, throughput, and
11-
error rate, and fails on any mismatch between successful reservations and the
12-
resulting Redis `reserved` totals.
8+
The concurrent suite also runs sustained reserve fan-outs at 1, 10, 50, and
9+
200 clients in two shapes: one contending on a shared tenant budget and one
10+
sharded across independent agent-level leaf budgets. It records reserve p99,
11+
throughput, and error rate, and fails on any mismatch between successful
12+
reservations and the resulting Redis `reserved` totals.
1313

14-
### 200-client reserve fan-out — v0.1.25.59 (2026-07-30)
14+
### Fresh-process reserve fan-out — v0.1.25.59 (2026-07-30)
1515

1616
Reference environment: AMD Ryzen Threadripper 3990X (64 cores), Java 21,
1717
Spring Boot 3.5.16, Redis 7 Alpine in Testcontainers, Docker Desktop 29.6.1,
18-
and localhost networking. The table reports the median of three fresh-process
19-
trials. Each trial performed 50 warmups, then measured one five-second window
20-
per shape.
21-
22-
| Budget shape | Reserve p99 median (range) | Throughput median (range) | Errors | Ledger mismatches |
23-
|---|---:|---:|---:|---:|
24-
| Shared tenant budget | 531.7ms (524.8–532.3ms) | 1,328.6 reserves/s (1,295.0–1,354.6) | 0 | 0 |
25-
| Independent agent leaf budgets | 446.8ms (438.8–455.7ms) | 1,683.4 reserves/s (1,655.8–1,687.0) | 0 | 0 |
26-
27-
Across all six measured windows, 45,022 successful reservations completed
28-
with zero request errors and zero Redis ledger mismatches. These localhost
29-
results are reference evidence, not an SLO; network topology, server sizing,
30-
Redis placement, and client connection pools affect tail latency.
31-
32-
Run benchmarks: `mvn test -Pbenchmark` (requires Docker).
18+
and localhost networking. Every table cell reports the median and range of
19+
three trials that each started a fresh Maven, Spring, and Testcontainers
20+
process. A controlled warmup performs at least 50 requests and reaches every
21+
logical client, with warmup concurrency capped at 50. The ledger is then reset
22+
before one five-second measured window. This gives every client level the same
23+
startup and warmup treatment without opening 200 cold connections at once.
24+
25+
| Clients | Budget shape | Reserve p99 median (range) | Throughput median (range) | Errors | Ledger mismatches |
26+
|---:|---|---:|---:|---:|---:|
27+
| 1 | Shared tenant budget | 32.4ms (30.8–34.5ms) | 49.6 reserves/s (46.6–58.0) | 0 | 0 |
28+
| 1 | Independent agent leaf budgets | 32.8ms (26.4–40.7ms) | 56.2 reserves/s (52.0–60.4) | 0 | 0 |
29+
| 10 | Shared tenant budget | 40.6ms (40.1–46.4ms) | 430.0 reserves/s (424.8–435.8) | 0 | 0 |
30+
| 10 | Independent agent leaf budgets | 42.6ms (37.9–43.2ms) | 426.0 reserves/s (426.0–434.6) | 0 | 0 |
31+
| 50 | Shared tenant budget | 113.8ms (100.1–126.5ms) | 998.8 reserves/s (994.8–1,022.4) | 0 | 0 |
32+
| 50 | Independent agent leaf budgets | 117.0ms (115.4–165.6ms) | 986.0 reserves/s (817.6–990.6) | 0 | 0 |
33+
| 200 | Shared tenant budget | 1,325.9ms (1,131.0–1,738.1ms) | 927.8 reserves/s (886.8–954.2) | 0 | 0 |
34+
| 200 | Independent agent leaf budgets | 929.1ms (651.8–1,040.6ms) | 833.0 reserves/s (832.6–864.6) | 0 | 0 |
35+
36+
Across all 24 measured windows, 70,046 successful reservations completed with
37+
zero request errors and zero Redis ledger mismatches. Shared and isolated
38+
shapes are close through 50 clients. At 200 clients both shapes saturate the
39+
single application instance and its HTTP/Redis capacity, while the shared
40+
atomic ledger adds tail latency. These localhost results are reference
41+
evidence, not an SLO; network topology, server sizing, Redis placement, and
42+
client connection pools affect latency.
43+
44+
This matrix supersedes the earlier same-day 200-only figures of 531.7ms shared
45+
and 446.8ms isolated. Review showed those cells inherited warmup from preceding
46+
suite tests, while their 50 sequential warmups did not control wide-fan-out
47+
connection state. They are retained in Git history but are not used as current
48+
evidence.
49+
50+
Run all benchmark levels with `mvn test -Pbenchmark` (requires Docker). For a
51+
reproducible single cell, set `-Dbenchmark.fanout.clients=<level>` and select
52+
one fan-out test method, for example:
53+
54+
```bash
55+
mvn -pl cycles-protocol-service-api -am test -Pbenchmark \
56+
-Dtest='CyclesProtocolConcurrentBenchmarkTest#concurrentReserve_sharedBudget' \
57+
-Dbenchmark.fanout.clients=10
58+
```
3359

3460
### Release coverage
3561

benchmarks/README.md

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

4545
## Metrics tracked
4646

47-
Nine established regression signals are supplemented by eight fields from the
48-
200-client reserve fan-out. The Java benchmark itself fails above a 1% request
49-
error rate or on any Redis ledger mismatch; p99 remains informational in the
50-
cross-run regression gate because shared-runner tails are noisy.
47+
The Java suite measures reserve fan-out at 1, 10, 50, and 200 clients for both
48+
shared and isolated ledger shapes. Nine established regression signals are
49+
supplemented by eight machine-history fields from the 200-client saturation
50+
level. Lower-concurrency reference medians and ranges are curated in
51+
[`../BENCHMARKS.md`](../BENCHMARKS.md); the history pipeline keeps the widest
52+
level to avoid a breaking expansion of existing baseline records. History is
53+
a consistent full-suite regression signal and can inherit warm state from
54+
earlier tests; it is not interchangeable with the independently launched
55+
fresh-process reference cells. The Java benchmark itself fails above a 1%
56+
request error rate or on any Redis ledger mismatch; p99 remains informational
57+
in the cross-run regression gate because shared-runner tails are noisy.
5158

5259
| Metric | Source test | Why |
5360
|---|---|---|
@@ -82,9 +89,12 @@ Each line is a standalone JSON object:
8289

8390
The example fan-out values illustrate the record shape; they are not a
8491
published measurement. Each fan-out result measures reserve HTTP latency for
85-
five sustained seconds after 50 warmups. `shared` sends every client through
86-
one tenant budget; `isolated` removes that parent budget and assigns each
87-
client its own agent-level leaf budget.
92+
five sustained seconds after a controlled warmup of at least 50 requests that
93+
reaches every logical client, with warmup concurrency capped at 50. The ledger
94+
is reset before timing starts. `shared` sends every client through one tenant
95+
budget; `isolated` removes that parent budget and assigns each client its own
96+
agent-level leaf budget. Use `-Dbenchmark.fanout.clients=<level>` plus one
97+
fan-out test method to reproduce an individual fresh-process cell.
8898

8999
## Baseline format (`baseline.json`)
90100

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

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
* multiple threads executing Reserve→Commit lifecycles simultaneously.
2020
*
2121
* Lifecycle tests ramp from 8 → 16 → 32 concurrent threads. Reserve-only
22-
* tests add a 200-client fan-out in two shapes: one shared budget (the
23-
* contention case) and 200 independent leaf budgets (the sharded case).
22+
* tests ramp from 1 → 10 → 50 → 200 clients in two shapes: one shared
23+
* budget (the contention case) and independent leaf budgets (the sharded
24+
* case).
2425
*
2526
* Results are CI-environment sensitive — latency and throughput depend on
2627
* container resources, Redis container networking, and JVM warm-up.
2728
*
2829
* Run separately: mvn test -Pbenchmark
30+
* Select fan-out levels for isolated fresh-process trials with
31+
* -Dbenchmark.fanout.clients=1,10,50,200.
2932
*/
3033
@DisplayName("Concurrent Load Benchmarks")
3134
@Tag("benchmark")
@@ -34,8 +37,12 @@
3437
class CyclesProtocolConcurrentBenchmarkTest extends BaseIntegrationTest {
3538

3639
private static final int WARMUP_OPS = 50;
40+
private static final int MAX_WARMUP_CONCURRENCY = 50;
3741
private static final long MEASURE_DURATION_MS = 5_000;
38-
private static final int FANOUT_CLIENTS = 200;
42+
private static final List<Integer> DEFAULT_FANOUT_CLIENT_LEVELS =
43+
List.of(1, 10, 50, 200);
44+
private static final List<Integer> FANOUT_CLIENT_LEVELS =
45+
configuredFanoutClientLevels();
3946
private static final long FANOUT_ALLOCATION = 1_000_000_000_000L;
4047
private static final long FANOUT_RESERVE_AMOUNT = 100L;
4148
/** Max acceptable error rate (%) before failing the test */
@@ -111,16 +118,20 @@ void concurrentLifecycle_32threads() throws Exception {
111118

112119
@Test
113120
@Order(4)
114-
@DisplayName("Reserve fan-out at 200 clients on one shared budget")
115-
void concurrentReserve_200clients_sharedBudget() throws Exception {
116-
runConcurrentReserveFanout(false);
121+
@DisplayName("Reserve fan-out from 1 to 200 clients on one shared budget")
122+
void concurrentReserve_sharedBudget() throws Exception {
123+
for (int clients : FANOUT_CLIENT_LEVELS) {
124+
runConcurrentReserveFanout(false, clients);
125+
}
117126
}
118127

119128
@Test
120129
@Order(5)
121-
@DisplayName("Reserve fan-out at 200 clients on independent leaf budgets")
122-
void concurrentReserve_200clients_independentBudgets() throws Exception {
123-
runConcurrentReserveFanout(true);
130+
@DisplayName("Reserve fan-out from 1 to 200 clients on independent leaf budgets")
131+
void concurrentReserve_independentBudgets() throws Exception {
132+
for (int clients : FANOUT_CLIENT_LEVELS) {
133+
runConcurrentReserveFanout(true, clients);
134+
}
124135
}
125136

126137
private void runConcurrentLifecycle(int threadCount) throws Exception {
@@ -227,30 +238,27 @@ private void runConcurrentLifecycle(int threadCount) throws Exception {
227238
}
228239
}
229240

230-
private void runConcurrentReserveFanout(boolean isolated) throws Exception {
231-
prepareFanoutBudgets(isolated);
241+
private void runConcurrentReserveFanout(
242+
boolean isolated,
243+
int clientCount) throws Exception {
244+
prepareFanoutBudgets(isolated, clientCount);
232245

233-
// Prime JIT, auth cache, HTTP connection management, and EVALSHA before
234-
// resetting the measured ledger state.
235-
for (int i = 0; i < WARMUP_OPS; i++) {
236-
ResponseEntity<Map> response = post(
237-
"/v1/reservations",
238-
API_KEY_SECRET_A,
239-
fanoutReservationBody(isolated, i % FANOUT_CLIENTS));
240-
assertThat(response.getStatusCode().value()).isEqualTo(200);
241-
assertThat(response.getBody()).containsKey("reservation_id");
242-
}
243-
prepareFanoutBudgets(isolated);
246+
// Prime JIT, auth cache, HTTP connection management, and EVALSHA
247+
// before resetting the ledger. The bounded ramp exercises every
248+
// logical client without turning 200 simultaneous cold connections
249+
// into a warmup-only transport failure.
250+
warmUpFanout(isolated, clientCount);
251+
prepareFanoutBudgets(isolated, clientCount);
244252

245-
ExecutorService executor = Executors.newFixedThreadPool(FANOUT_CLIENTS);
253+
ExecutorService executor = Executors.newFixedThreadPool(clientCount);
246254
try {
247255
ConcurrentLinkedQueue<Long> timings = new ConcurrentLinkedQueue<>();
248256
AtomicInteger errorCount = new AtomicInteger();
249-
AtomicLongArray successesByClient = new AtomicLongArray(FANOUT_CLIENTS);
257+
AtomicLongArray successesByClient = new AtomicLongArray(clientCount);
250258
CountDownLatch startLatch = new CountDownLatch(1);
251259
AtomicBoolean running = new AtomicBoolean(true);
252260

253-
for (int client = 0; client < FANOUT_CLIENTS; client++) {
261+
for (int client = 0; client < clientCount; client++) {
254262
int clientIndex = client;
255263
executor.submit(() -> {
256264
try {
@@ -307,13 +315,13 @@ private void runConcurrentReserveFanout(boolean isolated) throws Exception {
307315
: 0.0;
308316
double opsPerSec = totalOps / (MEASURE_DURATION_MS / 1000.0);
309317
long ledgerMismatches = countFanoutLedgerMismatches(
310-
isolated, successesByClient, totalOps);
318+
isolated, successesByClient, totalOps, clientCount);
311319

312320
FanoutResult result;
313321
if (totalOps > 0) {
314322
result = new FanoutResult(
315323
isolated ? "isolated" : "shared",
316-
FANOUT_CLIENTS,
324+
clientCount,
317325
totalOps,
318326
opsPerSec,
319327
p(sorted, 50),
@@ -327,7 +335,7 @@ private void runConcurrentReserveFanout(boolean isolated) throws Exception {
327335
} else {
328336
result = new FanoutResult(
329337
isolated ? "isolated" : "shared",
330-
FANOUT_CLIENTS,
338+
clientCount,
331339
0, 0, 0, 0, 0, 0, 0,
332340
errors, errorRate, ledgerMismatches);
333341
}
@@ -346,27 +354,61 @@ private void runConcurrentReserveFanout(boolean isolated) throws Exception {
346354
result.errorRatePercent, result.ledgerMismatches);
347355

348356
assertThat(errorRate)
349-
.as("Reserve error rate for %s 200-client fan-out", result.shape)
357+
.as("Reserve error rate for %s %d-client fan-out",
358+
result.shape, result.clients)
350359
.isLessThan(MAX_ERROR_RATE_PERCENT);
351360
assertThat(totalOps)
352-
.as("Successful reserves for %s 200-client fan-out", result.shape)
361+
.as("Successful reserves for %s %d-client fan-out",
362+
result.shape, result.clients)
353363
.isGreaterThan(0);
354364
assertThat(ledgerMismatches)
355-
.as("Ledger mismatches for %s 200-client fan-out", result.shape)
365+
.as("Ledger mismatches for %s %d-client fan-out",
366+
result.shape, result.clients)
356367
.isZero();
357368
} finally {
358369
executor.shutdownNow();
359370
}
360371
}
361372

362-
private void prepareFanoutBudgets(boolean isolated) {
373+
private void warmUpFanout(boolean isolated, int clientCount)
374+
throws Exception {
375+
int warmupOps = Math.max(WARMUP_OPS, clientCount);
376+
int warmupConcurrency =
377+
Math.min(clientCount, MAX_WARMUP_CONCURRENCY);
378+
ExecutorService executor =
379+
Executors.newFixedThreadPool(warmupConcurrency);
380+
try {
381+
List<Future<ResponseEntity<Map>>> responses =
382+
new ArrayList<>(warmupOps);
383+
for (int operation = 0; operation < warmupOps; operation++) {
384+
int clientIndex = operation % clientCount;
385+
responses.add(executor.submit(() -> post(
386+
"/v1/reservations",
387+
API_KEY_SECRET_A,
388+
fanoutReservationBody(isolated, clientIndex))));
389+
}
390+
executor.shutdown();
391+
for (Future<ResponseEntity<Map>> future : responses) {
392+
ResponseEntity<Map> response = future.get(30, TimeUnit.SECONDS);
393+
assertThat(response.getStatusCode().value()).isEqualTo(200);
394+
assertThat(response.getBody()).containsKey("reservation_id");
395+
}
396+
assertThat(executor.awaitTermination(30, TimeUnit.SECONDS))
397+
.as("Fan-out warmup workers terminated")
398+
.isTrue();
399+
} finally {
400+
executor.shutdownNow();
401+
}
402+
}
403+
404+
private void prepareFanoutBudgets(boolean isolated, int clientCount) {
363405
try (var jedis = jedisPool.getResource()) {
364406
if (!isolated) {
365407
seedBudget(jedis, TENANT_A, "TOKENS", FANOUT_ALLOCATION);
366408
return;
367409
}
368410
jedis.del("budget:tenant:" + TENANT_A + ":TOKENS");
369-
for (int client = 0; client < FANOUT_CLIENTS; client++) {
411+
for (int client = 0; client < clientCount; client++) {
370412
seedScopeBudget(
371413
jedis,
372414
fanoutScope(client),
@@ -391,7 +433,8 @@ private Map<String, Object> fanoutReservationBody(boolean isolated, int client)
391433
private long countFanoutLedgerMismatches(
392434
boolean isolated,
393435
AtomicLongArray successesByClient,
394-
int totalOps) {
436+
int totalOps,
437+
int clientCount) {
395438
try (var jedis = jedisPool.getResource()) {
396439
if (!isolated) {
397440
long actual = Long.parseLong(jedis.hget(
@@ -400,7 +443,7 @@ private long countFanoutLedgerMismatches(
400443
return actual == totalOps * FANOUT_RESERVE_AMOUNT ? 0 : 1;
401444
}
402445
long mismatches = 0;
403-
for (int client = 0; client < FANOUT_CLIENTS; client++) {
446+
for (int client = 0; client < clientCount; client++) {
404447
long actual = Long.parseLong(jedis.hget(
405448
"budget:" + fanoutScope(client) + ":TOKENS",
406449
"reserved"));
@@ -422,6 +465,28 @@ private static String fanoutScope(int client) {
422465
return "tenant:" + TENANT_A + "/agent:" + fanoutAgent(client);
423466
}
424467

468+
private static List<Integer> configuredFanoutClientLevels() {
469+
String configured = System.getProperty("benchmark.fanout.clients");
470+
if (configured == null || configured.isBlank()) {
471+
return DEFAULT_FANOUT_CLIENT_LEVELS;
472+
}
473+
try {
474+
List<Integer> levels = Arrays.stream(configured.split(","))
475+
.map(String::trim)
476+
.map(Integer::parseInt)
477+
.toList();
478+
if (levels.isEmpty() || levels.stream().anyMatch(level -> level <= 0)) {
479+
throw new IllegalArgumentException(
480+
"benchmark.fanout.clients values must be positive");
481+
}
482+
return levels;
483+
} catch (NumberFormatException e) {
484+
throw new IllegalArgumentException(
485+
"benchmark.fanout.clients must be a comma-separated list of integers",
486+
e);
487+
}
488+
}
489+
425490
private static long p(long[] sorted, int percentile) {
426491
return sorted[percentileIndex(sorted.length, percentile)];
427492
}

0 commit comments

Comments
 (0)