Skip to content

Commit 6aef3c7

Browse files
robacourtclaude
andcommitted
Prevent shape removal during restart (bug 6)
Bug 6 surfaced as a 409 (must-refetch) on a healthy subquery shape after a StackSupervisor restart, with a deterministic regression test that fails without the fix: - consumer.ex: notify_materializer_of_new_changes/3 now catches the :exit (:noproc / transient :normal/:shutdown) from its inline materializer call. Previously, when the materializer died mid-call during shutdown the consumer crashed with a non-shutdown reason, routed through handle_writer_termination, and removed the shape from disk — leaving it half-removed (the SLC was already gone) and 409ing after restart. The pending :DOWN now drives a clean stop instead. This is the part of the fix battle-tested in rob/restore-subqueries-bug-5. The earlier speculative ArgumentError fallbacks in shape_status.ex and api.ex turned out not to be needed and have been dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a8b48c commit 6aef3c7

3 files changed

Lines changed: 77 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@core/sync-service': patch
3+
---
4+
5+
Stop subquery shapes from being spuriously removed during a server restart. When
6+
a dependency consumer's inline call to its materializer raced the materializer's
7+
shutdown, the resulting `:noproc` exit crashed the consumer and removed the shape
8+
from disk, causing a `409 must-refetch` after the restart. The consumer now
9+
absorbs that exit and lets the monitored `:DOWN` drive a clean stop.

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,18 @@ defmodule Electric.Shapes.Consumer do
10631063
opts
10641064
) do
10651065
Materializer.new_changes(Map.take(state, [:stack_id, :shape_handle]), changes_or_bounds, opts)
1066+
catch
1067+
# The consumer monitors the materializer; if the materializer died the
1068+
# :DOWN message is already in our mailbox and handle_materializer_down/2
1069+
# will run after the current handle_event/handle_call completes.
1070+
# Treat a `:noproc` (or transient `:normal`/`:shutdown` exit) here as
1071+
# the same condition: don't crash the consumer (which would route into
1072+
# the abnormal-shutdown path of handle_writer_termination and remove
1073+
# the shape from disk).
1074+
:exit, {:noproc, _} -> :ok
1075+
:exit, :noproc -> :ok
1076+
:exit, {:normal, _} -> :ok
1077+
:exit, {:shutdown, _} -> :ok
10661078
end
10671079

10681080
defp notify_materializer_of_new_changes(_state, _changes_or_bounds, _opts), do: :ok

packages/sync-service/test/electric/shapes/consumer_test.exs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2760,6 +2760,62 @@ defmodule Electric.Shapes.ConsumerTest do
27602760
# After cleanup, the shape's rows should be removed from the index
27612761
refute SubqueryIndex.has_positions?(index, shape_handle)
27622762
end
2763+
2764+
test "dependency consumer survives a :noproc from its materializer without removing the shape",
2765+
ctx do
2766+
# During a stack shutdown, a dependency consumer's inline call into its
2767+
# materializer can race the materializer's death and exit with :noproc.
2768+
# notify_materializer_of_new_changes/3 absorbs that exit so the pending
2769+
# monitored :DOWN drives a clean stop, rather than the consumer exiting
2770+
# with a non-shutdown reason that routes through handle_writer_termination
2771+
# and removes the shape from disk.
2772+
2773+
# Make the dependency consumer's notification call into the materializer
2774+
# exit exactly as a GenServer.call to an already-dead process would.
2775+
Repatch.patch(Consumer.Materializer, :new_changes, [mode: :shared], fn _, _, _ ->
2776+
exit({:noproc, {GenServer, :call, [:materializer, :new_changes, 5000]}})
2777+
end)
2778+
2779+
Support.TestUtils.activate_mocks_for_descendant_procs(Consumer)
2780+
2781+
# The consumer must stay alive and never remove the shape, so fail
2782+
# loudly if remove_shape is called.
2783+
patch_shape_status(
2784+
remove_shape: fn _, handle ->
2785+
raise "Unexpected remove_shape for #{handle}"
2786+
end
2787+
)
2788+
2789+
{shape_handle, _} =
2790+
ShapeCache.get_or_create_shape_handle(@shape_with_subquery, ctx.stack_id)
2791+
2792+
:started = ShapeCache.await_snapshot_start(shape_handle, ctx.stack_id)
2793+
2794+
{:ok, shape} = Electric.Shapes.fetch_shape_by_handle(ctx.stack_id, shape_handle)
2795+
[dep_handle] = shape.shape_dependencies_handles
2796+
2797+
dep_consumer = Consumer.whereis(ctx.stack_id, dep_handle)
2798+
assert is_pid(dep_consumer)
2799+
ref = Process.monitor(dep_consumer)
2800+
2801+
# A change to the dependency table makes the dependency consumer notify
2802+
# its materializer — hitting the patched, exiting call.
2803+
ShapeLogCollector.handle_event(
2804+
complete_txn_fragment(100, Lsn.from_integer(50), [
2805+
%Changes.NewRecord{
2806+
relation: {"public", "other_table"},
2807+
record: %{"id" => "1"},
2808+
log_offset: LogOffset.new(Lsn.from_integer(50), 0)
2809+
}
2810+
]),
2811+
ctx.stack_id
2812+
)
2813+
2814+
# The dependency consumer absorbs the :noproc, stays alive, and the
2815+
# shape is not removed.
2816+
refute_receive {:DOWN, ^ref, :process, _, _}, 500
2817+
assert Consumer.whereis(ctx.stack_id, dep_handle) == dep_consumer
2818+
end
27632819
end
27642820

27652821
defp refute_storage_calls_for_txn_fragment(shape_handle) do

0 commit comments

Comments
 (0)