Skip to content

Commit 964f81e

Browse files
committed
chore: bump to v0.1.25.12 and update six-doc matrix
Release bookkeeping for the sort_by + sort_dir feature per spec revision 2026-04-16: - cycles-protocol-service/pom.xml <revision> -> 0.1.25.12 (flows to all child modules via ${revision} parent inheritance). - docker-compose.prod.yml: cycles-server image pin -> 0.1.25.12. - docker-compose.full-stack.prod.yml: cycles-server image pin -> 0.1.25.12; admin and events pins unchanged (these drift on purpose to represent a last-known-good cross-service combo). Docs: - AUDIT.md: full entry at top chronologically, plus version added to the **Date:** header line. - CHANGELOG.md: Keep-a-Changelog entry covering Added / Wire format / Internal / Notes for upgraders. Derived from AUDIT, not from memory, per v0.1.25.10 accuracy gotcha. - OPERATIONS.md: new Reservation list sorting section flagging the O(N) full-SCAN behaviour of the sorted path and the metric to watch (http_server_requests_seconds p99 on the list endpoint). - BENCHMARKS.md: .11 and .12 added to the skipped-releases list in Release coverage with rationale (sort path is opt-in; legacy path byte-identical). - README.md (root): version examples bumped (jar filename + image tag) to 0.1.25.12. - cycles-protocol-service/README.md: sort_by / sort_dir rows added to the GET /v1/reservations query-parameter table with the cursor-reset note. Jar command still uses -*.jar wildcard. PROTOCOL_VERSION stays "0.1.25" — cycles-protocol info.version did not bump; the 2026-04-16 revision is tracked in the spec's embedded info.description CHANGELOG only.
1 parent bd31064 commit 964f81e

9 files changed

Lines changed: 156 additions & 6 deletions

File tree

AUDIT.md

Lines changed: 74 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-14 (automated performance regression detection — nightly trend + release gate, no version bump),
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),
4+
2026-04-14 (automated performance regression detection — nightly trend + release gate, no version bump),
45
2026-04-14 (nightly soak test — long-duration stability coverage, no version bump),
56
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),
67
2026-04-14 (v0.1.25.10 — custom Micrometer counters for reserve/commit/release/extend/expired/events + overdraft, plus Redis-disconnect resilience test; dormant emitExpiredEvent key-prefix bug fixed as a side effect),
@@ -17,6 +18,78 @@
1718

1819
---
1920

