Skip to content

Commit 9f84e60

Browse files
authored
fix(sync-service): drop subquery shapes on restart instead of restoring them (#4735)
## Summary Correctly restoring a subquery shape's on-disk view together with its dependency materializer across a server restart is not yet implemented. Until it is, a restored subquery shape can diverge, producing incorrect results after the server comes back up. So for now this **drops every shape involved in a subquery on restart** — the outer shape and all of its dependency materializers — and lets clients re-request them from scratch. ## How it works Both restore paths in the shape subsystem read the same source of truth, `ShapeStatus`: - `ShapeLogCollector.restore_shapes` rebuilds the routing indexes (starts first) - `ShapeCache.wait_for_restore` starts consumers (starts later) So the subquery shapes are removed **once, from that single source, before either path reads it**: - New `ShapeStatus.prune_subquery_shapes/1` deletes the transitive subquery hierarchy — `[handle | shape_dependencies_handles]` for every shape with a non-empty `shape_dependencies` (nested subqueries are covered automatically: an intermediate dependency that itself has a subquery matches the same filter and contributes its own dependencies) — from shape metadata + on-disk storage. - It is called at the very start of `ShapeLogCollector.restore_shapes` — the first restore path to read `ShapeStatus`, and one that runs on every `:one_for_all` shape-subsystem restart. By the time routing is built or consumers start, the shapes are already gone, so no restore path can reinstate them. - Because no consumer or routing entry exists yet at that point, pruning is a plain metadata + storage delete — no consumer to stop, no routing entry to clear, and no async routing-removal race. The old eager subquery-consumer start in `ShapeCache.wait_for_restore` is removed (there is nothing to eagerly start once these shapes are dropped). The lazy `restore_shape_and_dependencies/3` path (used by `start_consumer_for_handle` and fresh creation) is untouched. ## Test Plan - This PR adds a new CI github action `oracle_property_test_with_restarts` which restarts the server every 3 batches and checks consistency is maintained after: graceful shutdown and restore and brutal shutdown and recovery. <img width="237" height="128" alt="Screenshot 2026-07-27 at 21 48 44" src="https://github.com/user-attachments/assets/a4251a7b-7793-43be-86c8-1ec7f46232bb" /> ## Notes Supersedes the restore path added in #4666 / #4715 for the subquery-restart case. --- Generated with [Claude Code](https://claude.com/claude-code)
1 parent fb06dd3 commit 9f84e60

8 files changed

Lines changed: 166 additions & 321 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': patch
3+
---
4+
5+
Drop shapes that involve subqueries on server restart to prevent consistency issues.

.github/workflows/sync_service_tests.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,55 @@ jobs:
169169

170170
- *upload_test_results_to_codecov
171171

172+
oracle_property_test_with_restarts:
173+
name: 'Oracle property test with restarts (${{ matrix.restart_type }})'
174+
runs-on: blacksmith-4vcpu-ubuntu-2404
175+
defaults:
176+
run:
177+
working-directory: packages/sync-service
178+
strategy:
179+
fail-fast: false
180+
matrix:
181+
restart_type: [graceful, brutal]
182+
env:
183+
MIX_ENV: test
184+
MIX_TARGET: application
185+
POSTGRES_VERSION: '170000'
186+
CODECOV_FLAGS: elixir,oracle-tests,sync-service,postgres-170000
187+
CODECOV_TEST_RESULTS_FILES: ./junit/regular-test-junit-report.xml
188+
CHECK_TIMEOUT: 60000
189+
SHAPE_COUNT: 200
190+
MUTATIONS_PER_TXN: 10
191+
TXNS_PER_BATCH: 10
192+
BATCH_COUNT: 50
193+
SKIP_REPATCH_PREWARM: 'true'
194+
RESTART_SERVER_EVERY: 3
195+
RESTART_TYPE: ${{ matrix.restart_type }}
196+
TEST_POOL_SIZE: 20
197+
services:
198+
postgres:
199+
image: 'ghcr.io/${{ github.repository }}/postgres:17-alpine-logical'
200+
env:
201+
POSTGRES_PASSWORD: password
202+
options: *postgres_health_check
203+
ports:
204+
- 54321:5432
205+
206+
pgbouncer: *pgbouncer_service
207+
steps:
208+
- *checkout_source
209+
- *seed_database
210+
- *setup_beam
211+
- *cache_dependencies
212+
- *cache_compiled_code
213+
- *install_dependencies
214+
- *compile_package
215+
216+
- name: Run oracle property test with ${{ matrix.restart_type }} server restarts
217+
run: mix test --only oracle test/integration/oracle_property_test.exs
218+
219+
- *upload_test_results_to_codecov
220+
172221
performance_test:
173222
name: 'Performance test, pg17'
174223
runs-on: blacksmith-4vcpu-ubuntu-2404

packages/sync-service/lib/electric/replication/shape_log_collector.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,14 @@ defmodule Electric.Replication.ShapeLogCollector do
296296
fn ->
297297
start = System.monotonic_time()
298298

299+
# Restoring subquery shapes consistently across a restart is not yet
300+
# implemented, so for now they are dropped rather than restored and
301+
# clients re-request them. This is the first restore path in the shape
302+
# subsystem to read from ShapeStatus, so pruning them here — before we
303+
# build routing and before the shape consumers start — is the single
304+
# point that keeps every restore path from reinstating a subquery shape.
305+
:ok = Electric.ShapeCache.ShapeStatus.prune_subquery_shapes(state.stack_id)
306+
299307
{partitions, event_router, layers, count} =
300308
state.stack_id
301309
|> Electric.ShapeCache.ShapeStatus.list_shapes()

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

Lines changed: 4 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -286,13 +286,10 @@ defmodule Electric.ShapeCache do
286286

287287
Electric.Replication.PublicationManager.wait_for_restore(state.stack_id)
288288

289-
# Subquery shapes' consumers must be fully initialized before
290-
# ShapeLogCollector starts dispatching events. If events flow first,
291-
# the materializer can advance past the outer shape's on-disk storage;
292-
# the outer consumer's later init would then seed `state.views` from
293-
# the advanced materializer view and a subsequent move-in event for
294-
# a value already in that seeded view would be dropped as redundant.
295-
eagerly_start_subquery_shape_consumers(state)
289+
# Shapes involved in a subquery are dropped rather than restored on restart
290+
# (see `ShapeStatus.prune_subquery_shapes/1`, called from ShapeLogCollector's
291+
# restore before any routing or consumer state is rebuilt), so there is
292+
# nothing to eagerly start here.
296293

297294
# Let ShapeLogCollector that it can start processing after finishing this function so that
298295
# we're subscribed to the producer before it starts forwarding its demand.
@@ -313,61 +310,6 @@ defmodule Electric.ShapeCache do
313310
{:noreply, state}
314311
end
315312

316-
# Shapes whose where clause contains a subquery (`shape_dependencies != []`)
317-
# rely on their materializer subscription to be notified of dependency-side
318-
# changes. The router only delivers events for a shape when its own
319-
# `root_table` changes, so a subquery dependent stays dormant after a
320-
# restart until something writes to its own table — movements driven by
321-
# the dependency (e.g. parent rows becoming active) never reach its
322-
# on-disk view. Restoring it here re-establishes the materializer
323-
# subscription so dependency updates flow in.
324-
#
325-
# `await_snapshot_start/2` is queued *after* the consumer's
326-
# `:initialize_shape` info message, so by the time it returns
327-
# `EventHandlerBuilder.build` has run and `state.views` is seeded.
328-
defp eagerly_start_subquery_shape_consumers(state) do
329-
opts = %{
330-
stack_id: state.stack_id,
331-
action: :restore,
332-
otel_ctx: nil,
333-
feature_flags: state.feature_flags
334-
}
335-
336-
for {handle, %Shape{shape_dependencies: [_ | _]} = shape} <-
337-
ShapeStatus.list_shapes(state.stack_id),
338-
is_nil(Electric.Shapes.ConsumerRegistry.whereis(state.stack_id, handle)) do
339-
case restore_shape_and_dependencies(handle, shape, opts) do
340-
{:ok, _pid} ->
341-
# await_snapshot_start/2 is a GenServer.call into the just-started
342-
# consumer. If that consumer dies before/during the call it exits;
343-
# left unguarded that would propagate out of handle_continue and
344-
# crash ShapeCache before mark_as_ready — turning a single shape
345-
# that reliably fails its snapshot into a stack-wide restart loop.
346-
# A call timeout (the consumer is alive but wedged) exits the same
347-
# way. In either case we can't confirm the shape's consumer came up
348-
# subscribed-and-correct, and the eager start exists precisely to
349-
# guarantee that consistency. Leaving the shape alive-but-unconfirmed
350-
# would silently reintroduce the divergence this restore path fixes,
351-
# so we purge it (mirroring restore_shape_and_dependencies' own
352-
# clean_shape-on-failure) and let the client refetch from scratch.
353-
try do
354-
_ = Electric.Shapes.Consumer.await_snapshot_start(state.stack_id, handle)
355-
catch
356-
:exit, reason ->
357-
Logger.warning(
358-
"Eager subquery consumer await failed for #{handle}: #{inspect(reason)}; " <>
359-
"purging shape to force a clean refetch"
360-
)
361-
362-
clean_shape(handle, state.stack_id)
363-
end
364-
365-
_ ->
366-
:ok
367-
end
368-
end
369-
end
370-
371313
@impl GenServer
372314
def handle_call({:create_or_wait_shape_handle, shape, otel_ctx}, _from, state) do
373315
if not is_nil(otel_ctx), do: OpenTelemetry.set_current_context(otel_ctx)

packages/sync-service/lib/electric/shape_cache/shape_status.ex

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,64 @@ defmodule Electric.ShapeCache.ShapeStatus do
147147
end)
148148
end
149149

