Skip to content

Commit 63f4107

Browse files
alcoclaude
andauthored
feat(sync-service): hibernate before suspend to enable GC (#4284)
## Summary When consumer suspend is enabled, consumers now hibernate first (triggering GC) before eventually suspending (terminating). Previously, consumers went directly to suspend without hibernating, and due to a large timeout for suspend, consumer processes could hold onto garbage memory for longer than necessary. - Add `shape_suspend_after` config (default 10 minutes) - delay between hibernation and suspension - Consumer hibernates on `hibernate_after` timeout, schedules suspend timer - Suspend timer fires after `suspend_after` ms, terminates process if still idle - Any activity cancels the pending suspend timer, restarting the cycle - Update `ConsumerRegistry.enable_suspend/4` to include suspend_after parameter 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 683cfae commit 63f4107

12 files changed

Lines changed: 280 additions & 68 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@core/sync-service': minor
3+
---
4+
5+
Add hibernate-then-suspend behavior for Consumer processes. When suspend is enabled, consumers now hibernate first (triggering GC) before suspending. Adds `shape_suspend_after` config to control the delay between hibernation and suspension. Any activity cancels the pending suspend timer, restarting the cycle.

integration-tests/tests/shape-suspension-resumption.lux

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
# Start Electric and wait for it to finish initialization.
2222
# Set a very small hibernation timeout so consumers will shutdown quickly
23-
[invoke setup_electric_with_env "ELECTRIC_SHAPE_HIBERNATE_AFTER=200ms ELECTRIC_SHAPE_SUSPEND_CONSUMER=true"]
23+
[invoke setup_electric_with_env "ELECTRIC_SHAPE_HIBERNATE_AFTER=200ms ELECTRIC_SHAPE_SUSPEND_AFTER=200ms ELECTRIC_SHAPE_SUSPEND_CONSUMER=true"]
2424
[shell electric]
2525
[timeout 10]
2626
??[debug] Replication client started streaming

packages/sync-service/config/runtime.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ shape_hibernate_after =
170170

171171
shape_enable_suspend? = env!("ELECTRIC_SHAPE_SUSPEND_CONSUMER", :boolean, nil)
172172

173+
shape_suspend_after =
174+
env!("ELECTRIC_SHAPE_SUSPEND_AFTER", &Electric.Config.parse_human_readable_time!/1, nil)
175+
173176
system_metrics_poll_interval =
174177
env!(
175178
"ELECTRIC_SYSTEM_METRICS_POLL_INTERVAL",
@@ -270,6 +273,7 @@ config :electric,
270273
env!("ELECTRIC_SUBQUERY_BUFFER_MAX_TRANSACTIONS", :integer, nil),
271274
shape_hibernate_after: shape_hibernate_after,
272275
shape_enable_suspend?: shape_enable_suspend?,
276+
shape_suspend_after: shape_suspend_after,
273277
storage_dir: storage_dir,
274278
storage: storage_spec,
275279
cleanup_interval_ms:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ defmodule Electric.Application do
153153
cleanup_interval_ms: get_env(opts, :cleanup_interval_ms),
154154
shape_hibernate_after: get_env(opts, :shape_hibernate_after),
155155
shape_enable_suspend?: get_env(opts, :shape_enable_suspend?),
156+
shape_suspend_after: get_env(opts, :shape_suspend_after),
156157
conn_max_requests: get_env(opts, :conn_max_requests),
157158
handler_fullsweep_after: get_env(opts, :handler_fullsweep_after),
158159
process_spawn_opts: get_env(opts, :process_spawn_opts)

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,15 @@ defmodule Electric.Config do
8585
otel_sampling_ratio: 0.01,
8686
metrics_sampling_ratio: 1,
8787
## Memory
88+
# After this duration of inactivity, consumer processes will hibernate
89+
# to allow garbage collection
8890
shape_hibernate_after: :timer.seconds(30),
89-
# Should we terminate consumer processes after `shape_hibernate_after` ms
90-
# or just hibernate them?
91+
# If enabled, terminate (suspend) consumer processes after hibernating.
92+
# This frees memory more aggressively than hibernation alone.
9193
shape_enable_suspend?: false,
94+
# After hibernating, wait this duration before suspending (terminating).
95+
# Only applies when shape_enable_suspend? is true.
96+
shape_suspend_after: :timer.minutes(10),
9297
# Sets max_requests for Bandit handler processes:
9398
# https://hexdocs.pm/bandit/Bandit.html#t:http_1_options/0
9499
# "The maximum number of requests to serve in a single HTTP/{1,2}

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

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ defmodule Electric.Shapes.Consumer do
175175
end
176176

177177
@impl GenServer
178+
# Any incoming message counts as activity: cancel the pending suspend timer (if
179+
# any) and recurse for actual handling of the call.
180+
def handle_call(msg, from, %{suspend_timer: ref} = state) when not is_nil(ref) do
181+
handle_call(msg, from, cancel_suspend_timer(state))
182+
end
183+
178184
def handle_call({:monitor, pid}, _from, %{monitors: monitors} = state) do
179185
ref = make_ref()
180186
{:reply, ref, %{state | monitors: [{pid, ref} | monitors]}, state.hibernate_after}
@@ -186,8 +192,8 @@ defmodule Electric.Shapes.Consumer do
186192

187193
def handle_call(:await_snapshot_start, from, state) do
188194
Logger.debug("Starting a wait on the snapshot #{state.shape_handle} for #{inspect(from)}}")
189-
190-
{:noreply, State.add_waiter(state, from), state.hibernate_after}
195+
state = State.add_waiter(state, from)
196+
{:noreply, state, state.hibernate_after}
191197
end
192198

193199
def handle_call({:handle_event, event, trace_context}, _from, state) do
@@ -205,9 +211,8 @@ defmodule Electric.Shapes.Consumer do
205211
def handle_call({:subscribe_materializer, pid}, _from, state) do
206212
Logger.debug("Subscribing materializer for #{state.shape_handle}")
207213
Process.monitor(pid, tag: :materializer_down)
208-
209-
{:reply, {:ok, state.latest_offset}, %{state | materializer_subscribed?: true},
210-
state.hibernate_after}
214+
state = %{state | materializer_subscribed?: true}
215+
{:reply, {:ok, state.latest_offset}, state, state.hibernate_after}
211216
end
212217

213218
def handle_call({:stop, reason}, _from, state) do
@@ -216,6 +221,11 @@ defmodule Electric.Shapes.Consumer do
216221
end
217222

218223
@impl GenServer
224+
# Cancel the suspend timer on activity, then recurse for the actual handling of the cast.
225+
def handle_cast(msg, %{suspend_timer: ref} = state) when not is_nil(ref) do
226+
handle_cast(msg, cancel_suspend_timer(state))
227+
end
228+
219229
def handle_cast(
220230
{:pg_snapshot_known, shape_handle, {xmin, xmax, xip_list} = snapshot},
221231
%{shape_handle: shape_handle} = state
@@ -253,6 +263,29 @@ defmodule Electric.Shapes.Consumer do
253263
end
254264

255265
@impl GenServer
266+
def handle_info(:suspend_timeout, %{suspend_timer: ref} = state) when not is_nil(ref) do
267+
state = %{state | suspend_timer: nil}
268+
269+
if consumer_suspend_enabled?(state) and consumer_can_suspend?(state) do
270+
Logger.debug(fn -> ["Suspending consumer ", to_string(state.shape_handle)] end)
271+
{:stop, ShapeCleaner.consumer_suspend_reason(), state}
272+
else
273+
# Conditions changed - just restart the hibernate timeout
274+
{:noreply, state, state.hibernate_after}
275+
end
276+
end
277+
278+
# Timer already cancelled. Ignore the trigger.
279+
def handle_info(:suspend_timeout, state) do
280+
{:noreply, state, state.hibernate_after}
281+
end
282+
283+
# Any incoming message counts as activity: cancel the pending suspend timer (if any)
284+
# and recurse for the actual handling of the message.
285+
def handle_info(msg, %{suspend_timer: ref} = state) when not is_nil(ref) do
286+
handle_info(msg, cancel_suspend_timer(state))
287+
end
288+
256289
def handle_info({:initialize_shape, shape, opts}, state) do
257290
%{stack_id: stack_id, shape_handle: shape_handle} = state
258291

@@ -383,32 +416,24 @@ defmodule Electric.Shapes.Consumer do
383416
{:stop, reason, state}
384417
end
385418

386-
# Set a new value for hibernate after and set a timeout between
387-
# hibernate_after and max_timeout in order to spread
388-
# consumer suspend events.
389-
def handle_info({:configure_suspend, hibernate_after, jitter_period}, state) do
390-
{:noreply, %{state | hibernate_after: hibernate_after},
391-
Enum.random(hibernate_after..jitter_period)}
419+
# Set new values for hibernate_after and suspend_after, and set a jittered
420+
# timeout between hibernate_after and jitter_period to spread hibernation
421+
# events. Each consumer will hibernate at the jittered timeout, then schedule
422+
# suspension for suspend_after ms later.
423+
def handle_info({:configure_suspend, hibernate_after, suspend_after, jitter_period}, state) do
424+
state = %{state | hibernate_after: hibernate_after, suspend_after: suspend_after}
425+
{:noreply, state, Enum.random(hibernate_after..jitter_period)}
392426
end
393427

394428
def handle_info(:timeout, state) do
395-
# we can only suspend (terminate) the consumer process if
396-
#
397-
# 1. Consumer suspend has been enabled in the stack config
398-
# 2. we're not waiting for snapshot information
399-
# 3. we are not part of a subquery dependency tree, that is either
400-
# a. we have no dependent shapes
401-
# b. we don't have a materializer subscribed
402-
# 4. we're not in the middle of processing a multi-fragment transaction
429+
state = %{state | writer: ShapeCache.Storage.hibernate(state.writer)}
403430

404-
if consumer_suspend_enabled?(state) and consumer_can_suspend?(state) do
405-
Logger.debug(fn -> ["Suspending consumer ", to_string(state.shape_handle)] end)
406-
{:stop, ShapeCleaner.consumer_suspend_reason(), state}
407-
else
408-
state = %{state | writer: ShapeCache.Storage.hibernate(state.writer)}
431+
state =
432+
if consumer_suspend_enabled?(state) and consumer_can_suspend?(state),
433+
do: schedule_suspend_timer(state),
434+
else: state
409435

410-
{:noreply, state, :hibernate}
411-
end
436+
{:noreply, state, :hibernate}
412437
end
413438

414439
defp consumer_suspend_enabled?(%{stack_id: stack_id}) do
@@ -420,6 +445,20 @@ defmodule Electric.Shapes.Consumer do
420445
not state.materializer_subscribed? and is_nil(state.pending_txn)
421446
end
422447

448+
defp schedule_suspend_timer(%{suspend_after: nil} = state), do: state
449+
450+
defp schedule_suspend_timer(%{suspend_after: suspend_after} = state) do
451+
ref = :erlang.send_after(suspend_after, self(), :suspend_timeout)
452+
%{state | suspend_timer: ref}
453+
end
454+
455+
defp cancel_suspend_timer(%{suspend_timer: nil} = state), do: state
456+
457+
defp cancel_suspend_timer(%{suspend_timer: ref} = state) do
458+
:erlang.cancel_timer(ref)
459+
%{state | suspend_timer: nil}
460+
end
461+
423462
@impl GenServer
424463
def terminate(reason, state) do
425464
Logger.debug(fn ->

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ defmodule Electric.Shapes.Consumer.State do
4141
# When a {Storage, :flushed, offset} message arrives during a pending
4242
# transaction, we defer the notification and store the max flushed offset
4343
# here. Multiple deferred notifications are collapsed into a single most recent offset.
44-
pending_flush_offset: nil
44+
pending_flush_offset: nil,
45+
# Reference of the pending suspend timer, or nil if none is armed. The timer
46+
# is armed when the consumer settles into hibernation and is cancelled as soon
47+
# as any message arrives (activity), so at most one is ever live at a time.
48+
suspend_timer: nil,
49+
# How long after hibernation to suspend (in ms)
50+
suspend_after: nil
4551
]
4652

4753
@type pg_snapshot() :: SnapshotQuery.pg_snapshot()
@@ -95,6 +101,12 @@ defmodule Electric.Shapes.Consumer.State do
95101
:shape_hibernate_after,
96102
Electric.Config.default(:shape_hibernate_after)
97103
),
104+
suspend_after:
105+
Electric.StackConfig.lookup(
106+
stack_id,
107+
:shape_suspend_after,
108+
Electric.Config.default(:shape_suspend_after)
109+
),
98110
buffering?: true
99111
}
100112
end

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -217,33 +217,35 @@ defmodule Electric.Shapes.ConsumerRegistry do
217217
disabled, because the configuration message will have the side-effect of
218218
waking all consumers from hibernation.
219219
220-
The `jitter_period` value allows for spreading the suspension of existing
221-
consumers over a large time period to avoid a sudden rush of consumer
222-
shutdowns after `hibernate_after` ms.
220+
The `jitter_period` value allows for spreading the hibernation of existing
221+
consumers over a time period to avoid a sudden rush of hibernation events.
222+
Each consumer picks a random timeout between `hibernate_after` and `jitter_period`,
223+
then hibernates and schedules suspension for `suspend_after` ms later.
223224
224225
To re-enable consumer suspend:
225226
226-
# set the hibernation timeout to 1 minute but phase the suspension of
227-
# existing consumers over a 20 minute period
228-
Electric.Shapes.ConsumerRegistry.enable_suspend(stack_id, 60_000, 60_000 * 20)
227+
# hibernation timeout: 1 min, suspend timeout: 4 min, jitter window: 20 min
228+
# Consumers will hibernate between 1-20 min, then suspend 4 min after hibernating
229+
Electric.Shapes.ConsumerRegistry.enable_suspend(stack_id, 60_000, 4 * 60_000, 60_000 * 20)
229230
230231
Disabling suspension is as easy as:
231232
232233
Electric.StackConfig.put(stack_id, :shape_enable_suspend?, false)
233234
234235
"""
235-
@spec enable_suspend(stack_id(), pos_integer(), pos_integer()) ::
236+
@spec enable_suspend(stack_id(), pos_integer(), pos_integer(), pos_integer()) ::
236237
consumer_count :: non_neg_integer()
237-
def enable_suspend(stack_id, hibernate_after, jitter_period)
238-
when is_integer(hibernate_after) and is_integer(jitter_period) and
239-
jitter_period > hibernate_after do
238+
def enable_suspend(stack_id, hibernate_after, suspend_after, jitter_period)
239+
when is_integer(hibernate_after) and is_integer(suspend_after) and
240+
is_integer(jitter_period) and jitter_period > hibernate_after do
240241
Electric.StackConfig.put(stack_id, :shape_hibernate_after, hibernate_after)
242+
Electric.StackConfig.put(stack_id, :shape_suspend_after, suspend_after)
241243
Electric.StackConfig.put(stack_id, :shape_enable_suspend?, true)
242244

243245
:ets.foldl(
244246
fn {_shape_handle, pid}, n ->
245247
if Process.alive?(pid),
246-
do: send(pid, {:configure_suspend, hibernate_after, jitter_period})
248+
do: send(pid, {:configure_suspend, hibernate_after, suspend_after, jitter_period})
247249

248250
n + 1
249251
end,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ defmodule Electric.StackConfig do
3030
snapshot_timeout_to_first_data: :timer.seconds(30),
3131
shape_hibernate_after: Electric.Config.default(:shape_hibernate_after),
3232
shape_enable_suspend?: Electric.Config.default(:shape_enable_suspend?),
33+
shape_suspend_after: Electric.Config.default(:shape_suspend_after),
3334
chunk_bytes_threshold: Electric.ShapeCache.LogChunker.default_chunk_size_threshold(),
3435
feature_flags: [],
3536
process_spawn_opts: %{}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ defmodule Electric.StackSupervisor do
136136
type: :boolean,
137137
default: Electric.Config.default(:shape_enable_suspend?)
138138
],
139+
shape_suspend_after: [
140+
type: :non_neg_integer,
141+
default: Electric.Config.default(:shape_suspend_after)
142+
],
139143
snapshot_timeout_to_first_data: [
140144
type: :pos_integer,
141145
default: Electric.Config.default(:snapshot_timeout_to_first_data)
@@ -351,6 +355,7 @@ defmodule Electric.StackSupervisor do
351355

352356
shape_hibernate_after = Keyword.fetch!(config.tweaks, :shape_hibernate_after)
353357
shape_enable_suspend? = Keyword.fetch!(config.tweaks, :shape_enable_suspend?)
358+
shape_suspend_after = Keyword.fetch!(config.tweaks, :shape_suspend_after)
354359
process_spawn_opts = Keyword.fetch!(config.tweaks, :process_spawn_opts)
355360

356361
shape_cache_opts = [
@@ -400,6 +405,7 @@ defmodule Electric.StackSupervisor do
400405
inspector: inspector,
401406
shape_hibernate_after: shape_hibernate_after,
402407
shape_enable_suspend?: shape_enable_suspend?,
408+
shape_suspend_after: shape_suspend_after,
403409
process_spawn_opts: process_spawn_opts,
404410
feature_flags: Map.get(config, :feature_flags, [])
405411
]},

0 commit comments

Comments
 (0)