21+
### 2026-04-16 — v0.1.25.12: `sort_by` + `sort_dir` on GET /v1/reservations
22+
23+
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.
24+
25+
**Spec shape:**
26+
27+
- `sort_by` enum (7 values): `reservation_id`, `tenant`, `scope_path`, `status`, `reserved`, `created_at_ms`, `expires_at_ms`.
28+
- `sort_dir` enum: `asc`, `desc`. Defaults to `desc` when `sort_by` is provided. When both are omitted, legacy behaviour (Redis-SCAN arbitrary order) is preserved exactly — zero-risk to existing clients.
29+
- Invalid enum values → HTTP 400 `INVALID_REQUEST` with the bad token echoed in the message.
30+
- Cursors MUST bind to `(sort_by, sort_dir, filters)`; mismatched reuse → HTTP 400.
31+
32+
**Implementation shape — dual-path:**
33+
34+
Existing code uses Redis `SCAN` to page `reservation:res_*` keys. SCAN returns keys in arbitrary, cursor-coupled order, which is fundamentally incompatible with server-side sorting. The alternative — per-tenant ZSET indices per sort key — would have required new Lua scripts, dual-write paths on every reservation state transition, and a backfill migration. Disproportionate to runtime-plane scale (per-tenant N typically ≤ 10³; O(N) in-memory sort per sorted page is cheaper than index maintenance).
35+
36+
So the controller branches:
37+
38+
- **Legacy path** (no `sort_by` AND no `sort_dir` AND no sorted cursor) — unchanged `SCAN`/pipelined `HGETALL`/opaque-cursor loop. Byte-for-byte identical to v0.1.25.11.
39+
- **Sorted path** (either sort param OR a decoded sorted cursor present) — full `SCAN` pass with filter predicates applied in-stream, deterministic in-memory `Comparator` sort, opaque slice cursor.
40+
41+
**New types (`cycles-protocol-service-data/.../repository/support/`):**
42+
43+
- `ReservationComparators.of(sortBy, sortDir)` — per-key extractors + `.thenComparing(reservation_id ASC)` tiebreaker so pagination boundaries are unambiguous under ties. Null-safe via `Comparator.nullsLast`. Also exposes `extractSortValue(ReservationSummary, sortBy)` for cursor `lsv` encoding.
44+
- `FilterHasher.hash(t, i, st, ws, ap, wf, ag, ts)` — SHA-256 of canonical `k=v|k=v|...` over eight filter fields, first 16 hex chars (8-byte truncation). Not a security boundary — sole job is cheap detection of cross-tuple cursor reuse. Trades length for brevity in the base64url cursor payload.
45+
- `SortedListCursor` record `{v:int, sb, sd, fh, lsv, lrid}` — v=1, base64url-no-pad Jackson JSON. `decode(String)` returns `Optional.empty()` on null/blank/all-digit input, which routes old numeric cursors straight to the legacy SCAN path — backward-compat kept at the cursor-parsing boundary rather than the controller boundary.
46+
47+
**Repository change:**
48+
49+
`RedisReservationRepository.listReservations` signature extended with trailing `String sortBy, String sortDir` (10 → 12 args). The 10-arg overload was removed intentionally — keeping both caused Mockito stubs defined over the 10-arg form to fail to match 12-arg call-sites from the updated controller, which silently made unit tests pass with `null` responses. Single explicit signature surfaces the contract at the type level.
50+
51+
New private helpers on the repository:
52+
53+
- `listReservationsSorted(...)` — full SCAN pass (no early termination), pipelined `HGETALL`, status/scope/filter predicates applied as rows stream through, in-memory sort via the comparator, opaque slice cursor emitted when `idx + limit < total`.
54+
- `findSliceStart(rows, cursor)` — binary-search-like boundary finder over the sorted list; returns the first index strictly greater than `(lsv, lrid)` per the active comparator.
55+
- `compareAtBoundary(...)` — honours sort direction so boundary comparison matches emitted order (desc cursor page-forward goes to smaller `lsv`, not larger).
56+
57+
**Cross-tuple cursor rejection:**
58+
59+
On resume, the decoded cursor's `(sb, sd, fh)` is compared to the current request's derived tuple. Mismatch throws `CyclesProtocolException(INVALID_REQUEST, ..., 400)`. Prevents the class of bug where a client paginates by `created_at_ms asc`, changes to `reserved desc` on the UI, re-submits the old cursor, and silently gets a corrupt mid-stream slice.
60+
61+
**Controller validation (at `ReservationController.list`):**
62+
63+
Matches the existing `status` validation pattern at line 246 — uppercase the incoming parameter, parse against the enum, throw `CyclesProtocolException(INVALID_REQUEST, "sort_by must be one of ...", 400)` on failure. No Spring `@Valid` involved; intentional — error-body shape and message wording must stay under cycles-protocol error-envelope control, not Jackson's default.
64+
65+
**Tests (≥95 % coverage preserved):**
66+
67+
- `ReservationControllerTest` — 4 new: invalid `sort_by` → 400, invalid `sort_dir` → 400, both params propagated to repo (argument captor), all 7 spec enum values accepted. Pre-existing Mockito stubs updated from 10-arg to 12-arg (`, any(), any()`) to match the new repository signature.
68+
- `ReservationComparatorsTest` (new) — per-field asc/desc for all 7 sort keys, `reservation_id` tiebreaker under ties, null-subject-tenant safety under both directions, `extractSortValue` correctness.
69+
- `SortedListCursorTest` (new) — round-trip encode/decode, malformed base64url → empty Optional (legacy fallback), digit-only input → empty Optional, JSON with missing fields → empty Optional.
70+
- `FilterHasherTest` (new) — determinism, null-vs-empty-string equivalence (trailing empties collapse so clients can omit the trailing filters without forcing a new tuple), 16-hex-char output shape.
71+
- `RedisReservationQueryTest.SortedListReservationsTest` (new `@Nested`) — Testcontainers Redis seeded with 25 reservations across statuses and timestamps; paginates `sort_by=created_at_ms&sort_dir=asc&limit=10` across 3 pages with `has_more` transitions checked; cursor reused under different `sort_by` throws; legacy numeric cursor still works without sort params; scope_path lexicographic ordering verified.
72+
- Pre-existing `listReservations` call-sites (~12 across the Testcontainers file) updated from 10-arg to 12-arg.
73+
74+
**Release bookkeeping:**
75+
76+
- `cycles-protocol-service/pom.xml` `<revision>``0.1.25.12`.
77+
- Both prod docker-compose image pins bumped: `docker-compose.prod.yml`, `docker-compose.full-stack.prod.yml`.
78+
- Six-doc markdown matrix updated: AUDIT.md (this entry), CHANGELOG.md, OPERATIONS.md, BENCHMARKS.md, README.md × 2.
79+
- Benchmarks unaffected (sort path is off for existing workloads because clients haven't added the params yet); release can use the `[benchmark-skip]` marker in the GH release notes body. Documented in CHANGELOG.
80+
81+
**Verification:**
82+
83+
- `mvn -B test --file cycles-protocol-service/pom.xml -Dtest='!*IntegrationTest'` — 352 data-module tests + 344 API-module tests green.
84+
- Testcontainers Redis integration tests exercise the sort-path cursor round-trip.
85+
86+
**Out of scope (deferred):**
87+
88+
- Per-tenant ZSET indices by sort key — follow-up if any tenant exceeds ~10 k reservations and sorted-list latency crosses a service-level objective.
89+
- Admin spec `/v1/auth/introspect` (cycles-governance-admin-v0.1.25.yaml revisions `101416f` / `6aca3f9`) — not a runtime-plane concern.
90+
91+
---
92+
2093
### 2026-04-14 — Automated performance regression detection (no version bump)
2194

2295
Closes the last remaining gap in the v0.1.25.11 scorecard: **performance regression detection was 6/10** because BENCHMARKS.md tracked trends but nothing failed automatically on a regression. A 2× p99 slowdown could merge, tag, and ship through the release workflow with only human review to catch it. This change adds an automated gate.

BENCHMARKS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ running them would only measure environmental noise. Skipped releases:
2121
increments on success/failure paths; no Redis/Lua changes). The
2222
`ReservationExpiryService` prefix fix is off the request hot path
2323
(runs in the background sweep).
24+
- **v0.1.25.11** — test-only release (concurrent-retry / counter-
25+
accuracy regression tests).
26+
- **v0.1.25.12**`sort_by` + `sort_dir` on `GET /v1/reservations`
27+
list endpoint. The sorted path is opt-in (clients must pass the
28+
new params to activate it); legacy list behaviour is byte-for-byte
29+
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.
2432

