feat(api): bounded materialized page snapshots for paged reads #83
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-45-pagination"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #45.
What this builds
Implements the bounded daemon-side page-snapshot store specified in Issue #45 and deferred from #9 (PR #44), making
docs/technical/cli-api.md#common-data-types-and-paginationtrue end to end: "Pages share a bounded materialized read result; expiry yieldscursor_expiredand requires restarting."Daemon-side bounded page-snapshot store (
src/api/pagination.rs):SessionId { epoch, id }— cursor session identity bound to the process boot epoch.TaskSnapshot,TimelineSnapshot), filter fingerprint, capture instant (as_of), and expiry deadline (expires_at).PAGE_SESSION_BOUND = 16concurrent listing sessions per route, with deterministic LRU eviction. Justification: 16 concurrent listings provides generous headroom for a single-user local deployment while capping resident memory at 16 materialized reads per route — strictly tighter than the unconstrained per-request materialization it replaces.PAGE_SESSION_TTL = CURSOR_TTL(15 minutes). The cursor's expiry matches the snapshot's deadline, so a cursor never outlives the snapshot it continues.Zero backend re-querying inside live sessions:
/v1/tasks: page 1 materializesTaskSnapshot(items, complete, coverage, warnings). Page N reads strictly from the in-memory snapshot; CalDAV is never re-queried (0 backend requests);as_ofdiscloses the capture instant./v1/timeline: page 1 materializesTimelineSnapshot(events, window bounds, unknown time count). Page N reads strictly from the in-memory snapshot; SQLite is never re-queried (0 database queries);as_ofdiscloses the capture instant.Identical error surface for expiry and eviction:
cursor_expired(HTTP 422, CLI exit 2) with byte-identical error envelopes.Structural restart invalidation:
SessionIdcarries the daemon boot epoch (u64). A cursor issued by a previous process carries a foreign epoch and is rejected ascursor_expiredeven if presented within its 15-minute TTL.Created once at startup and shared:
PageStoreis created once inapi::serve()and layered onto the Axum router viaExtension<Arc<PageStore>>. Handlers extractExtension(pages)and pass the store reference.router_with_stateprovides a default store per router, preserving test harness isolation.Disclosed design choices & tradeoffs
/v1/timeline, timeline snapshots are bounded atMAX_EXPANDED_INSTANCES(10,000) facts/entries. If a query window contains more than 10,000 events, it is refused withlimit_exceeded("narrow the timeline window") rather than silently truncated.docs/note:docs/is excluded from git in this clone (.git/info/exclude), so local specification notes are kept locally without pretending to be reviewable commits.Existing tests adjusted
tests/agenda_tasks_tests.rs: 3 synthetic cursor construction calls updated to passSessionId::for_test(...). Assertions (HTTP 422,cursor_expired) unchanged.tests/next_ranking_tests.rs: 1run_taskscall updated to pass&Default::default().tests/policy_filter_tests.rs: 2run_taskscalls updated to pass&Default::default().Verification
cargo fmt --check: clean (0 formatting issues)cargo clippy --all-targets -- -D warnings: clean (0 errors, 0 warnings)cargo test: 798 passed across 45 test suites, 0 failedtests/issue45_pagination_tests.rsprove:Review: PR #83 —
feat(api): bounded materialized page snapshots for paged readsVerdict:
APPROVEExecutive Summary
PR #83 implements the bounded daemon-side page-snapshot store specified in Issue #45 and deferred from #9 (PR #44), fully delivering the pagination specification in
docs/technical/cli-api.md#common-data-types-and-pagination: "Pages share a bounded materialized read result; expiry yieldscursor_expiredand requires restarting."The architecture is clean, conservative, and defensively designed:
PageStoreis instantiated once inapi::serve()and shared across handlers via Axum extensions. It is not spawned per request.PAGE_SESSION_BOUND = 16) with active LRU eviction based onlast_usedtimestamps, plus 15-minute TTL cleanup on access.as_ofpreserves the snapshot's capture instant across all subsequent pages rather than echoing the current wall clock.cursor_expiredwith identical messages.SessionIdembeds a random 64-bit boot epoch into the HMAC-signed cursor payload, preventing cursors from previous daemon runs from resuming against fresh processes.Acceptance Criteria Assessment
tests/issue45_pagination_tests.rs::test_headline_page1_mutate_backend_page2_from_snapshot_no_extra_backend_requestusesCountingCalDavto assertreport_countafter page 2 equalsreport_countafter page 1 after rewriting backend data with intruder tasks. Also verified for timeline intest_timeline_page1_mutate_journal_page2_continues_from_snapshot.cursor_expired(HTTP 422 / CLI exit 2) with identical error surfacesrc/api/pagination.rsmaps both toSESSION_GONE("cursor expired") andApiFailure::cursor_expired, which sets HTTP 422 and exit code 2 (INVALID_INPUT). Asserted intests/issue45_pagination_tests.rs::test_headline_expired_and_evicted_cursors_yield_cursor_expired_422_exit2.SessionIdcontainsepoch: u64generated randomly at store startup (PageSessions::with_limits) and embedded into the cursor payload. Verified intests/issue45_pagination_tests.rs::test_restart_invalidates_sessions_structurally.PageSessions::startdrops the session with minimumlast_usedwhenentries.len() >= bound. Verified intests/issue45_pagination_tests.rs::test_bound_actually_evicts_oldest_lru_session.api::serve()constructsArc<PageStore>once and registers it with.layer(Extension(pages)). Verified insrc/api/mod.rsand tested intests/issue45_pagination_tests.rs::test_store_is_created_once_and_shared_across_requests.clamp_limit, opaque hex cursor, constant-time filter comparison viact_eq. Verified intests/issue45_pagination_tests.rs::test_pinned_pagination_contractand existingtests/agenda_tasks_tests.rs.src/api/timeline.rsmaterializesTimelineSnapshoton page 1 and skips DB query on cursor path. Verified intests/issue45_pagination_tests.rs::test_timeline_page1_mutate_journal_page2_continues_from_snapshot.Detailed Review Focus Analysis
1. Store Lifecycle & Sharing
src/api/mod.rs:1953-1984, 2014-2060, 2158-2169.api::serve(),let pages = Arc::new(PageStore::new());is constructed once before starting the server.router_with_auth_and_pages()and registered via.layer(Extension(pages)).tasks_handlerandtimeline_handler) extractExtension(pages): Extension<Arc<PageStore>>.router_with_state()androuter_with_pages()attach a singleArc<PageStore>to each router instance.PageStoreper request.2. Bound Calculation & Memory Residency
PAGE_SESSION_BOUND = 16concurrent sessions per route./v1/tasksand/v1/timeline.MAX_EXPANDED_INSTANCES = 10,000(both routes refuse sets larger than 10,000 withlimit_exceeded).ApiItems.TimelineFacts.3. Backend Re-Querying & Mutation Isolation
PageSessions.cursorpresent): Handlers inspect the cursor, open the session snapshot, slice[offset .. offset + limit], and return immediately.CountingCalDavintest_headline_page1_mutate_backend_page2_from_snapshot_no_extra_backend_requestverifiesreports_after_p2 == reports_after_p1after rewriting the backend data.4.
as_ofDisclosureSnapshot<T>storescaptured_at: DateTime<Utc>.as_of: snapshot.captured_at().as_of: snapshot.captured_at().data2["as_of"] == data1["as_of"]. The current wall clock is never substituted.5. Expiry vs. Eviction Indistinguishability
src/api/pagination.rs,SESSION_GONE = "cursor expired".decode_cursor(whenexpiry < now.timestamp()) andPageSessions::open(whenentries.get_mut(&id)isNone, covering evicted or stale-boot sessions) returnErr(CursorExpired("cursor expired")).ApiFailure::cursor_expired, which yields HTTP 422 (UNPROCESSABLE_ENTITY), exit code 2 (INVALID_INPUT), anderror.code = "cursor_expired".test_headline_expired_and_evicted_cursors_yield_cursor_expired_422_exit2asserts that the response envelopes and error messages are byte-for-byte identical.6. Structural Restart Invalidation
SessionIdis composed ofepoch: u64andid: [u8; 16].epochis generated randomly onPageSessionsconstruction usingring::rand::SystemRandom.epochandidin its payload signed by HMAC-SHA256.cursor_expired.test_restart_invalidates_sessions_structurally, Process A and Process B share the same process-local HMAC key (so signature verification passes), yet Process B rejects Process A's cursor with HTTP 422cursor_expiredsolely due to epoch mismatch. In production, a fresh HMAC key generated on boot adds a second independent layer of rejection.7. Existing Paging Contract
clamp_limit: default 50, maximum 200.subtle::ConstantTimeEq. Mismatched filter returnscursor does not match current filters(cursor_expired, 422 / exit 2).(deadline, title), timeline sorted in reverse chronological order.8. Audit of Modified Existing Test Files
All hunks in the three modified existing test files were reviewed:
tests/agenda_tasks_tests.rs: Lines 745–770 intest_pagination_expired_and_tampered_cursors_rejected. Updated synthetic cursor calls to passSessionId::for_test(1, [0u8; 16])andexpires_at: DateTime<Utc>to match the newencode_cursorsignature. All assertions (HTTP 422,cursor_expired) remain unchanged.tests/next_ranking_tests.rs: Line 1224 inrun_tasks. Added&Default::default()for the newsessionsparameter. No assertions or fixtures changed.tests/policy_filter_tests.rs: Lines 881 & 1130 inrun_tasks. Added&Default::default()for the newsessionsparameter. No assertions or fixtures changed.9. The Disclosed Timeline Bound
/v1/timelineis newly introduced in this PR. Onmain,/v1/timelinequeried SQLite directly withLIMIT limit OFFSET offsetwithout enforcingMAX_EXPANDED_INSTANCES.docs/technical/calendar.mdanddocs/technical/cli-api.md#common-data-types-and-pagination. The spec mandates that pages share a bounded materialized read result. Without an upper bound on the initial snapshot query, large timeline queries would attempt unbounded in-memory materialization.ApiFailure::limit_exceededwith the exact message instructing the client to narrow the window.Findings
src/api/pagination.rs:386): Indecode_cursor, the check isif bytes.len() < PAYLOAD_BYTES + 32. If extra trailing bytes are appended,split_at(PAYLOAD_BYTES)produces ataglonger than 32 bytes, whichring::hmac::verifysafely rejects with signature mismatch. Checkingbytes.len() != PAYLOAD_BYTES + 32would reject earlier before HMAC verification, though the end outcome is identical (cursor_expired). (Pre-existing from #9).Verification I Ran
Ran in worktree
/Users/yurisasc/Development/Other/life-service/.worktrees/issue-45-pagination:nix develop --command cargo fmt --checknix develop --command cargo clippy --all-targets -- -D warningsnix develop --command cargo test --no-fail-fastmainbaseline + 7 new headline tests intests/issue45_pagination_tests.rs).tests/proposal_apply_tests.rshit the known intermittent abort race condition. All 7 headline tests intests/issue45_pagination_tests.rsand all 22 tests intests/agenda_tasks_tests.rspassed without failure.PR-Body Honesty
All claims in the PR description were verified against the code and test runs:
PAGE_SESSION_BOUND = 16concurrent listing sessions per route, with deterministic LRU eviction." — Verified insrc/api/pagination.rs:59, 281-291.test_headline_page1_mutate_backend_page2_from_snapshot_no_extra_backend_request.test_restart_invalidates_sessions_structurally.Anything Not Checked & Why
limit_exceedederror on/v1/timeline, due to the runtime and disk overhead of generating 10k rows during normal test runs. The code path (if page.has_more || page.facts.len() > MAX_EXPANDED_INSTANCES) was inspected and mirrors the verifiedtasksandagendachecks.yuris referenced this pull request2026-09-23 06:57:04 +00:00