150+
@doc """
151+
Given a list of `{handle, shape}` pairs (e.g. from `list_shapes/1`), return the
152+
set of handles for every shape involved in a subquery: each shape with a
153+
non-empty `shape_dependencies` plus all of its dependency handles.
154+
155+
This is the transitive closure of the subquery hierarchy — a nested dependency
156+
that itself has a subquery matches the same filter and contributes its own
157+
dependencies — so it covers outer shapes, intermediate dependencies and leaf
158+
materializers. Used by `prune_subquery_shapes/1`.
159+
"""
160+
@spec subquery_shape_handles([{shape_handle(), Shape.t()}]) :: MapSet.t(shape_handle())
161+
def subquery_shape_handles(handles_and_shapes) do
162+
for {handle, %Shape{shape_dependencies: [_ | _]} = shape} <- handles_and_shapes,
163+
h <- [handle | shape.shape_dependencies_handles],
164+
into: MapSet.new(),
165+
do: h
166+
end
167+
168+
@doc """
169+
Remove every shape involved in a subquery (the outer shape plus its dependency
170+
materializers) from shape metadata and on-disk storage.
171+
172+
Correctly restoring a subquery shape's on-disk view together with its
173+
dependency materializer across a restart is not yet implemented, so for now we
174+
drop every subquery shape on restart and let clients re-request them from
175+
scratch. This is called once at the start of the shape subsystem's restore —
176+
from `ShapeLogCollector`'s `restore_shapes`, before it (or the shape consumers)
177+
rebuild any state from `list_shapes/1` — so no restore path ever reinstates a
178+
subquery shape. At that point no consumer or routing entry exists for these
179+
shapes yet, so a direct metadata + storage delete is sufficient; there is no
180+
consumer to stop or routing entry to clear.
181+
"""
182+
@spec prune_subquery_shapes(stack_id()) :: :ok
183+
def prune_subquery_shapes(stack_id) when is_stack_id(stack_id) do
184+
handles =
185+
stack_id
186+
|> list_shapes()
187+
|> subquery_shape_handles()
188+
189+
unless Enum.empty?(handles) do
190+
Logger.notice(
191+
"Dropping #{MapSet.size(handles)} shape(s) involved in subqueries on restart; " <>
192+
"clients will re-request them from scratch"
193+
)
194+
195+
stack_storage = Electric.ShapeCache.Storage.for_stack(stack_id)
196+
197+
for handle <- handles do
198+
case remove_shape(stack_id, handle) do
199+
:ok -> Electric.ShapeCache.Storage.cleanup!(stack_storage, handle)
200+
{:error, _reason} -> :ok
201+
end
202+
end
203+
end
204+
205+
:ok
206+
end
207+
150208
@spec topological_sort([{shape_handle(), Shape.t()}]) :: [{shape_handle(), Shape.t()}]
151209
defp topological_sort(handles_and_shapes, acc \\ [], visited \\ MapSet.new())
152210
defp topological_sort([], acc, _visited), do: Enum.reverse(acc) |> List.flatten()