2533
Last benchmarked release: **v0.1.25.7**.
2634

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,45 @@ 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.12] — 2026-04-16
18+
19+
### Added
20+
21+
- `GET /v1/reservations` now accepts `sort_by` and `sort_dir` query
22+
parameters (cycles-protocol spec revision 2026-04-16). Valid
23+
`sort_by` values: `reservation_id`, `tenant`, `scope_path`,
24+
`status`, `reserved`, `created_at_ms`, `expires_at_ms`. Valid
25+
`sort_dir` values: `asc`, `desc` (default `desc` when `sort_by`
26+
is provided). Invalid enum values return HTTP 400
27+
`INVALID_REQUEST`.
28+
- Server-side ordering with deterministic `reservation_id ASC`
29+
tiebreaker so pagination is unambiguous under ties.
30+
- Opaque sorted cursor (base64url-no-pad JSON, `{v,sb,sd,fh,lsv,lrid}`)
31+
that binds to the `(sort_by, sort_dir, filters)` tuple via an
32+
8-byte SHA-256 filter hash. Reusing a cursor under a different
33+
tuple returns HTTP 400.
34+
35+
### Wire format
36+
37+
Backward compatible. Omitting `sort_by`/`sort_dir` preserves the
38+
existing Redis-SCAN cursor semantics; legacy all-digit cursors
39+
continue to work unchanged.
40+
41+
### Internal
42+
43+
- New `support.SortedListCursor`, `support.FilterHasher`,
44+
`support.ReservationComparators` utilities.
45+
- `RedisReservationRepository.listReservations` signature extended
46+
with trailing `sortBy`, `sortDir` parameters (10 → 12 args).
47+
Direct callers inside the service have been updated; external
48+
callers (if any) must add two trailing `null`s.
49+
50+
### Notes for upgraders
51+
52+
No action required for clients that don't use the new parameters.
53+
Ops teams monitoring sorted-list query latency should watch for
54+
the documented O(N) full-SCAN behaviour — see OPERATIONS.md.
55+
1756
## [0.1.25.11] — 2026-04-14
1857

