fix(api): bound journal scans and seen-sets — API RSS no longer grows with the journal#184
Merged
Merged
Conversation
… with the journal (#183) The webhook dispatcher's 2 s poll re-materialized the entire pipeline_events journal on every pass (unlimited fetch_pipeline_events); the S11 soak grew the journal to ~683k rows and the API process to 1.67 GB RSS in 4 h. Measured at unit scale: 35.5 -> 283.6 MB per scan as the journal grew 50k -> 400k rows; flat <= 0.8 MB after this change. - Webhook scan is incremental and bounded: at most scan_batch_size rows at/after a processed_at cursor (new min_processed_at parameter on fetch_pipeline_events; strictly parsed, inclusive, second-floored). The cursor freezes at the first event whose durable enqueue failed (retry-forever semantics preserved) and an all-seen full batch still advances it. Startup seeding is O(batch), not O(journal). - Scan/push dedup sets are capped (BoundedSeenSet, FIFO-with-refresh): eviction is safe because enqueue is idempotent on its primary key and a redundant cache invalidate repopulates on the next read. - Found while fixing: the metric-cache journal-scan fallback read the OLDEST 200 rows (ascending + limit), going silently blind once the journal outgrew the window; journal_scan_fetch now reads the tail window (newest_first) with a regression test. Live stand re-verification is scheduled for the next stand window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DORA Metrics
|
…ide check Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 10, 2026
brownjuly2003-code
added a commit
that referenced
this pull request
Jul 11, 2026
…roof E4 replica-correctness topology proof + serving audit fixes (#184-186)
brownjuly2003-code
added a commit
that referenced
this pull request
Jul 11, 2026
…ckend feat(flink): configurable state.backend (default rocksdb) + changelog for #184-186
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.
Closes #183.
Root cause (reproduced at unit scale)
WebhookDispatcher.dispatch_new_events()calledfetch_pipeline_events()with no limit every 2 s —SELECT … FROM pipeline_events ORDER BY processed_at ASCover the whole journal. The S11 soak (docs/perf/soak-s11-2026-07-10.md) appended ~683 k journal rows over 4 h, so each poll materialized the entire table (on ClickHouse: the full HTTP JSON response string + a list of ~683 k dicts), and the API's RSS high-water ratcheted to 1.67 GB. The bridge on the same host stayed flat because it never scans the journal.Unit-scale measurement (DuckDB backend, same mechanism — the leak is journal-size-driven, not ClickHouse-specific):
Extrapolated to the soak's 683 k rows: ~480 MB of transient allocation every 2 seconds by hour 4, plus two retained unbounded seen-sets (
WebhookDispatcher.seen_event_ids,MetricCacheController._seen_event_ids— one entry per event forever, the push feed included). Matches the observed curve: monotonic ratcheting growth, flat bridge, and a partial drop (1.67 → 1.17 GB) once load stopped while scans continued against the now-static journal.Fix
scan_batch_size(1000) rows at/after aprocessed_atcursor (newmin_processed_atparameter onfetch_pipeline_events; strictly parsed →ValueErrorbefore any SQL, inclusive, second-floored — the seen-set dedups the re-fetched cursor second). Delivery semantics preserved: the cursor advances over the contiguous prefix of rows that end the pass seen and freezes at the first row whose durable enqueue failed, so that row is re-fetched and retried next pass, exactly like the full scan's retry-forever behavior. An all-seen full batch still advances the cursor (livelock guard). Startup seeding is O(batch), not O(journal).BoundedSeenSet(capped, FIFO-with-refresh eviction) in the dispatcher (50 k) and cache controller (10 k). Eviction is safe: webhook enqueue is idempotent on its primary key and inline delivery fires only for freshly inserted rows; a redundant metric-cache invalidate merely repopulates on the next read.journal_scan_fetchnow reads thenewest_firsttail window; a regression test pins detection on a journal larger than the window.Verification
seencapped; cursor reaches the tail.ruff check/format --checkclean oversrc/ tests/ scripts/ sdk/.min_processed_atparse/injection guards.Follow-up (not in scope)
The SSE stream's per-connection
seen_event_idsset (routers/stream.py) grows for the lifetime of one connection — same class, but per-connection and not part of the soak's growth (no SSE clients were connected). Noted here for a future pass.🤖 Generated with Claude Code