packages/sync-service/test/electric/replication/shape_log_collector_test.exs

Lines changed: 5 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,6 @@ defmodule Electric.Replication.ShapeLogCollectorTest do
4646
@shape Shape.new!("test_table", inspector: @inspector)
4747
@shape_handle "the-shape-handle"
4848

49-
@subquery_inspector Support.StubInspector.new(
50-
tables: [{1234, {"public", "test_table"}}, {5678, {"public", "parent"}}],
51-
columns: [%{name: "id", type: "int8", type_id: {20, 1}, pk_position: 0}]
52-
)
53-
@subquery_shape Shape.new!("test_table",
54-
inspector: @subquery_inspector,
55-
where: "id IN (SELECT id FROM public.parent)"
56-
)
57-
@subquery_shape_handle "subquery-shape-handle"
58-
5949
def setup_log_collector(ctx) do
6050
%{stack_id: stack_id} = ctx
6151
# Start a test Registry
@@ -235,60 +225,11 @@ defmodule Electric.Replication.ShapeLogCollectorTest do
235225
assert xids == [xmin]
236226
end
237227

238-
@tag restore_shapes: [{@subquery_shape_handle, @subquery_shape}],
239-
inspector: @subquery_inspector
240-
test "restored subquery shape routes via fallback before consumer seeds index", ctx do
241-
alias Electric.Shapes.Filter.Indexes.SubqueryIndex
242-
243-
# After restore, the subquery shape should be in fallback because
244-
# no consumer has seeded the SubqueryIndex yet.
245-
index = SubqueryIndex.for_stack(ctx.stack_id)
246-
assert index != nil
247-
assert SubqueryIndex.fallback?(index, @subquery_shape_handle)
248-
249-
parent = self()
250-
251-
consumer =
252-
start_link_supervised!(
253-
{Support.TransactionConsumer,
254-
[
255-
id: 1,
256-
stack_id: ctx.stack_id,
257-
parent: parent,
258-
shape: @subquery_shape,
259-
shape_handle: @subquery_shape_handle,
260-
stack_id: ctx.stack_id,
261-
action: :restore
262-
]}
263-
)
264-
265-
:ok =
266-
Electric.Shapes.ConsumerRegistry.register_consumer(
267-
consumer,
268-
@subquery_shape_handle,
269-
ctx.stack_id
270-
)
271-
272-
xmin = 100
273-
lsn = Lsn.from_string("0/10")
274-
last_log_offset = LogOffset.new(lsn, 0)
275-
276-
# Any root-table change should route to the shape via fallback,
277-
# even if the record wouldn't match the subquery membership.
278-
txn =
279-
complete_txn_fragment(xmin, lsn, [
280-
%Changes.NewRecord{
281-
relation: {"public", "test_table"},
282-
record: %{"id" => "999"},
283-
log_offset: last_log_offset
284-
}
285-
])
286-
287-
assert :ok = ShapeLogCollector.handle_event(txn, ctx.stack_id)
288-
289-
xids = Support.TransactionConsumer.assert_consume([{1, consumer}], [txn])
290-
assert xids == [xmin]
291-
end
228+
# Subquery shapes are pruned (not restored) at the start of the collector's
229+
# restore via `ShapeStatus.prune_subquery_shapes/1`. Its effect — the subquery
230+
# hierarchy absent from both `ShapeCache.list_shapes/1` and `active_shapes/1`
231+
# after a real restart, while plain shapes are retained — is covered by the
232+
# "after restart" tests in `shape_cache_test.exs`.
292233

293234
@tag restore_shapes: [{@shape_handle, @shape}, {@shape_handle <> "-2", @shape}],
294235
inspector: @inspector

0 commit comments

Comments
 (0)