Skip to content

Commit 7a8f8c8

Browse files
erik-the-implementeralcoclaude
authored
feat(consumer): bound Shape.Consumer heap growth via spawn opts + adaptive GC (#4539)
Closes #4476 ## Summary Bounds runaway old-heap growth of `Shape.Consumer` processes (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 default `fullsweep_after: 65535` and rarely hibernate, so a full sweep is effectively never triggered. Two complementary, independently-tunable levers: 1. **Per-process spawn opts (env-driven).** `Consumer`, `Consumer.Snapshotter`, and `Consumer.Materializer` now pass `spawn_opt: Electric.StackConfig.spawn_opts(stack_id, key)` (keys `:consumer`, `:consumer_snapshotter`, `:consumer_materializer`), mirroring `ShapeLogCollector`. This lets `ELECTRIC_PROCESS_SPAWN_OPTS` set `fullsweep_after` per process type. No baked-in defaults — purely env-configured. 2. **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 exceeds `consumer_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 the `ShapeLogCollector`'s synchronous publish call, and a hysteresis interval (`@gc_min_interval_ms`) caps how much CPU a busy consumer spends sweeping. See `Electric.Shapes.Consumer.set_gc_heap_threshold/2`. Request-handler (`GET /v1/shape`) processes are intentionally untouched — they already expose `fullsweep_after` via `ELECTRIC_TWEAKS_HANDLER_FULLSWEEP_AFTER` and force GC before long-poll blocking. ## Config | Env var | Meaning | Default | |---|---|---| | `ELECTRIC_PROCESS_SPAWN_OPTS` | JSON map; now accepts `consumer`/`consumer_snapshotter`/`consumer_materializer` keys (e.g. `{"consumer":{"fullsweep_after":4}}`) | `%{}` | | `ELECTRIC_CONSUMER_GC_HEAP_THRESHOLD` | adaptive-GC heap threshold in bytes; `nil` disables | `nil` | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Oleksii Sholik <oleksii@sholik.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 48d3ba3 commit 7a8f8c8

12 files changed

Lines changed: 483 additions & 8 deletions

File tree

.changeset/consumer-heap-gc.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/sync-service": patch
3+
---
4+
5+
Bound Shape.Consumer heap growth: make the consumer family's process spawn options (incl. `fullsweep_after`) configurable per process via `ELECTRIC_PROCESS_SPAWN_OPTS`, and add an opt-in adaptive GC that runs after a transaction fragment when the consumer's heap exceeds the runtime-tunable `ELECTRIC_CONSUMER_GC_HEAP_THRESHOLD` (off by default).

packages/sync-service/config/runtime.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ config :electric,
291291
process_registry_partitions: env!("ELECTRIC_TWEAKS_PROCESS_REGISTRY_PARTITIONS", :integer, nil),
292292
process_spawn_opts:
293293
env!("ELECTRIC_PROCESS_SPAWN_OPTS", &Electric.Config.parse_spawn_opts!/1, %{}),
294+
consumer_gc_heap_threshold: env!("ELECTRIC_CONSUMER_GC_HEAP_THRESHOLD", :integer, nil),
294295
http_api_num_acceptors: env!("ELECTRIC_TWEAKS_HTTP_API_NUM_ACCEPTORS", :integer, 100),
295296
conn_max_requests: env!("ELECTRIC_TWEAKS_CONN_MAX_REQUESTS", :integer, nil),
296297
handler_fullsweep_after: env!("ELECTRIC_TWEAKS_HANDLER_FULLSWEEP_AFTER", :integer, nil),

packages/sync-service/lib/electric/application.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ defmodule Electric.Application do
156156
shape_suspend_after: get_env(opts, :shape_suspend_after),
157157
conn_max_requests: get_env(opts, :conn_max_requests),
158158
handler_fullsweep_after: get_env(opts, :handler_fullsweep_after),
159-
process_spawn_opts: get_env(opts, :process_spawn_opts)
159+
process_spawn_opts: get_env(opts, :process_spawn_opts),
160+
consumer_gc_heap_threshold: get_env(opts, :consumer_gc_heap_threshold)
160161
],
161162
manual_table_publishing?: get_env(opts, :manual_table_publishing?),
162163
shape_db_opts: [

packages/sync-service/lib/electric/config.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ defmodule Electric.Config do
114114
#
115115
# e.g. %{shape_log_collector: [min_heap_size: 1024 * 1024, min_bin_vheap_size: 1024 * 1024]}
116116
process_spawn_opts: %{},
117+
# Heap-size threshold (in BYTES) above which a consumer runs :erlang.garbage_collect()
118+
# after processing a transaction fragment.
119+
consumer_gc_heap_threshold: nil,
117120
## Misc
118121
process_registry_partitions: &Electric.Config.Defaults.process_registry_partitions/0,
119122
feature_flags: if(Mix.env() == :test, do: @known_feature_flags, else: []),

packages/sync-service/lib/electric/shapes/consumer.ex

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ defmodule Electric.Shapes.Consumer do
3838
@default_snapshot_timeout 45_000
3939
@stop_and_clean_timeout 30_000
4040
@stop_and_clean_reason ShapeCleaner.consumer_cleanup_reason()
41+
@word_size :erlang.system_info(:wordsize)
42+
43+
# Minimum wall-clock interval (ms) between consumer-forced full GC sweeps. Caps how much CPU
44+
# a busy consumer spends on full sweeps to at most one per @gc_min_interval_ms regardless of
45+
# fragment rate.
46+
@gc_min_interval_ms 1_000
4147

4248
@type initialize_shape_opts() :: %{
4349
:action => :create | :restore,
@@ -110,9 +116,24 @@ defmodule Electric.Shapes.Consumer do
110116
ConsumerRegistry.whereis(stack_id, shape_handle)
111117
end
112118

119+
@doc """
120+
Set the adaptive-GC heap threshold (bytes, or nil to disable) for a single stack.
121+
122+
Consumers cache this value at startup (see `State.new/2`), so the new threshold only
123+
applies to consumers started after this call — already-running consumers keep the
124+
threshold they read when they booted. Safe to call from IEx.
125+
"""
126+
@spec set_gc_heap_threshold(Electric.stack_id(), non_neg_integer() | nil) :: :ok
127+
def set_gc_heap_threshold(stack_id, threshold_bytes)
128+
when is_nil(threshold_bytes) or (is_integer(threshold_bytes) and threshold_bytes >= 0) do
129+
Electric.StackConfig.put(stack_id, :consumer_gc_heap_threshold, threshold_bytes)
130+
:ok
131+
end
132+
113133
def start_link(%{stack_id: stack_id, shape_handle: shape_handle} = _config) do
114134
GenServer.start_link(__MODULE__, %{stack_id: stack_id, shape_handle: shape_handle},
115-
name: name(stack_id, shape_handle)
135+
name: name(stack_id, shape_handle),
136+
spawn_opt: Electric.StackConfig.spawn_opts(stack_id, :consumer)
116137
)
117138
end
118139

@@ -170,10 +191,19 @@ defmodule Electric.Shapes.Consumer do
170191
if state.terminating? do
171192
{:noreply, state, {:continue, :stop_and_clean}}
172193
else
173-
{:noreply, state, state.hibernate_after}
194+
{:noreply, state, {:continue, :maybe_gc}}
174195
end
175196
end
176197

198+
# Deferred adaptive GC. Reached via {:continue, :maybe_gc} after a fragment (or a
199+
# full buffer drain) has been processed, so a forced full sweep runs off the
200+
# reply/critical path rather than blocking the ShapeLogCollector. Re-establishes
201+
# the hibernate_after timeout that the {:continue, …} return could not carry.
202+
def handle_continue(:maybe_gc, state) do
203+
state = maybe_garbage_collect(state)
204+
{:noreply, state, state.hibernate_after}
205+
end
206+
177207
@impl GenServer
178208
# Any incoming message counts as activity: cancel the pending suspend timer (if
179209
# any) and recurse for actual handling of the call.
@@ -204,7 +234,7 @@ defmodule Electric.Shapes.Consumer do
204234
{:reply, :ok, state, {:continue, :stop_and_clean}}
205235

206236
state ->
207-
{:reply, :ok, state, state.hibernate_after}
237+
{:reply, :ok, state, {:continue, :maybe_gc}}
208238
end
209239
end
210240

@@ -520,6 +550,56 @@ defmodule Electric.Shapes.Consumer do
520550
handle_txn_fragment(txn_fragment, state)
521551
end
522552

553+
# Adaptive GC check. Always invoked from a {:continue, :maybe_gc} handler so the
554+
# forced full sweep runs off the ShapeLogCollector's synchronous publish path: the
555+
# SLC has already received :ok by the time this runs. Because the SLC publishes
556+
# fragments to a given consumer sequentially, the continue typically completes
557+
# before the next fragment arrives, so it does not block steady-state throughput.
558+
559+
# Fast path: adaptive GC is disabled — skip all process_info/time calls.
560+
defp maybe_garbage_collect(%State{gc_heap_threshold: nil} = state), do: state
561+
562+
defp maybe_garbage_collect(%State{gc_heap_threshold: threshold_bytes} = state) do
563+
{:total_heap_size, heap_words} = :erlang.process_info(self(), :total_heap_size)
564+
heap_bytes = heap_words * @word_size
565+
now = System.monotonic_time(:millisecond)
566+
567+
if should_force_gc?(heap_bytes, threshold_bytes, state.last_forced_gc_at, now) do
568+
:erlang.garbage_collect()
569+
%{state | last_forced_gc_at: now}
570+
else
571+
state
572+
end
573+
end
574+
575+
@doc false
576+
# Decide whether to force a full GC sweep: heap (bytes) must be over the
577+
# threshold (bytes) AND at least @gc_min_interval_ms must have elapsed since the
578+
# last forced GC. last_gc_at / now_ms are monotonic milliseconds; last_gc_at is
579+
# nil if this consumer has never forced a GC (always fire on first over-threshold
580+
# event). Passing explicit min_interval_ms enables deterministic unit tests.
581+
@spec should_force_gc?(
582+
non_neg_integer(),
583+
non_neg_integer() | nil,
584+
integer() | nil,
585+
integer(),
586+
non_neg_integer()
587+
) :: boolean()
588+
def should_force_gc?(
589+
heap_bytes,
590+
threshold_bytes,
591+
last_gc_at,
592+
now_ms,
593+
min_interval_ms \\ @gc_min_interval_ms
594+
)
595+
596+
def should_force_gc?(_heap_bytes, nil, _last_gc_at, _now_ms, _min_interval_ms), do: false
597+
598+
def should_force_gc?(heap_bytes, threshold_bytes, last_gc_at, now_ms, min_interval_ms) do
599+
heap_bytes > threshold_bytes and
600+
(is_nil(last_gc_at) or now_ms - last_gc_at >= min_interval_ms)
601+
end
602+
523603
# A consumer process starts with buffering?=true before it has PG snapshot info (xmin, xmax, xip_list).
524604
# In this phase we have to buffer incoming txn fragments because we can't yet decide what to
525605
# do with the transaction: skip it or write it to the shape log.

packages/sync-service/lib/electric/shapes/consumer/materializer.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ defmodule Electric.Shapes.Consumer.Materializer do
111111
do: subscribe(%{stack_id: stack_id, shape_handle: shape_handle})
112112

113113
def start_link(opts) do
114-
GenServer.start_link(__MODULE__, opts, name: name(opts))
114+
GenServer.start_link(__MODULE__, opts,
115+
name: name(opts),
116+
spawn_opt: Electric.StackConfig.spawn_opts(opts.stack_id, :consumer_materializer)
117+
)
115118
end
116119

117120
def init(opts) do

packages/sync-service/lib/electric/shapes/consumer/snapshotter.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ defmodule Electric.Shapes.Consumer.Snapshotter do
2020
end
2121

2222
def start_link(config) when is_map(config) do
23-
GenServer.start_link(__MODULE__, config, name: name(config))
23+
GenServer.start_link(__MODULE__, config,
24+
name: name(config),
25+
spawn_opt: Electric.StackConfig.spawn_opts(config.stack_id, :consumer_snapshotter)
26+
)
2427
end
2528

2629
def init(config) do

packages/sync-service/lib/electric/shapes/consumer/state.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ defmodule Electric.Shapes.Consumer.State do
4747
# as any message arrives (activity), so at most one is ever live at a time.
4848
suspend_timer: nil,
4949
# How long after hibernation to suspend (in ms)
50-
suspend_after: nil
50+
suspend_after: nil,
51+
# Monotonic millisecond timestamp of the last consumer-forced GC (nil if never).
52+
# Used by hysteresis logic in maybe_garbage_collect/1 to cap forced-GC frequency.
53+
last_forced_gc_at: nil,
54+
# Adaptive-GC heap threshold (bytes) cached at consumer startup, or nil when
55+
# disabled.
56+
gc_heap_threshold: nil
5157
]
5258

5359
@type pg_snapshot() :: SnapshotQuery.pg_snapshot()
@@ -107,6 +113,7 @@ defmodule Electric.Shapes.Consumer.State do
107113
:shape_suspend_after,
108114
Electric.Config.default(:shape_suspend_after)
109115
),
116+
gc_heap_threshold: Electric.StackConfig.lookup(stack_id, :consumer_gc_heap_threshold, nil),
110117
buffering?: true
111118
}
112119
end

