feat(listReservations): add from/to time-window filter (v0.1.25.19, closes #159)#160
Merged
Merged
Conversation
Collaborator
Author
Code reviewFound 2 issues:
Also considered but filtered out (real but lower-severity): legacy-path cursor doesn't invalidate on filter change — pre-existing across all filters (status/workspace/etc), the PR follows the same pattern; stale 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
4 tasks
Closes #159. Implements cycles-protocol-v0.yaml revision 2026-05-21 (runcycles/cycles-protocol#97). Two new optional query parameters on GET /v1/reservations: * from: ISO 8601 date-time. Inclusive lower bound on created_at_ms. * to: ISO 8601 date-time. Inclusive upper bound on created_at_ms. Either may be supplied alone (open interval) or together (closed window). The filter always binds to created_at_ms regardless of sort_by, so sort-by-expires_at_ms + window-by-created_at_ms is well-defined. Implementation: * Controller: parses ISO-8601 via Instant.parse; malformed → 400 INVALID_REQUEST; from > to → 400 before any repository call; blank-string values treated as unset. * Repository: window predicate applied in both legacy SCAN-cursor and sorted paths. Predicate body shared via createdAtInWindow helper. Missing/unparseable created_at rows defensively excluded when either bound is supplied. * FilterHasher: fromMs/toMs folded into canonical hash so cursors invalidate on window change (same contract as v0.1.25.12 sort_by/sort_dir/filter-mismatch). * Java signature change: listReservations gains trailing Long fromMs, Long toMs (12 → 14 args). No wire change for clients that omit the new params. Coverage: * FilterHasherTest: 2 new cases (from/to distinct hash, positional). * RedisReservationQueryTest: 7 new cases under TimeWindowFilter nested class. * ReservationControllerTest: 7 new cases under ListReservations nested class (malformed-from/to, reversed-window, from-only, to-only, equal-bounds, sort-key independence, blank-as-unset). * 537 tests pass (374 data + 163 api). Docs: * AUDIT.md: new dated entry walks through naming, sort-binding, cursor invalidation, validation choices, defensive read-side, coverage, and out-of-scope notes (expires_at follow-up). * CHANGELOG.md: [0.1.25.19] entry under Keep-a-Changelog format. * pom.xml revision bumped 0.1.25.18 → 0.1.25.19.
Two real bugs found during the post-push code-review pass against PR #160: 1. FilterHasher.hash unconditionally appended `|fr=...|to=...` to the canonical string, even when both bounds were null. This changed the SHA-256 hash for every pre-window cursor, including any v0.1.25.18 sorted-path cursor mid-pagination across the deployment boundary — the server would reject those cursors with 400 INVALID_REQUEST despite the client never sending the new params. That falsified the PR's stated "byte-for-byte" back-compat claim for the sorted path. Fix: gate the fr=/to= emission behind `fromMs != null || toMs != null`. When both bounds are null the canonical form reverts byte-exactly to the v0.1.25.12 8-field shape, so any pre-window cursor continues to resolve. When either bound is supplied, the new fields render in the canonical string as before, so cursors still invalidate on window change. Adds an explicit regression test `preservesPreWindowHashWhenBothBoundsNull` that locks the truncated 8-byte SHA-256 to its v0.1.25.18 golden value (2f397ea0e8fb53b7). 2. Three new ReservationControllerTest cases (shouldPropagateFromOnly / shouldPropagateToOnly / shouldAcceptEqualBounds) hard-coded 1779710400000L as the epoch-ms for "2026-05-21T12:00:00Z", but the correct value is 1779364800000L (the literal was off by 4 days — it's actually 2026-05-25T12:00:00Z). Mockito's eq(...) never matched the controller's computed value, the stub silently returned null, ResponseEntity.ok(null) still produced HTTP 200, and the tests passed vacuously without asserting anything about epoch-ms propagation. Fix: drop the literal entirely. Derive the expected ms from the same parser the controller uses (java.time.Instant.parse(...).toEpochMilli()). Each test also gains a verify(repository).listReservations(... eq(...) ...) assertion so a future Mockito miss raises a wanted-but-not-invoked failure instead of slipping through as a vacuous 200. No production behavior change beyond restoring the back-compat invariant for v0.1.25.18 sorted-path cursors. All 538 protocol-service tests pass (375 data + 163 api). JaCoCo 95% bundle gate met. Also folds in the .19 → .20 revision bump from the rebase onto main post-#161 (Tomcat CVE hygiene), so the PR's cumulative version is now v0.1.25.20.
b3d3d26 to
4b122f7
Compare
Two P3 follow-up findings from the human review pass: 1. cycles-protocol-service/README.md GET /v1/reservations parameter table omitted the new from / to query params. Added rows for both, plus a clarifying note that legacy SCAN cursors do not carry filter state (clients paginating with from / to but no sort_by must keep the window stable across pages — same convention the legacy path already enforces for status / workspace / app / etc). 2. CHANGELOG.md and AUDIT.md previously claimed "a cursor issued under one (from, to) returns 400 if re-used under a different window" without qualification, but the FilterHasher-driven check only applies to the sorted-path cursor (the SortedListCursor.fh field embedded in the opaque cursor returned when sort_by / sort_dir are supplied). Legacy SCAN cursors have no embedded filter state and are not window-validated. Qualified both docs to "sorted-path cursor invalidation" with an explicit legacy-path caveat so the contract reads accurately on first pass. Also reconciled a stale AUDIT.md line that said "null values render as empty in the canonical form" — the fix-up in 4b122f7 changed the canonical form to omit the |fr=|to= appendix entirely when both bounds are null, so the v0.1.25.18 8-field shape is preserved byte-exactly. Reworded to reflect the gated-emission behavior and cite the FilterHasherTest.preservesPreWindowHashWhenBothBoundsNull regression test as the lockdown. Bumped the in-doc test counts from 537 → 538 (374 data + 163 api → 375 data + 163 api) to reflect the new regression test added in the fix-up commit. The v0.1.25.19 Tomcat-hygiene entry's count is unchanged (correct at the time of that PR). No code or test changes — pure documentation. No version bump (continues v0.1.25.20).
The previous doc fixup (6082ba8) qualified the detailed "Sorted-path cursor invalidation" section but left the parallel claim in the top-of-file Date summary at AUDIT.md:3 unqualified. The summary still read "cursors invalidate on window change" without distinguishing sorted-path from legacy SCAN. Reviewer correctly flagged this as a trailing inconsistency. Updated to "sorted-path cursors invalidate on window change (the legacy Redis-SCAN cursor is not window-validated, matching how it already treats every other filter)." Wording now matches the detailed section and the README. No code or test changes. Pure summary-line correction. Still v0.1.25.20.
This was referenced May 21, 2026
Merged
amavashev
added a commit
to runcycles/cycles-client-typescript
that referenced
this pull request
May 21, 2026
…0.3.2) (#103) Client-side companion to cycles-protocol-v0.yaml revision 2026-05-21 (runcycles/cycles-protocol#97) and runcycles/cycles-server#160. Closes the TypeScript-client side of issue #159. The existing `listReservations(params?: Record<string, string>)` signature already forwards arbitrary keys to the URL query string, so the new `from` and `to` ISO-8601 date-time params work over the wire today without a code change. This commit adds a regression test that pins the contract: URL-encoded form "from=2026-05-21T00%3A00%3A00Z&to=2026-05-22T00%3A00%3A00Z" must land in fetch's call. Future tightening of the Record signature cannot silently drop the new params. Unlike Python, TypeScript has no reserved-keyword issue — callers can write `client.listReservations({ from: "...", to: "..." })` directly. No protocol or wire-format change; servers older than v0.1.25.20 silently ignore the new params per the additive-parameter guarantee in cycles-protocol-v0.yaml. 316 tests pass at 98.4% statement coverage / 99.62% line coverage (gate ≥95%). Bumped to 0.3.2, updated AUDIT.md and CHANGELOG.md.
amavashev
added a commit
to runcycles/cycles-client-rust
that referenced
this pull request
May 21, 2026
Client-side companion to cycles-protocol-v0.yaml revision 2026-05-21 (runcycles/cycles-protocol#97) and runcycles/cycles-server#160. Closes the Rust-client side of issue #159. `ListReservationsParams` is strongly-typed (unlike the loose **kwargs / Record<string, string> shapes in the Python and TS clients), so this PR adds two new `Option<String>` fields with `#[serde(rename = ...)]` to project to the spec-mandated query-string names: /// Inclusive lower bound on `created_at_ms`. ISO 8601 date-time. #[serde(rename = "from", skip_serializing_if = "Option::is_none")] pub from: Option<String>, /// Inclusive upper bound on `created_at_ms`. ISO 8601 date-time. #[serde(rename = "to", skip_serializing_if = "Option::is_none")] pub to: Option<String>, Pure additive struct change. Callers using `ListReservationsParams::default()` or `..Default::default()` continue to compile and behave identically; new fields default to `None` and are skipped during serialization. Adds `list_reservations_forwards_from_to_window` in tests/client_test.rs that uses wiremock's `query_param` matcher to assert the new fields land on the wire under the spec-mandated names ("from" / "to", not the Rust struct field names — though they happen to match here). Pre-existing drift on `ListReservationsParams` (missing `workspace` / `workflow` / `toolset` subject filters, missing `sort_by` / `sort_dir` from the v0.1.25.12 spec revision, missing `idempotency_key`) is NOT addressed in this PR — kept scope tight. Worth a follow-up to bring the struct to full v0.1.25 spec parity, but separate from the #159 chain. No protocol or wire-format change; servers older than v0.1.25.20 silently ignore the new params per the additive-parameter guarantee in cycles-protocol-v0.yaml. 134 tests pass, clippy + doc-tests clean. Bumped to 0.2.5, updated AUDIT.md and CHANGELOG.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #159. Implements
cycles-protocol-v0.yamlrevision 2026-05-21 (runcycles/cycles-protocol#97, merged 2026-05-21).Summary
Two new optional query parameters on
GET /v1/reservations:from: ISO 8601 date-time, inclusive lower bound oncreated_at_ms.to: ISO 8601 date-time, inclusive upper bound oncreated_at_ms.Either may be supplied alone (open interval) or together (closed window). The filter always binds to
created_at_msregardless ofsort_by, sosort_by=expires_at_ms&from=…&to=…returns reservations created in the window, ordered by expiry. Pure additive wire change — clients that don't send the new params get the v0.1.25.18 response byte-for-byte.Why
The old "fetch last 24h of reservations" workflow required clients to sort by
created_at_msand walk pages, doubling page size on each retry until the trailing row fell outside the window. For high-volume agent clusters that's O(N) for an O(window) operation. With server-sidefrom/to, the scan is boundaried before hydration and cursor pagination over that window stays cursor-stable.Implementation
ReservationController.list) — parses ISO-8601 viaInstant.parse. Malformed values → 400INVALID_REQUEST(Invalid from: …/Invalid to: …).from > to→ 400 before the repository call, withverify(repository, never())locking that boundary. Blank strings treated as unset.RedisReservationRepository.listReservations,listReservationsSorted) — window predicate applied as a per-row filter in both the legacy SCAN-cursor path and the sorted path, after existing scope/status/tenant predicates and before hydration. SharedcreatedAtInWindow(fields, fromMs, toMs)helper. Missing/unparseablecreated_atrows defensively excluded when either bound is supplied.hash(...)now takes trailingLong fromMs, Long toMs. They're folded into the canonical string after the eight existing string fields, so a cursor issued under one(from, to)returns 400 if re-used under a different window — same contract as v0.1.25.12sort_by/sort_dir/filter-mismatch.listReservations(...)gains trailingLong fromMs, Long toMs(12 → 14 args). All Java callers updated. No wire format change.Validation rules
from=not-a-dateINVALID_REQUEST, messageInvalid from: …to=not-a-dateINVALID_REQUEST, messageInvalid to: …from > toINVALID_REQUEST, messagefrom must be less than or equal to to. No repository call.fromalonetoalonefrom == tofrom=&to=(blank)created_at, window activecreated_at, window inactiveCoverage
537 tests pass (374 data + 163 api) under
mvn test. 16 new tests:FilterHasherTest: 2 new (from/to distinct hash, positional swap).RedisReservationQueryTest: 7 new underTimeWindowFilternested class — legacy path from/to/inclusive-bounds, sorted path window, cursor-mismatch-on-window-change, missingcreated_atdefensive, unparseablecreated_atdefensive.ReservationControllerTest: 7 new underListReservationsnested class — malformed-from, malformed-to, reversed-window, from-only, to-only, equal-bounds, sort-key independence, blank-as-unset.Tests were run with
-Dcontract.spec.url=file:///path/to/local/cycles-protocol-v0.yamldue to a local SSL cert quirk; CI hits the defaultraw.githubusercontent.comURL which now resolves to the merged spec carryingfrom/to.Out of scope
The issue rationale mentions cleanup of expired/abandoned reservations as a use case — that actually wants
expires_atorfinalized_atwindow filters, notcreated_at. Flagged on the issue thread; leaving it as a follow-up to keep this change reviewable.Test plan
curl '…/v1/reservations?from=…&to=…', thencurl '…&from=…&to=…&sort_by=expires_at_ms'