Skip to content

Commit c5ffb08

Browse files
authored
Merge pull request #106 from runcycles/fix/reservations-sort-hydration-cap-v0.1.25.13
fix(reservations): hydration cap + enum wire annotations (v0.1.25.13)
2 parents d5d67a9 + 9a06704 commit c5ffb08

11 files changed

Lines changed: 299 additions & 13 deletions

File tree

AUDIT.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Cycles Protocol v0.1.25 — Server Implementation Audit
22

3-
**Date:** 2026-04-16 (v0.1.25.12 — `sort_by` + `sort_dir` on `GET /v1/reservations` per cycles-protocol spec revision 2026-04-16; 7-value sort enum, opaque cursor binds `(sort_by, sort_dir, filters)` tuple, legacy SCAN-cursor path preserved when both params omitted),
3+
**Date:** 2026-04-16 (v0.1.25.13 — hydration cap + enum wire annotations on the sorted `GET /v1/reservations` path; `SORTED_HYDRATE_CAP=2000` guard on the in-memory sort hydration with WARN-on-cap, matches admin plane's v0.1.25.24 pattern; `@JsonValue`/`@JsonCreator fromWire` on `ReservationSortBy` + `SortDirection` to mirror admin's `SortSpec`/`SortDirection` contract),
4+
2026-04-16 (v0.1.25.12 — `sort_by` + `sort_dir` on `GET /v1/reservations` per cycles-protocol spec revision 2026-04-16; 7-value sort enum, opaque cursor binds `(sort_by, sort_dir, filters)` tuple, legacy SCAN-cursor path preserved when both params omitted),
45
2026-04-14 (automated performance regression detection — nightly trend + release gate, no version bump),
56
2026-04-14 (nightly soak test — long-duration stability coverage, no version bump),
67
2026-04-14 (v0.1.25.11 — concurrent retry-storm test for idempotency cache expiry + concurrent accuracy test for custom counters; closes two gaps flagged in the v0.1.25.10 review),
@@ -18,6 +19,29 @@
1819

1920
---
2021

