Operational views and dossiers rebuild the whole ledger on every request, so read cost grows without bound #142
Labels
No labels
burndown-2026-08
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/advocate
role/director
role/exec
role/frontend
role/gamedev
role/human
role/platform
role/qa
role/science
role/sysadmin
state
ambient
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/agent-proxy#142
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
What
_operational_builder()inapp/trajectory/api.pyruns on every request to/v1/trajectory/views/{view_name}and/v1/trajectory/dossiers/{trajectory_id}. Each call:materialize_retained_events, which itself reads the full events table)events = tuple(raw.iter_events())save_allA dossier request for one
trajectory_idtherefore pays for the entire ledger, then filters to one row at the end. The events table is append-only, so the cost of every read grows with everything the service has ever retained.Numbers
Measured on a synthetic ledger of request-lifecycle event pairs, steady state (already materialized, so nothing actually changed). Times are one
_operational_builder()call.Growth is linear at roughly 0.2 ms per retained turn. These are post-fix figures, with the per-record connect from the PR below already removed. Before that fix the same points were 54 / 146 / 293 / 617 ms.
Extrapolating that slope past what was measured, and taking issue #140's observed rate of about 375 completions a day:
Two things make it worse than the wall-clock suggests
It writes on a read.
MaterializationStore.save_alltakesBEGIN IMMEDIATEper record. The write lock is cheap in isolation (measured at about 7 ms across 2000 records) but it serializes against the hot-path trajectory emitter, which connects withtimeout=30. A large view rebuild can stall ingest.The dossier endpoint needs almost none of it.
OperationalViewBuilder.dossieruses exactly one materialized trajectory plus that trajectory's evaluations.MaterializationStore.latest(trajectory_id)andEvaluationStore.for_trajectory(trajectory_id)already exist and are both index-backed.Why this is not just a patch
The rebuild is what makes a view reflect events ingested since the last request. Reading only stored materializations would be O(1) for a dossier but could serve a result that misses a just-ingested event. That is a freshness-versus-cost decision, not a mechanical fix, which is why it is filed rather than folded into the connection-churn PR.
Roughly, the options:
latestplusfor_trajectory, and rebuild on a timer or on ingest rather than on read. Cheapest to build, and it changes what a read promises.trajectory_idindex oneventsplus a filterediter_events, neither of which exists today.Reproducing
Ingest N request-lifecycle event pairs into a
TrajectoryStore, call_operational_builder()once to warm it, then time a second call. The second call does no useful work and still costs the full amount.Related
The connection-churn half is now open as #143. It removes the largest constant factor (
save_allat 2000 turns goes 281.6 ms to 63.0 ms, the full rebuild 617.3 ms to 405.3 ms) and deliberately leaves the growth curve alone, because that is the part needing the freshness-versus-cost decision described above.The post-fix numbers in this issue's body were measured against that branch, so they are what remains after #143 lands.
#144 takes the second half that needed no decision: the builder read the whole events table twice per request, once inside
materialize_retained_eventsand again for the evaluation half.That second read was also a latent correctness problem. The two calls are separate SQLite snapshots, so an event ingested between them appeared in the evaluation half and not the materialized half, letting one request build its view from two different ledgers. Passing one snapshot to both removes it by construction.
Combined with #143, a 2000-turn rebuild is down from 617.3 ms to 262.3 ms:
Still open here: the growth curve. Read cost stays linear in ledger size because the rebuild still materializes everything on every request, and closing that changes what a read promises. The three options in the body above are unchanged, and the choice between them is still the blocker.
Superseded by #145 and closing.
Two of the three things described here landed: #143 (a SQLite connect per record) and #144 (a second full read of the events table). Together they took a 2000-turn rebuild from 617 ms to 297 ms.
What remains is the growth curve, which is a decision rather than a defect, so it moved to #145 on its own. That issue also corrects two things stated here:
trajectory_idis derived by unioning correlations rather than stored, so there is no event-to-trajectory mapping to index.The numbers in the body above are stale and #145 carries fresh ones measured on
4fa0bbe.