Repository navigation
Apply each telemetry event batch incrementally instead of re-materializing the run - #219
Merged
Merged
Conversation
…tion The collector re-materializes a run from every stored event whenever a batch arrives, so ingestion cost grows with the run's history. This adds the reducer that lets the store fold a batch onto the run's stored columns instead. The fold is exact because of how the materializer's columns behave. updated_at is a maximum over every event, and started_at is a minimum over the final producer's events. Every other column is written by the latest event that sets it, from that event alone. So new events that follow the active producer's stored events fold directly onto the stored columns. Two cases need targeted reads: - an event delivered after later events of its producer replays only that producer's stored events from it onward; - an earlier producer's first event reads the active producer's earliest timestamp, once per run. materialize_run now shares the fold loop, so both paths apply events and producer resets identically. The store is unchanged in this commit. Tests replay scripted and seeded delivery schedules covering every event kind, out-of-order and duplicate deliveries, and restarted producers. After each batch they compare the folded columns with full materialization, and they count the stored events read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
append_events re-read every stored event of the run, joined to its producer, and re-materialized the run whenever a batch added an event. Each batch therefore cost time proportional to the run's history, which is O(n^2) over a run. It also held the run's row lock for longer as the build went on. The store now reads each producer's highest stored sequence with one query: an index-only max per producer. It inserts the batch with RETURNING, so the reducer sees the same database values (time zone, JSONB) that a full re-read would. It then folds the inserted events onto the stored columns with apply_event_batch. A producer delivers in sequence order, so a normal batch reads no stored events. Two cases read more: - a late event reads only its producer's events from it onward; - an earlier producer's first event reads one minimum timestamp, at most once per run. The stored columns, the database contents and the HTTP responses are unchanged, and there is no schema change. get_run still materializes from every event, because it returns them all. Tests count the event rows each batch reads: constant per in-order batch, and only those after a late event, whatever the run's length. They also check, after every batch of scripted and seeded schedules, that the stored row equals the full materialization over the stored events, and that the run listing matches it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every earlier test sent UTC timestamps, so nothing checked that the store folds the rows its insert returns rather than the submitted values. They differ only in offset, and work.updated_at is stored as text, so a fold over the submitted values keeps the producer's offset where a full re-read of the stored events gives the database's. The new Postgres test sends each schedule twice, in UTC and in alternating +14:00 and -12:00 offsets: in-order batches of every event kind, a late event that replays the events stored after it, and an earlier attempt that first reports after a later one. After every batch the stored row must equal a full re-materialization, and each non-UTC run must equal its UTC run. It passes against this branch, which already folds the returned rows. Against a store that folds the submitted timestamps, it fails at the second batch on the work column, while the seven existing Postgres tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apply_event_batch is exact only if each producer's highest stored sequence, the run's stored columns and the targeted event reads all describe every event stored before the batch. append_events gets that from the run's FOR UPDATE lock, which producer registration also takes. Say so at the read and in apply_event_batch's docstring: read before the lock, a concurrent batch could commit in between, and this batch's earlier sequences would fold on top of its events instead of replaying as late events. Comments only; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 19, 2026
This branch was successfully deployed
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.
Fixes #217
Summary
The telemetry collector re-read every stored event of a run, and re-materialized the run, whenever a batch added an event (
PostgresTelemetryStore.append_events). Each batch's cost grew with the run's history, which is O(n²) over a run. It also held the run's row lock for longer as a build went on.The store now folds each batch onto the run's stored columns, so a batch's cost no longer grows with the run's stored events. It grows with the batch and with the number of producers registered for the run: every nonempty batch reads each registered producer's highest stored sequence (one row and one index lookup per producer; a producer is one build attempt, so a run that never restarted has one). Stored events are read only in the cases listed below. The resulting run state, database contents and HTTP responses are identical to the full re-materialization.
Why the fold is exact
materialize_runfolds events in canonical order (producer registration, producer, sequence) and resets the state at each later producer's first event:updated_atis a maximum over every event.started_atis a minimum over the final producer's events. It also includes the run's registration only while that producer's events are the run's first.So replaying any suffix of the active producer's stream that contains the new events, on top of the stored columns, gives exactly the full fold.
apply_event_batchhandles each case:updated_atmin(emitted_at)of the active producer, at most once per runThe store inserts with
RETURNINGthe stored rows. The fold therefore sees the same database values (time zone, JSONB) as a full re-read.materialize_runandapply_event_batchshare one fold loop.get_runstill materializes from every event, because it returns them all.Normal delivery reads no stored events
PolicyEngine/microcosm-emitter's telemetry delivery (
telemetry/service/collector.pyandspool.py) sends one batch per producer, in sequence order. It sends the next batch only after a 202.Commits
1.
6993387Add an incremental event-batch reducer equivalent to full materializationTests:
tests/test_incremental_batch_apply.py, which is pure, with an in-memory run that de-duplicates by event id and by producer and sequence, as the store does;tests/event_schedules.py: 18 event kinds covering every materializer branch, plus scripted and seeded delivery schedules.After every batch, the tests compare the folded columns with
materialize_runover all stored events. They cover:The cost tests count the stored events the reducer reads.
Red: 8 failed, for the intended reason.
NotImplementedErrorfrom the stubapply_event_batch. Only the interface existed, with no incremental application.started_atcorrection;updated_atfrom superseded events;Green: 8 passed.
Gate:
TEST_DATABASE_URL(postgres:16-alpine): 96 passed;ruff check .: passed;ruff format --check .: passed.2.
e881547Apply each stored event batch incrementally in the Postgres storeTests: in
tests/test_storage_postgres.py:test_incremental_batch_apply_reads_a_constant_number_of_event_rows_per_in_order_batchtest_incremental_batch_apply_reads_only_the_event_rows_after_a_late_eventtest_incremental_batch_apply_stores_the_fully_materialized_state. This one covers 42 scripted and seeded schedules. After every batch it checks that the stored row equalsmaterialize_runover the stored events, which is what ingestion stored before. After each schedule it checks thatlist_runsmatches.Red, against the store before this change: 2 failed, for the intended reason; 1 passed.
{60: 60, 600: 600}rows instead of 7.Green: 3 passed. Rows read per in-order batch are constant: one producer row, because the test's run has one producer. A late event reads 7 rows (one producer row, then the late event and the 5 stored after it), whether the run has 60 events or 600.
Gate:
python -m telemetry_collector.migrate, thenalembic check, against the disposable test database: "No new upgrade operations detected";ruff check .: passed;ruff format --check .: passed.Query plans, on 20 runs × 2,000 events:
max(sequence)is an Index Only Scan Backward under a Limit: 1 row, 4 buffers.telemetry_events_run_order_idx: 6 rows.3.
bbe4ea5MergemainA mechanical merge, so no red step. None of
main's 12 new commits touchtelemetry-service/.4.
15bb5b3Test incremental batch application with non-UTC timestampsA follow-up from code review: every earlier test sent UTC timestamps, so nothing checked the claim that the fold sees the same database values as a full re-read. The submitted value and the row returned by the insert differ only in their offset.
work.updated_atis stored as text, so a fold over the submitted values would store the producer's offset where a full re-read gives the database's.Test:
test_incremental_batch_apply_stores_database_values_for_non_utc_timestampsintests/test_storage_postgres.py. It sends each schedule twice, once in UTC and once in alternating +14:00 and −12:00 offsets. With those offsets, every second pair of events has wall-clock times in the opposite order to their instants. The schedules:After every batch the stored row must equal a full re-materialization. Each non-UTC run must also equal its UTC run.
Red: 0 failed against this branch, as expected. The test guards behaviour commit 2 already implements: the fold uses the rows the insert returns. To check that the test can fail, I ran it against a store that folds the submitted timestamps instead. It failed at the second batch, on the
workcolumn. The 7 existing Postgres tests passed against that store.Green: 1 passed.
5.
3dfbe05Document that the producer read must follow the run row lockThe second review follow-up, and comments only, so no red step.
apply_event_batchis exact only if each producer's highest stored sequence, the run's stored columns and the targeted event reads all describe every event stored before the batch.append_eventsgets that from the run'sFOR UPDATElock, which producer registration also takes. A comment at the producer read and a paragraph inapply_event_batch's docstring now say so. If the read moved before the lock, a concurrent batch could commit in between, and this batch's earlier sequences would be folded on top of its events instead of replayed as late events.After commits 4 and 5:
tests/test_storage_postgres.pyandtests/test_incremental_batch_apply.pywithTEST_DATABASE_URL(postgres:16-alpine): 16 passed;ruff checkandruff format --checkon the four changed files: passed.Locally I ran only the test files this PR adds or changes. CI's
telemetry-service.ymlvalidation job runs the whole telemetry-service suite, the migration andalembic check.Review follow-up (2026-10-20): description only, no commits
A review found that this description overstated the cost bound. It said a batch's cost was proportional to the batch, and that a duplicate re-send did no work. But
_stored_producersloads every registered producer and computes each one's highest stored sequence for every nonempty batch, so the cost also grows with the number of producers. The Summary, "Normal delivery reads no stored events", commit 2's green result and the Risks now say so, and the merge-method note at the top is new.No code change. The fold needs every producer's highest sequence only to find the active producer (the latest-registered one with stored events) and to tell whether it is the only one with events. Reading less would need a different producer query and a different input to
apply_event_batch, which the equivalence and cost tests would have to cover again, in a change that deploys straight to production. It would also add a commit after3dfbe05, which microcosm-collector#11 would then have to carry over again. With one producer per build attempt, that is not a cheap, safe fix, so the head stays3dfbe05.Findings, risks and deploy implications
Findings
storage.pylines 537–553 onmain.blockedrun status, block details and failure classes #212) rebuilt its row. The incremental fold therefore continues from consistent state.tests/fakes.pystill materializes fully. It remains the reference for the HTTP contract tests.Risks
_apply_event's rules will leave runs that are in progress during its deploy with columns computed under the old rules, unless those runs are re-materialized once. The fold also relies on each column staying a maximum, a minimum or an event-only write;_apply_event's docstring now says so._stored_producersloads every producer registered for the run and computes each one's highest stored sequence, for every nonempty batch, including a batch of duplicates. That is one indexed lookup per producer (an Index Only Scan Backward under a Limit, per the query plans above), so a run restarted many times pays for every earlier attempt on each batch. I did not measure how many producers production runs have.FOR UPDATElock before reading its producers. The code now says the read must stay after the lock (commit 5).Deploy implications
maintriggers.github/workflows/telemetry-service.yml(push tomain, pathstelemetry-service/**): validate, deploy staging, then deploy production. Each environment gets a maintenance revision, the migration job, a candidate revision and promotion; production also gets a Cloud SQL backup first.telemetry-service/is being extracted to PolicyEngine/microcosm-collector. Carry over calibration-diagnostics#219: apply each event batch incrementally microcosm-collector#11 carries this branch's head3dfbe05over, so the extracted collector doesn't regress to full re-materialization. Merge this PR with a merge commit (see the note at the top).Unverified
🤖 Generated with Claude Code