Repository navigation
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.
|
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. A batch's cost is proportional to the batch, and 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). 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.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.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. This change must be carried into that repository before cutover, so the extracted collector doesn't regress to full re-materialization.Unverified
🤖 Generated with Claude Code