22+
### 2026-04-16 — v0.1.25.13: hydration cap + enum wire annotations on the sorted list path
23+
24+
Closes two follow-up gaps surfaced by the three-step review of v0.1.25.12 against the admin-plane implementation of the same feature in `cycles-server-admin` v0.1.25.24:
25+
26+
**P1 — `listReservationsSorted` hydrates an unbounded population before the in-memory sort.** The sorted path in v0.1.25.12 does a full `SCAN` of `reservation:res_*`, hydrates every matching row into a `List<ReservationSummary>`, then sorts + slices. For a tenant with 2M reservations under a single workspace, that's 2M `HGETALL` round-trips and a 2M-entry heap object before the cursor walk even starts. The admin plane ran into exactly the same shape on `ApiKeyRepository.listAllTenantsSorted` and `BudgetRepository.listAllTenantsSorted` and closed it with a `SORTED_HYDRATE_CAP = 2000` constant: when the per-key hydration loop observes `matching.size() >= cap` it sets `capped = true`, breaks out of the SCAN loop, logs a WARN naming the tenant + sort tuple, and the sort/slice/cursor code then operates on the capped slice as normal. Page still fills, `has_more` + `next_cursor` still populate from the capped slice, so the UI isn't blocked — the cap is a heap-safety bound, not a correctness bound.
27+
28+
This release ports the same constant + `scanLoop:` labeled-break + post-loop WARN to `RedisReservationRepository.listReservations` on the sorted path only. The legacy (no-sort-params, no-decoded-sorted-cursor) path is intentionally uncapped because it streams page-by-page via the SCAN cursor and never builds an unbounded in-memory list.
29+
30+
**Why 2000:** Same rationale as admin — covers 99%+ of production tenants, keeps the heap bound predictable at roughly 2000 × ~2 KB ReservationSummary = ~4 MB per concurrent sorted-list call. Operators who outgrow the cap should either (a) narrow filters (`status`, `idempotency_key`, scope segments `workspace`/`app`/`workflow`/`agent`/`toolset`), or (b) revisit the deferred per-key ZSET index ADR at `docs/deferred-optimizations/sorted-list-zset-indices.md`.
31+
32+
**P2 — `ReservationSortBy` and `SortDirection` lacked Jackson wire annotations.** In v0.1.25.12 the enums were plain Java enums; the controller did manual `String.toUpperCase()``Enum.valueOf()` conversion with a try/catch → 400. That works, but it diverges from the admin plane where `SortSpec` and `SortDirection` carry `@JsonValue getWire()` + `@JsonCreator fromWire(String)` so Jackson handles the lowercase-on-wire contract natively and controllers delegate to `fromWire(...)` with the same try/catch → 400 shape. This release brings the two runtime-plane enums into line: lowercase `getWire()`, `null → null` on `fromWire(null)`, `IllegalArgumentException` on unknown tokens (which the controller converts to 400 with the documented allow-list payload). The wire contract is unchanged — lowercase tokens in and out — but direct JSON-over-wire uses of these enums (future list-response DTOs that echo the sort tuple back, for instance) now serialize/deserialize correctly without custom converters.
33+
34+
**Test strategy:**
35+
36+
- `RedisReservationQueryTest#sortedHydrationStopsAtCap` — mocks a SCAN page returning `cap + 10` keys, stubs pipeline `hgetAll` for each, invokes the 5-row sorted page, asserts exactly 5 rows in the documented ascending-`created_at_ms` order and `has_more=true`, `next_cursor != null`. Exercises the capped-slice cursor path. (The full hydration loop consults only up to `cap` rows; remaining stubs are unused as designed.)
37+
- `EnumsTest` (new, `cycles-protocol-service-model`) — round-trip tests for both enums: `getWire()` emits lowercase, `fromWire` accepts canonical lowercase + uppercase + mixed case, `fromWire(null)` returns null, `fromWire("bogus")` throws `IllegalArgumentException`, `for each value: fromWire(getWire(v)) == v`.
38+
39+
No spec change; wire format is identical. No signature changes. No caller-visible behaviour change for tenants under the cap.
40+
41+
**Backward compatibility:** Full. Behaviour-visible for tenants whose sorted-list query previously returned >2000 matching rows (silently; those were already on the O(N) full-SCAN path documented in v0.1.25.12). Post-cap, rows beyond row 2000 in the capped slice are unreachable without narrowing filters — same contract the admin plane established in v0.1.25.24 for `ApiKey` + `Budget` cross-tenant sorted paths.
42+
43+
---
44+
2145
### 2026-04-16 — v0.1.25.12: `sort_by` + `sort_dir` on GET /v1/reservations
2246

2347
Closes the runtime-protocol gap opened by **cycles-protocol spec revision 2026-04-16** (commits `064e95f` + `a2a8f13`): list-reservations needed server-side ordering with client-selectable sort key + direction, and the cursor needed to encode the sort state so page breaks remain consistent.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@ 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.13] — 2026-04-16
18+
19+
### Fixed
20+
21+
- `GET /v1/reservations` sorted path no longer hydrates an unbounded
22+
reservation population before the in-memory sort. A
23+
`SORTED_HYDRATE_CAP = 2000` guard mirrors the admin-plane pattern
24+
shipped in `cycles-server-admin` v0.1.25.24: once the cap is hit
25+
the SCAN loop breaks, a WARN is logged so operators can see the
26+
window was truncated, and the cursor page still fills from the
27+
capped slice. Callers that need to see past the cap should narrow
28+
filters (`status`, `idempotency_key`, `workspace`/`app`/
29+
`workflow`/`agent`/`toolset`) — same workaround doc pattern as
30+
admin.
31+
32+
### Internal
33+
34+
- `Enums.ReservationSortBy` and `Enums.SortDirection` now carry
35+
`@JsonValue getWire()` + `@JsonCreator fromWire(String)` Jackson
36+
annotations matching the admin plane's `SortSpec` /
37+
`SortDirection` pattern. Wire form stays lowercase, parsing stays
38+
case-insensitive with `null → null`. Controller-level validation
39+
is unchanged: unknown tokens still surface as HTTP 400
40+
`INVALID_REQUEST` with the documented allow-list payload.
41+
- `RedisReservationRepository.SORTED_HYDRATE_CAP` is package-private
42+
(test-visible) to allow cap-hit tests to assert the bound
43+
deterministically.
44+
45+
### Notes for upgraders
46+
47+
Behavior-visible for callers that previously relied on the sorted
48+
path silently returning all rows even for a single tenant with
49+
thousands of reservations: the page shape is identical, but rows
50+
beyond row 2000 in the capped slice are now unreachable without
51+
narrowing filters. Same trade-off doc as the admin plane cap.
52+
1753
## [0.1.25.12] — 2026-04-16
1854

