perf(trajectory): read the ledger once per view request, not twice #144

Merged
coilysiren merged 1 commit from task/142-single-ledger-read into main 2026-08-19 15:47:12 +00:00
Owner

Refs #142. Takes the half of it that needs no design decision, and deliberately leaves the rest.

The problem

_operational_builder runs on every /v1/trajectory/views/* and /dossiers/* request and called iter_events twice: once inside materialize_retained_events, then again for the evaluation half. Reading and JSON-parsing the whole events table is the dominant cost of that path, so it was being paid twice over.

Measurements

Same-process, synthetic ledger, steady state (nothing changed, every write a no-op). Baseline is merged main at 9327158.

ledger before after
500 turns 109.6 ms 60.0 ms
1000 turns 186.8 ms 121.9 ms
2000 turns 369.0 ms 262.3 ms

It was also a latent correctness problem

The two reads are separate SQLite snapshots. An event ingested between them appeared in the evaluation half and not in the materialized half, so a single request could build its view from two different ledgers. That window is narrow but the ingest emitter writes concurrently on the hot path, so it is reachable rather than theoretical. One snapshot removes it by construction, which is why materialize_retained_events grew an events parameter instead of the builder just caching a local.

Callers that pass no events are unaffected and still read the store.

Verification

  • tests/test_trajectory_view_reads.py, 3 tests. Mutation-checked: restoring the double read fails the suite. One test drives the real _operational_builder and counts full-table reads, the others pin that a supplied snapshot is what gets materialized and that the default path is unchanged.
  • Full suite 418 passed. just format-check, just lint, just typecheck, and pre-commit run --all-files clean.

What is still open on #142

The growth curve. Read cost is still linear in ledger size, because the rebuild still materializes everything on every request. Closing that means choosing between a staleness window, incremental materialization, or cached views. That changes what a read promises, so it stays yours to call and #142 stays open.

Between this and #143, a 2000-turn rebuild is down from 617.3 ms to 262.3 ms without touching that promise.

🤖 Generated with Claude Code

Refs #142. Takes the half of it that needs no design decision, and deliberately leaves the rest. ## The problem `_operational_builder` runs on every `/v1/trajectory/views/*` and `/dossiers/*` request and called `iter_events` **twice**: once inside `materialize_retained_events`, then again for the evaluation half. Reading and JSON-parsing the whole events table is the dominant cost of that path, so it was being paid twice over. ## Measurements Same-process, synthetic ledger, steady state (nothing changed, every write a no-op). Baseline is merged `main` at 9327158. | ledger | before | after | |---|---|---| | 500 turns | 109.6 ms | 60.0 ms | | 1000 turns | 186.8 ms | 121.9 ms | | 2000 turns | 369.0 ms | 262.3 ms | ## It was also a latent correctness problem The two reads are separate SQLite snapshots. An event ingested between them appeared in the evaluation half and not in the materialized half, so a single request could build its view from two different ledgers. That window is narrow but the ingest emitter writes concurrently on the hot path, so it is reachable rather than theoretical. One snapshot removes it by construction, which is why `materialize_retained_events` grew an `events` parameter instead of the builder just caching a local. Callers that pass no `events` are unaffected and still read the store. ## Verification * `tests/test_trajectory_view_reads.py`, 3 tests. Mutation-checked: restoring the double read fails the suite. One test drives the real `_operational_builder` and counts full-table reads, the others pin that a supplied snapshot is what gets materialized and that the default path is unchanged. * Full suite 418 passed. `just format-check`, `just lint`, `just typecheck`, and `pre-commit run --all-files` clean. ## What is still open on #142 The growth curve. Read cost is still linear in ledger size, because the rebuild still materializes everything on every request. Closing that means choosing between a staleness window, incremental materialization, or cached views. That changes what a read promises, so it stays yours to call and #142 stays open. Between this and #143, a 2000-turn rebuild is down from 617.3 ms to 262.3 ms without touching that promise. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
perf(trajectory): read the ledger once per view request, not twice
All checks were successful
ci / smoke (pull_request) Successful in 6s
ci / quality (pull_request) Successful in 32s
54125430b9
`_operational_builder` runs on every view and dossier request and called
`iter_events` twice: once inside `materialize_retained_events` and again for
the evaluation half. Reading and JSON-parsing the whole events table is the
dominant cost of that path, so it was being paid twice over.

`materialize_retained_events` now takes an optional `events`, and the builder
reads once and hands the same tuple to both halves. Measured same-process on a
synthetic ledger in the steady state:

    500 turns    109.6 ms  ->   60.0 ms
   1000 turns    186.8 ms  ->  121.9 ms
   2000 turns    369.0 ms  ->  262.3 ms

The second read was also a latent correctness problem, not only a cost. An
event ingested between the two calls appeared in the evaluation half and not
in the materialized half, so one request could report two different ledgers.
One snapshot removes that by construction.

Callers that pass no `events` are unaffected and still read the store.

Growth stays linear in ledger size, so #142 remains open for the freshness
decision behind incremental materialization.

Refs #142

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agent-proxy!144
No description provided.