Skip to content

feat(listReservations): add expires_*/finalized_* time-window filters (v0.1.25.21, closes #162)#163

Merged
amavashev merged 2 commits into
mainfrom
feat/issue-162-expires-finalized-filters
May 22, 2026
Merged

feat(listReservations): add expires_*/finalized_* time-window filters (v0.1.25.21, closes #162)#163
amavashev merged 2 commits into
mainfrom
feat/issue-162-expires-finalized-filters

Conversation

@amavashev
Copy link
Copy Markdown
Collaborator

Closes #162. Implements cycles-protocol-v0.yaml revision 2026-05-22. Follow-up to v0.1.25.20 (#160) — same chain, addresses the operational use case that revision intentionally left out.

Summary

Four new optional query parameters on GET /v1/reservations:

  • expires_from / expires_to — inclusive bounds on expires_at_ms.
  • finalized_from / finalized_to — inclusive bounds on finalized_at_ms.

All ISO 8601 date-time. Each pair binds to its target field regardless of sort_by. The three windows (from/to + expires_* + finalized_*) compose with AND semantics.

ReservationSummary gains an optional finalized_at_ms field so clients filtering on finalized_* can see the timestamp without a follow-up getReservation call. @JsonInclude(NON_NULL) for strict-schema back-compat.

Why

The v0.1.25.20 from/to window binds to created_at_ms, which is unhelpful for the operational use case the original issue thread also called out: cleanup sweepers that need to locate reservations expiring or already finalized within a window. A reservation created at T-7d that just expired this morning is invisible to a 24h created_at window — exactly what a sweeper is looking for.

Implementation

Controller (ReservationController.list)

Four new params, each validated independently:

  • Malformed values → 400 with distinct Invalid {param_name} message identifying which parameter failed.
  • expires_from > expires_to and finalized_from > finalized_to → 400 before any repository call.
  • Blank strings treated as unset (normative per the v0.1.25.22 spec carve-out).

Same parseIsoToEpochMs helper from v0.1.25.20 reused for parsing.

Repository (RedisReservationRepository)

Two new predicate helpers:

  • expiresAtInWindow(fields, fromMs, toMs) — reads expires_at hash field; defensive shape mirrors createdAtInWindow.
  • finalizedAtInWindow(fields, fromMs, toMs) — resolves the timestamp from committed_at OR released_at, matching buildReservationSummary's projection. Both absent → row excluded per the normative ACTIVE/EXPIRED exclusion.

Both applied in the legacy SCAN-cursor and sorted paths after the existing scope/status/tenant predicates and createdAtInWindow.

FilterHasher (cursor invalidation)

hash(...) gains four trailing Long arguments (10 → 14) with independent gated emission per pair. Each window pair emits its canonical block only when at least one of its bounds is non-null. This preserves byte-exact back-compat for both prior cursor generations:

  • v0.1.25.18 cursor (no windows) → canonical t=acme|i=|st=|ws=|ap=|wf=|ag=|ts= → golden hash 2f397ea0e8fb53b7 (locked down in v0.1.25.20)
  • v0.1.25.20 cursor (from/to only) → canonical t=acme|i=|st=|ws=|ap=|wf=|ag=|ts=|fr=100|to=200 → golden hash ad7204d521cfd133 (newly locked down here)

A future refactor that drops the gating (e.g., unconditionally appending |ef=|et=|ff=|ft= blocks) would break both goldens and fail the locked tests.

Internal Java signature changes

  • RedisReservationRepository.listReservations(...) 14 → 18 args (trailing Long expiresFromMs, Long expiresToMs, Long finalizedFromMs, Long finalizedToMs).
  • Private listReservationsSorted(...) mirrors.
  • FilterHasher.hash(...) 10 → 14 args.
  • ReservationSummary model gains finalizedAtMs field; toSummary(...) projection updated.
  • All Java callers updated.

The only wire-format change is the optional finalized_at_ms field on ReservationSummary — covered by the optional-property guarantee in the spec.

Validation matrix

Input Result
expires_from=not-a-date 400 INVALID_REQUEST, message Invalid expires_from: …
expires_to=2026-13-99T99:99:99Z 400, message Invalid expires_to: …
finalized_from=bogus 400, message Invalid finalized_from: …
finalized_to=bogus 400, message Invalid finalized_to: …
expires_from > expires_to 400, expires_from must be less than or equal to expires_to (...). No repository call.
finalized_from > finalized_to 400, distinct message for the finalized pair.
Blank-string for any of the 4 new params Treated as unset (matches v0.1.25.20 behavior for from/to).
Row with no committed_at and no released_at, finalized_* active Excluded (normative).
Row with only released_at, finalized_* active finalizedAtInWindow resolves from released_at.
All three windows + sort_by=expires_at_ms All 6 epoch-ms values propagate; AND-composes; ordering follows sort_by.

Coverage

557 protocol-service tests pass (384 data + 173 api), +19 from v0.1.25.20's 538:

  • FilterHasherTest (+3 new): expires_* values differ from base/from-to, finalized_* values differ from from-to/expires_*, v0.1.25.20 from/to golden hash lockdown.
  • RedisReservationQueryTest (+6 new) under ExpiresAndFinalizedWindowFilter nested class: legacy expires_from excludes-below, legacy expires_to excludes-above, legacy finalized_from excludes ACTIVE rows (no committed_at/released_at), finalized_at resolves from released_at when committed_at absent, all-three AND composition (created + expires + finalized), cursor mismatch on expires window change rejected with 400.
  • ReservationControllerTest (+10 new) under ListReservations nested class: 4 malformed-* tests, 2 reversed-window tests, expires propagation with verify(...) lock, finalized propagation with verify(...) lock, all-three combined with distinct epoch-ms per pair to catch slot mix-ups, blank-as-unset for new windows.

JaCoCo 95% bundle gate met.

Out of scope (intentionally)

The time_field-pivoted alternative parameter shape (one from/to plus a time_field=created_at|expires_at|finalized_at selector) was discussed in the spec PR and rejected — it would have been an awkward retroactive change to the v0.1.25.20 shape and split the family-wide from/to convention. Three parallel pairs is the cleaner expansion.

Test plan

  • CI green (lint, build, full test suite, Trivy)
  • Manual smoke against docker compose up (validation paths return 400, happy paths return 200; legacy SCAN cursor still resolves with new windows active)
  • (After merge) Client SDK regen — Python/TS/Spring already permissive; Rust needs 4 more Option<String> fields on ListReservationsParams
  • (After merge) cycles-server release v0.1.25.21 (rolls forward from .20)

amavashev added 2 commits May 22, 2026 07:58
… (v0.1.25.21)

Closes #162. Implements cycles-protocol-v0.yaml revision 2026-05-22
(runcycles/cycles-protocol#98). Follow-up to v0.1.25.20's from/to
window filter — addresses the operational use case that revision
intentionally left out: cleanup sweepers that need to locate
reservations expiring or already finalized within a window.

Four new optional query parameters on GET /v1/reservations:

  * expires_from / expires_to    — inclusive bounds on expires_at_ms.
  * finalized_from / finalized_to — inclusive bounds on finalized_at_ms.

All ISO 8601 date-time. Each pair binds to its target field
regardless of sort_by. The three windows (from/to + expires_* +
finalized_*) compose with AND semantics.

ReservationSummary gains an optional finalized_at_ms field so
clients filtering on finalized_* can see the timestamp without a
follow-up getReservation call. Optional (@JsonInclude NON_NULL) for
strict-schema back-compat.

ACTIVE/EXPIRED exclusion under finalized_*: per the spec, rows
where finalized_at_ms is absent MUST be excluded when either bound
is supplied. The predicate naturally fails on field-absent rows;
making the exclusion normative ensures conformant servers agree.

Implementation:

  * Controller: 4 new query params, each validated independently
    (malformed → 400 with distinct per-param message, from > to
    → 400 before any repository call, blank strings treated as
    unset). Mirrors the v0.1.25.20 parseIsoToEpochMs pattern.
  * Repository: two new predicate helpers (expiresAtInWindow,
    finalizedAtInWindow). finalizedAtInWindow resolves the
    timestamp from committed_at OR released_at, matching
    buildReservationSummary's projection logic. Applied in both
    legacy SCAN-cursor and sorted paths.
  * FilterHasher: gains four trailing Long args (10 → 14) with
    INDEPENDENT gated emission per pair. Preserves byte-exact
    back-compat for BOTH v0.1.25.18 cursors (no window canonical,
    golden 2f397ea0e8fb53b7) AND v0.1.25.20 cursors with from/to
    set (canonical |fr=|to= without expires/finalized, golden
    ad7204d521cfd133 — newly locked down here).
  * ReservationSummary model gets the new field; toSummary
    projection updated.
  * Java signature change: listReservations 14 → 18 args. No wire
    change for clients that omit the new params; the new
    response-body field is optional with NON_NULL serialization.

Coverage (557 tests, +19 from v0.1.25.20's 538):

  * FilterHasherTest +3: expires/finalized distinctness, finalized
    vs from/to distinctness, v0.1.25.20 8-byte golden lockdown.
  * RedisReservationQueryTest +6 under ExpiresAndFinalizedWindowFilter
    nested class: legacy expires_from excludes-below, legacy
    expires_to excludes-above, finalized excludes ACTIVE rows
    (no committed_at/released_at), finalized resolves from
    released_at when committed_at absent, all-three AND
    composition, cursor mismatch on expires window change.
  * ReservationControllerTest +10 under ListReservations nested
    class: 4 malformed-*, 2 reversed-window, expires propagation
    with verify() lock, finalized propagation with verify() lock,
    all-three combined with distinct epoch-ms per pair to catch
    slot mix-ups, blank-as-unset for new windows.

JaCoCo 95% bundle gate met.

Docs:

  * AUDIT.md: top date line + new long-form section walking
    through the rationale, three-pair design, schema addition,
    cursor back-compat, validation choices, and out-of-scope
    (time_field-pivoted alternative).
  * CHANGELOG.md: [0.1.25.21] entry under Keep-a-Changelog format.
  * README.md (cycles-protocol-service): four new param rows.
  * pom.xml revision bumped 0.1.25.20 → 0.1.25.21.
…and projection

Real Low finding from reviewer on #163.

`finalizedAtInWindow` (the new filter predicate) used a
committed-wins rule when both `committed_at` and `released_at` were
set on a row: `committedAt != null ? committedAt : releasedAt`.

`buildReservationSummary` (the existing projection that emits the
wire `finalized_at_ms`) used a last-write-wins assignment order
where `released_at` overwrites `committed_at`:

  if (committedAtStr != null) finalizedAtMs = parseLong(committedAtStr);
  if (releasedAtStr != null)  finalizedAtMs = parseLong(releasedAtStr);

In the normal lifecycle a row has at most one of these fields
(commit XOR release per the Lua scripts), so the disagreement only
surfaces on malformed Redis writes. But the spec contract is that
the filter operates against the returned `finalized_at_ms` — under
the bug, a row with both fields would be filtered using one
timestamp and returned with another.

Centralized via `resolveFinalizedAtStr(fields)`:

  String releasedAt = fields.get("released_at");
  if (releasedAt != null) return releasedAt;
  return fields.get("committed_at");

Both call sites now agree on released-wins, matching the pre-fix
projection behavior (so no wire change) and the spec contract.

Returns the raw String form so each caller can choose its own
parse-failure policy:
  - Predicate (finalizedAtInWindow): catches NumberFormatException,
    excludes the row defensively when the filter is active.
  - Projection (buildReservationSummary): lets it propagate as a
    data-corruption signal (unchanged from pre-fix behavior).

Regression test:

  finalizedResolverAgreesWhenBothFieldsSet — constructs a malformed
  row with committed_at=3000 and released_at=5000, queries with
  finalized window [4000, 6000]:
    - Pre-fix: committed-wins → 3000 not in window → row excluded
      → response empty (filter says "no match"). But the projection
      would have emitted 5000 if the row HAD been returned. Disagree.
    - Post-fix: released-wins → 5000 in window → row included.
      Projection also emits 5000 on the returned summary. Both
      paths agree. Test verifies BOTH the inclusion AND the
      response's finalized_at_ms == 5000.

558 protocol-service tests pass (385 data + 173 api), +1 vs the
initial #163 commit. JaCoCo 95% bundle gate met.

No version bump — staying at 0.1.25.21. Pure correctness fix on
a same-PR finding.
@amavashev amavashev merged commit f99c5bc into main May 22, 2026
8 checks passed
@amavashev amavashev deleted the feat/issue-162-expires-finalized-filters branch May 22, 2026 12:22
amavashev added a commit to runcycles/cycles-client-typescript that referenced this pull request May 22, 2026
…ugh (0.3.3) (#104)

Client-side companion to cycles-protocol-v0.yaml revision 2026-05-22
(runcycles/cycles-protocol#98) and runcycles/cycles-server#163.
Closes the TypeScript-client side of issue #162. Follow-up to v0.3.2
with the same shape, just four more params.

The existing `listReservations(params?: Record<string, string>)`
signature already forwards arbitrary keys to the URL query string,
so the four new ISO 8601 date-time params (expires_from /
expires_to / finalized_from / finalized_to) work over the wire
today without a code change. This commit adds a regression test
that pins the URL-encoded passthrough.

Call-site:

  await client.listReservations({
    tenant: "acme",
    expires_from: "2026-05-22T00:00:00Z",
    expires_to: "2026-05-23T00:00:00Z",
    finalized_from: "2026-05-15T00:00:00Z",
    finalized_to: "2026-05-22T00:00:00Z",
  });

Colons URL-encoded to %3A per native fetch + URLSearchParams.

No protocol or wire-format change; servers older than v0.1.25.21
silently ignore the new params per the additive-parameter guarantee.
317 tests pass at 98.4% statement / 99.62% line coverage.

Bumped to 0.3.3, updated AUDIT.md and CHANGELOG.md.
amavashev added a commit to runcycles/cycles-client-rust that referenced this pull request May 22, 2026
…_at_ms (0.2.6)

Client-side companion to cycles-protocol-v0.yaml revision 2026-05-22
(runcycles/cycles-protocol#98) and runcycles/cycles-server#163.
Closes the Rust-client side of issue #162. Follow-up to v0.2.5
with the same shape, just four more params on the request side
plus one new optional response-side field.

ListReservationsParams (request):

  Four new Option<String> fields, each with skip_serializing_if:
  expires_from, expires_to, finalized_from, finalized_to. Each
  pair binds to its target field independent of from/to and any
  sort_by. finalized_* excludes ACTIVE and EXPIRED rows
  (finalized_at_ms absent on those rows per the spec).

ReservationSummary (response):

  New Option<u64> field finalized_at_ms with #[serde(default)].
  Populated by servers on COMMITTED and RELEASED rows only;
  absent (None) on ACTIVE/EXPIRED rows and on pre-v0.1.25.21
  servers regardless of status. Back-compat preserved both ways
  by Option + serde default.

Regression tests in tests/client_test.rs:

  * list_reservations_forwards_expires_and_finalized_windows:
    wiremock query_param matchers assert all four request-side
    fields land on the wire under the spec names.
  * list_reservations_deserializes_finalized_at_ms_on_summary:
    server emits finalized_at_ms on a COMMITTED row, client
    deserializes to Some(value).
  * list_reservations_deserializes_absent_finalized_at_ms_as_none:
    server emits the row WITHOUT finalized_at_ms (pre-revision
    server or ACTIVE row), client deserializes to None. Locks
    the back-compat path.

No protocol or wire-format change beyond the optional response
field; servers older than v0.1.25.21 silently ignore the new
request params and don't emit the new response field. 134 tests
pass across integration + unit suites; doc-tests + clippy clean.

Out of scope (intentionally narrow, same as v0.2.5): pre-existing
drift on ListReservationsParams (missing workspace / workflow /
toolset / sort_by / sort_dir / idempotency_key fields relative to
full v0.1.25 spec parity) is not addressed here. Worth a follow-up.

Bumped to 0.2.6, refreshed Cargo.lock, updated AUDIT.md and
CHANGELOG.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Suggestion] Add expires_at / finalized_at range filtering to listReservations

1 participant