packages/sync-service/lib/electric/stack_config.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ defmodule Electric.StackConfig do
3333
shape_suspend_after: Electric.Config.default(:shape_suspend_after),
3434
chunk_bytes_threshold: Electric.ShapeCache.LogChunker.default_chunk_size_threshold(),
3535
feature_flags: [],
36-
process_spawn_opts: %{}
36+
process_spawn_opts: %{},
37+
consumer_gc_heap_threshold: Electric.Config.default(:consumer_gc_heap_threshold)
3738
]
3839
end
3940

packages/sync-service/lib/electric/stack_supervisor.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ defmodule Electric.StackSupervisor do
154154
default: nil
155155
],
156156
process_spawn_opts: [type: :map, default: %{}],
157+
consumer_gc_heap_threshold: [
158+
type: {:or, [:non_neg_integer, nil]},
159+
default: Electric.Config.default(:consumer_gc_heap_threshold)
160+
],
157161
consumer_partitions: [type: {:or, [:pos_integer, nil]}, default: nil]
158162
]
159163
],
@@ -357,6 +361,7 @@ defmodule Electric.StackSupervisor do
357361
shape_enable_suspend? = Keyword.fetch!(config.tweaks, :shape_enable_suspend?)
358362
shape_suspend_after = Keyword.fetch!(config.tweaks, :shape_suspend_after)
359363
process_spawn_opts = Keyword.fetch!(config.tweaks, :process_spawn_opts)
364+
consumer_gc_heap_threshold = Keyword.fetch!(config.tweaks, :consumer_gc_heap_threshold)
360365

361366
shape_cache_opts = [
362367
stack_id: stack_id
@@ -407,6 +412,7 @@ defmodule Electric.StackSupervisor do
407412
shape_enable_suspend?: shape_enable_suspend?,
408413
shape_suspend_after: shape_suspend_after,
409414
process_spawn_opts: process_spawn_opts,
415+
consumer_gc_heap_threshold: consumer_gc_heap_threshold,
410416
feature_flags: Map.get(config, :feature_flags, [])
411417
]},
412418
{Electric.AsyncDeleter,

0 commit comments

Comments
 (0)