feat(consumer): bound Shape.Consumer heap growth via spawn opts + adaptive GC#4539
feat(consumer): bound Shape.Consumer heap growth via spawn opts + adaptive GC#4539erik-the-implementer wants to merge 10 commits into
Conversation
…C test + negative test
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4539 +/- ##
==========================================
+ Coverage 56.45% 56.63% +0.17%
==========================================
Files 358 359 +1
Lines 39081 39329 +248
Branches 10973 11048 +75
==========================================
+ Hits 22064 22274 +210
- Misses 16946 16983 +37
- Partials 71 72 +1
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 ReviewSummaryThis PR bounds runaway old-heap growth in What's Working Well
Issues FoundCritical (Must Fix)None. Important (Should Fix)None remaining. The iteration-1 Important issue (synchronous GC on the publish path with no rate limit) is resolved by the hysteresis + documentation in Suggestions (Nice to Have)
Issue ConformancePR correctly declares Previous Review StatusIteration 1 → 2. Resolved:
Still open (both minor, by-design): per-fragment Review iteration: 2 | 2026-06-09 |
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, runtime-tunable. After processing a transaction fragment (including during the startup buffer drain), the consumer runs
:erlang.garbage_collect()only if its heap exceedsconsumer_gc_heap_threshold(bytes;nil= off, the default). The threshold is read fromStackConfigon every fragment, so it can be changed live from IEx — seeElectric.Shapes.Consumer.set_gc_heap_threshold/2andset_gc_heap_threshold_all_stacks/1.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_THRESHOLDnildisablesnilTesting
over_heap_threshold?/2(word/byte conversion, strict>boundary).ELECTRIC_CONSUMER_GC_HEAP_THRESHOLDreachesStackConfig.mix compile --warnings-as-errorsandmix format --check-formattedclean.Rollout / rollback
Inert by default: new spawn-opts keys do nothing unless named in
ELECTRIC_PROCESS_SPAWN_OPTS, and adaptive GC is off unless the threshold is set. The threshold can be toggled live from IEx without a deploy. Electric Cloud will setfullsweep_after: 4for the consumer family via env (separate stratovolt change).🤖 Generated with Claude Code