feat(consumer): bound Shape.Consumer heap growth via spawn opts + adaptive GC#4539
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4539 +/- ##
==========================================
+ Coverage 58.35% 58.74% +0.38%
==========================================
Files 370 383 +13
Lines 40674 42260 +1586
Branches 11553 12103 +550
==========================================
+ Hits 23735 24824 +1089
- Misses 16865 17360 +495
- Partials 74 76 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Claude Code ReviewSummary Bounds runaway old-heap growth in What is Working Well
Issues Found Critical (Must Fix): None. Important (Should Fix):
Suggestions (Nice to Have):
Issue Conformance Still matches #4476 precisely — the spawn-opt lever targets exactly the three named processes ( Previous Review Status Iteration 4 → 5.
The code is in good shape. The single actionable item remains updating the PR description/runbook wording to match the cached-at-startup + deferred-GC behavior. Review iteration: 5 | 2026-06-15 |
…C test + negative test
Cache the adaptive-GC heap threshold in consumer state once at init instead of doing a StackConfig ETS lookup on every txn fragment, and convert total_heap_size to bytes at the query site so the "words" jargon no longer threads through over_heap_threshold?/should_force_gc?. set_gc_heap_threshold/2 now only affects consumers started after the call; docstring updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(consumer): populate cached GC threshold in State.new/2 Move the consumer_gc_heap_threshold lookup into State.new/2 alongside the existing hibernate_after/suspend_after lookups, instead of patching the field onto the struct in Consumer.init/1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The helper had a single caller now that the threshold lives in bytes, so fold the comparison into should_force_gc?/5 and drop the standalone function and its tests (boundary coverage moved into should_force_gc?). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the adaptive GC check via {:continue, :maybe_gc} after replying to the
ShapeLogCollector, so a forced full sweep no longer blocks the SLC's
synchronous publish (design doc §6). The continue re-establishes the
hibernate_after timeout the {:continue, …} return cannot carry.
process_buffered_txn_fragments no longer GCs per fragment; the :consume_buffer
continue defers a single GC once the whole buffer is drained.
Switch the GC integration tests to assert on State.last_forced_gc_at (the
direct signal of our forced-GC decision) instead of heap-size magnitude or
GC-event traces, which were fragile against off-heap binaries and natural
BEAM major GCs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
This PR has been released! 🚀 The following packages include changes from this PR:
Thanks for contributing to Electric! |
Closes #4476
Summary
Bounds runaway old-heap growth of
Shape.Consumerprocesses (issue #4476). On a production node with 5,043 consumers, ~36 GB was unreclaimable garbage (7–9 MB heaps each holding ~8 KB of live state) because consumers inherit the BEAM defaultfullsweep_after: 65535and rarely hibernate, so a full sweep is effectively never triggered.Two complementary, independently-tunable levers:
Per-process spawn opts (env-driven).
Consumer,Consumer.Snapshotter, andConsumer.Materializernow passspawn_opt: Electric.StackConfig.spawn_opts(stack_id, key)(keys:consumer,:consumer_snapshotter,:consumer_materializer), mirroringShapeLogCollector. This letsELECTRIC_PROCESS_SPAWN_OPTSsetfullsweep_afterper process type. No baked-in defaults — purely env-configured.Adaptive GC, opt-in. After processing a transaction fragment (including during the startup buffer drain), the consumer forces
:erlang.garbage_collect()only if its heap exceedsconsumer_gc_heap_threshold(bytes;nil= off, the default). The threshold is cached per-consumer at startup (State.new/2), so changing it affects consumers started afterwards. The forced sweep runs off the reply path via a{:continue, :maybe_gc}step, so it never blocks theShapeLogCollector's synchronous publish call, and a hysteresis interval (@gc_min_interval_ms) caps how much CPU a busy consumer spends sweeping. SeeElectric.Shapes.Consumer.set_gc_heap_threshold/2.Request-handler (
GET /v1/shape) processes are intentionally untouched — they already exposefullsweep_afterviaELECTRIC_TWEAKS_HANDLER_FULLSWEEP_AFTERand force GC before long-poll blocking.Config
ELECTRIC_PROCESS_SPAWN_OPTSconsumer/consumer_snapshotter/consumer_materializerkeys (e.g.{"consumer":{"fullsweep_after":4}})%{}ELECTRIC_CONSUMER_GC_HEAP_THRESHOLDnildisablesnil🤖 Generated with Claude Code