1958
### Added

OPERATIONS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,28 @@ don't fit.
366366
| `admin.api-key` | (empty) | Set to a fixed-length secret to enable the admin-on-behalf-of endpoint (v0.1.25.8+). Leave empty to disable. |
367367
| `management.endpoints.web.exposure.include` | `health,info,prometheus` | Add more actuator endpoints if you need them, but `prometheus` is the one ops cares about. |
368368

369+
## Reservation list sorting (v0.1.25.12+)
370+
371+
`GET /v1/reservations` accepts `sort_by` (one of `reservation_id`,
372+
`tenant`, `scope_path`, `status`, `reserved`, `created_at_ms`,
373+
`expires_at_ms`) and `sort_dir` (`asc` or `desc`, default `desc`).
374+
375+
Implementation: full-SCAN + in-memory sort per sorted page. This is
376+
**O(N)** in reservations matching the filter — fine at the current
377+
runtime-plane target of ≤ 10³ reservations per tenant. Watch the
378+
Spring Boot `http_server_requests_seconds{uri="/v1/reservations"}`
379+
p99: if it climbs above 500 ms under real load, a tenant has grown
380+
past the in-memory threshold and per-tenant ZSET indexing becomes
381+
worthwhile. Track the top `tenant` tag on that metric to spot who.
382+
383+
Legacy clients (no sort params) stay on the existing Redis-SCAN
384+
cursor path and are unaffected by this concern.
385+
386+
Cursors encode the `(sort_by, sort_dir, filters)` tuple — reusing
387+
a cursor with a different sort or filter set returns HTTP 400
388+
`INVALID_REQUEST`. This is intentional; front-end code that mutates
389+
the sort/filter on a page change must reset to the first page.
390+
369391
## Getting help
370392

371393
- Bug reports / feature requests:

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.11`).
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`).
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.11
139+
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.12
140140
```
141141

142142
## Testing

cycles-protocol-service/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@ List reservations for the effective tenant. Optional recovery/debug endpoint. Re
456456
| `workspace` / `app` / `workflow` / `agent` / `toolset` | Subject field filters |
457457
| `limit` | Max results per page (default `50`, max `200`) |
458458
| `cursor` | Opaque pagination cursor from previous response |
459+
| `sort_by` | One of `reservation_id`, `tenant`, `scope_path`, `status`, `reserved`, `created_at_ms`, `expires_at_ms` (v0.1.25.12+). Omit for legacy unordered behaviour. |
460+
| `sort_dir` | `asc` or `desc`. Defaults to `desc` when `sort_by` is provided. Ignored unless `sort_by` is set. |
461+
462+
When `sort_by` or `sort_dir` is provided, the cursor encodes the
463+
`(sort_by, sort_dir, filters)` tuple — reusing a cursor after
464+
changing the sort key, direction, or any filter returns `400
465+
INVALID_REQUEST`. When both are omitted, the legacy Redis-SCAN
466+
cursor path is used and existing clients are unaffected.
459467

460468
**Response** `200 OK`
461469
```json

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.11</revision>
21+
<revision>0.1.25.12</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
@@ -14,7 +14,7 @@ services:
1414
retries: 5
1515

1616
cycles-server:
17-
image: ghcr.io/runcycles/cycles-server:0.1.25.11
17+
image: ghcr.io/runcycles/cycles-server:0.1.25.12
1818
restart: unless-stopped
1919
ports:
2020
- "7878:7878"

docker-compose.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ services:
1414
retries: 5
1515

1616
cycles-server:
17-
image: ghcr.io/runcycles/cycles-server:0.1.25.11
17+
image: ghcr.io/runcycles/cycles-server:0.1.25.12
1818
restart: unless-stopped
1919
ports:
2020
- "7878:7878"

0 commit comments

Comments
 (0)