1955
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ mvn clean install
117117
./build-all.sh
118118
```
119119

120-
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-<version>.jar` (where `<version>` is the `revision` property in `cycles-protocol-service/pom.xml` — e.g. `0.1.25.12`).
120+
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-<version>.jar` (where `<version>` is the `revision` property in `cycles-protocol-service/pom.xml` — e.g. `0.1.25.13`).
121121

122122
## Docker Deployment
123123

@@ -136,7 +136,7 @@ Pre-built images are published to GitHub Container Registry on each release:
136136

137137
```
138138
ghcr.io/runcycles/cycles-server:latest
139-
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.12
139+
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.13
140140
```
141141

142142
## Testing

cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/ReservationController.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,19 +256,21 @@ public ResponseEntity<ReservationListResponse> list(
256256
// v0.1.25.12 (cycles-protocol revision 2026-04-16): validate
257257
// sort_by / sort_dir at the controller boundary so clients get
258258
// a clean 400 INVALID_REQUEST on typos before the repo runs.
259-
// Enum values on the wire are lowercase (sort_by=created_at_ms);
260-
// uppercase them to match the Java enum constant.
259+
// v0.1.25.13: delegate parsing to Enums.*.fromWire (adds @JsonValue /
260+
// @JsonCreator round-trip), which is case-insensitive and throws
261+
// IllegalArgumentException on unknown values — caught and mapped to
262+
// the same 400 payload as before.
261263
if (sortBy != null) {
262264
try {
263-
Enums.ReservationSortBy.valueOf(sortBy.toUpperCase());
265+
Enums.ReservationSortBy.fromWire(sortBy);
264266
} catch (IllegalArgumentException e) {
265267
throw new CyclesProtocolException(Enums.ErrorCode.INVALID_REQUEST,
266268
"Invalid sort_by: " + sortBy + ". Must be one of: reservation_id, tenant, scope_path, status, reserved, created_at_ms, expires_at_ms", 400);
267269
}
268270
}
269271
if (sortDir != null) {
270272
try {
271-
Enums.SortDirection.valueOf(sortDir.toUpperCase());
273+
Enums.SortDirection.fromWire(sortDir);
272274
} catch (IllegalArgumentException e) {
273275
throw new CyclesProtocolException(Enums.ErrorCode.INVALID_REQUEST,
274276
"Invalid sort_dir: " + sortDir + ". Must be one of: asc, desc", 400);

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/RedisReservationRepository.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ public class RedisReservationRepository {
4747
.map(Enums.UnitEnum::name)
4848
.collect(Collectors.joining(","));
4949

50+
/**
51+
* v0.1.25.13 hydration cap for the sorted listReservations path. The sorted
52+
* path must full-SCAN every `reservation:res_*` key across all tenants
53+
* before the in-memory sort, which is unbounded in the naive case. At
54+
* runtime scale (10k reservations per active tenant) this can exhaust
55+
* heap before the cursor even runs. Cap at 2000 rows per request; when
56+
* the cap is hit we break out of hydration, sort the capped slice, and
57+
* serve the cursor from that slice. Operators that need to see beyond
58+
* the cap should narrow filters (status, scope segments, tenant) — the
59+
* deferred-optimization ADR (docs/deferred-optimizations/
60+
* sorted-list-zset-indices.md) tracks the longer-term per-tenant ZSET
61+
* index work that will retire this cap.
62+
*/
63+
static final int SORTED_HYDRATE_CAP = 2000;
64+
5065
private static final ThreadLocal<MessageDigest> SHA256_DIGEST = ThreadLocal.withInitial(() -> {
5166
try {
5267
return MessageDigest.getInstance("SHA-256");
@@ -660,6 +675,8 @@ private ReservationListResponse listReservationsSorted(
660675
String toolsetSegment = toolset != null ? "toolset:" + toolset.toLowerCase() : null;
661676

662677
String scanCursor = "0";
678+
boolean capped = false;
679+
scanLoop:
663680
do {
664681
ScanResult<String> scan = jedis.scan(scanCursor, params);
665682
List<String> keys = scan.getResult();
@@ -672,6 +689,7 @@ private ReservationListResponse listReservationsSorted(
672689
pipeline.sync();
673690

674691
for (String key : keys) {
692+
if (matching.size() >= SORTED_HYDRATE_CAP) { capped = true; break scanLoop; }
675693
try {
676694
Map<String, String> fields = responses.get(key).get();
677695
if (fields.isEmpty()) continue;
@@ -694,6 +712,11 @@ private ReservationListResponse listReservationsSorted(
694712
scanCursor = scan.getCursor();
695713
} while (!"0".equals(scanCursor));
696714

715+
if (capped) {
716+
LOG.warn("listReservationsSorted hydration capped at {} rows for tenant={} sort_by={} sort_dir={}; narrow filters to see beyond the cap",
717+
SORTED_HYDRATE_CAP, tenant, effectiveSortBy, effectiveSortDir);
718+
}
719+
697720
// Full in-memory sort. Acceptable at runtime-plane scale; metric below lets us
698721
// spot tenants that outgrow this path and need per-key ZSET indices.
699722
matching.sort(ReservationComparators.of(effectiveSortBy, effectiveSortDir));

cycles-protocol-service/cycles-protocol-service-data/src/test/java/io/runcycles/protocol/data/repository/RedisReservationQueryTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,60 @@ void cursorMismatchRejected() {
836836
.hasMessageContaining("cursor is not valid");
837837
}
838838

839+
@SuppressWarnings("unchecked")
840+
@Test
841+
@DisplayName("hydration stops at SORTED_HYDRATE_CAP; page still fills from capped slice")
842+
void sortedHydrationStopsAtCap() {
843+
when(jedisPool.getResource()).thenReturn(jedis);
844+
doNothing().when(jedis).close();
845+
846+
int cap = RedisReservationRepository.SORTED_HYDRATE_CAP;
847+
int total = cap + 10;
848+
849+
// Return ALL keys from a single SCAN page. The hydration guard sits
850+
// inside the per-key loop, so the break exits after exactly `cap`
851+
// rows are added — remaining keys are never consulted even though
852+
// they're still in the scan result.
853+
List<String> keys = new ArrayList<>(total);
854+
for (int i = 0; i < total; i++) {
855+
keys.add(String.format("reservation:res_r%05d", i));
856+
}
857+
858+
ScanResult<String> scanResult = mock(ScanResult.class);
859+
when(scanResult.getResult()).thenReturn(keys);
860+
// getCursor() is never read once the hydration cap breaks out of the
861+
// labeled scanLoop; stub leniently so strict-mode doesn't flag it.
862+
lenient().when(scanResult.getCursor()).thenReturn("0");
863+
when(jedis.scan(eq("0"), any(ScanParams.class))).thenReturn(scanResult);
864+
when(jedis.pipelined()).thenReturn(pipeline);
865+
866+
// Default pipeline.hgetAll stub from Base returns empty map; override
867+
// for the first `cap` keys so they pass the tenant filter and land
868+
// in the matching list. Keys beyond index `cap` are stubbed too, so
869+
// if the guard is ever removed the test fails loud (page fills past
870+
// the cap and the pipeline verifier trips).
871+
for (int i = 0; i < total; i++) {
872+
String id = String.format("r%05d", i);
873+
Map<String, String> f = reservationFields(id, "ACTIVE");
874+
f.put("created_at", String.valueOf(1_700_000_000_000L + i));
875+
Response<Map<String, String>> resp = mock(Response.class);
876+
lenient().when(resp.get()).thenReturn(f);
877+
lenient().when(pipeline.hgetAll("reservation:res_" + id)).thenReturn(resp);
878+
}
879+
880+
ReservationListResponse response = repository.listReservations(
881+
"acme", null, null, null, null, null, null, null, 5, null,
882+
"created_at_ms", "asc");
883+
884+
assertThat(response.getReservations()).hasSize(5);
885+
assertThat(response.getReservations())
886+
.extracting(ReservationSummary::getReservationId)
887+
.containsExactly("r00000", "r00001", "r00002", "r00003", "r00004");
888+
// has_more must be true — the capped slice still has rows beyond the page.
889+
assertThat(response.getHasMore()).isTrue();
890+
assertThat(response.getNextCursor()).isNotNull();
891+
}
892+
839893
@SuppressWarnings("unchecked")
840894
@Test
841895
@DisplayName("legacy numeric cursor with no sort params routes to legacy SCAN path")

cycles-protocol-service/cycles-protocol-service-model/src/main/java/io/runcycles/protocol/model/Enums.java

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package io.runcycles.protocol.model;
22

3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
36
/**
47
* Cycles Protocol v0.1.25
58
* All enums in one file for convenience
@@ -76,6 +79,13 @@ public enum ErrorCode {
7679
* Sort keys accepted by GET /v1/reservations.
7780
* Spec: cycles-protocol-v0.yaml revision 2026-04-16.
7881
* Default when sort_by is provided but unset on the wire: CREATED_AT_MS.
82+
*
83+
* Wire form is lowercase (reservation_id, created_at_ms, …). @JsonValue
84+
* emits the wire form on serialization; @JsonCreator fromWire parses
85+
* case-insensitively and returns null on null input so callers that
86+
* want 400-on-unknown can convert IllegalArgumentException into a
87+
* CyclesProtocolException themselves, matching the admin SortDirection
88+
* pattern.
7989
*/
8090
public enum ReservationSortBy {
8191
RESERVATION_ID,
@@ -84,11 +94,37 @@ public enum ReservationSortBy {
8494
STATUS,
8595
RESERVED,
8696
CREATED_AT_MS,
87-
EXPIRES_AT_MS
97+
EXPIRES_AT_MS;
98+
99+
@JsonValue
100+
public String getWire() {
101+
return name().toLowerCase();
102+
}
103+
104+
@JsonCreator
105+
public static ReservationSortBy fromWire(String value) {
106+
if (value == null) return null;
107+
return ReservationSortBy.valueOf(value.toUpperCase());
108+
}
88109
}
89110

90-
/** Sort direction for list endpoints. Default DESC. */
111+
/**
112+
* Sort direction for list endpoints. Default DESC. Wire form is
113+
* lowercase ("asc" / "desc"). See ReservationSortBy for the Jackson
114+
* round-trip contract — same pattern.
115+
*/
91116
public enum SortDirection {
92-
ASC, DESC
117+
ASC, DESC;
118+
119+
@JsonValue
120+
public String getWire() {
121+
return name().toLowerCase();
122+
}
123+
124+
@JsonCreator
125+
public static SortDirection fromWire(String value) {
126+
if (value == null) return null;
127+
return SortDirection.valueOf(value.toUpperCase());
128+
}
93129
}
94130
}

0 commit comments

Comments
 (0)