Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/restore-shutdown-shape-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@core/sync-service': patch
---

Stop subquery shapes from being spuriously removed during a server restart. When
a dependency consumer's inline call to its materializer raced the materializer's
shutdown, the resulting `:noproc` exit crashed the consumer and removed the shape
from disk, causing a `409 must-refetch` after the restart. The consumer now
absorbs that exit and lets the monitored `:DOWN` drive a clean stop.
9 changes: 9 additions & 0 deletions .changeset/subquery-move-replay-on-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@core/sync-service': patch
---

Fix optimized streaming subquery shapes losing dependency move-ins/move-outs
across a graceful server restart. On restart the dependency materializer now
replays the moves each outer consumer missed (deduplicated by a persisted
per-dependency source-LSN position), so the outer shape catches up instead of
diverging from Postgres.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ defmodule Electric.ShapeCache.InMemoryStorage do
@snapshot_start_index 0
@snapshot_end_index :end
@pg_snapshot_key :pg_snapshot
@move_positions_key :move_positions
@latest_offset_key :latest_offset

defstruct [
Expand Down Expand Up @@ -127,6 +128,20 @@ defmodule Electric.ShapeCache.InMemoryStorage do
:ok
end

@impl Electric.ShapeCache.Storage
def set_move_positions!(move_positions, %MS{} = opts) do
:ets.insert(opts.snapshot_table, {@move_positions_key, move_positions})
:ok
end

@impl Electric.ShapeCache.Storage
def fetch_move_positions(%MS{} = opts) do
case :ets.lookup(opts.snapshot_table, @move_positions_key) do
[{@move_positions_key, move_positions}] -> {:ok, move_positions}
[] -> {:ok, %{}}
end
end

@impl Electric.ShapeCache.Storage
def get_all_stored_shape_handles(_opts), do: {:ok, MapSet.new()}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ defmodule Electric.ShapeCache.PureFileStorage do
:last_persisted_txn_offset,
:snapshot_started?,
:pg_snapshot,
:move_positions,
:last_snapshot_chunk,
:compaction_started?,
:compaction_boundary
Expand Down Expand Up @@ -464,6 +465,18 @@ defmodule Electric.ShapeCache.PureFileStorage do
{:ok, read_cached_metadata(opts, :pg_snapshot)}
end

# move_positions is written only when an outer subquery consumer applies a
# dependency move and read only at consumer startup, so it is persisted
# directly to disk (term-encoded) rather than through the ETS metadata cache.
def set_move_positions!(move_positions, %__MODULE__{} = opts) do
write_metadata!(opts, :move_positions, move_positions)
:ok
end

def fetch_move_positions(%__MODULE__{} = opts) do
{:ok, read_metadata!(opts, :move_positions) || %{}}
end

defp read_latest_offset(%__MODULE__{} = opts) do
read_multiple_cached_metadata(opts, [
:last_seen_txn_offset,
Expand Down
27 changes: 27 additions & 0 deletions packages/sync-service/lib/electric/shape_cache/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ defmodule Electric.ShapeCache.Storage do
filter_txns?: boolean()
}
@type offset :: LogOffset.t()
@typedoc """
Per-dependency "moves-applied-up-to" source LSN positions for an outer
subquery consumer, keyed by the dependency's shape handle.
"""
@type move_positions :: %{shape_handle() => LogOffset.t()}

@type compiled_opts :: term()
@type shape_opts :: term()
Expand Down Expand Up @@ -77,6 +82,18 @@ defmodule Electric.ShapeCache.Storage do

@callback set_pg_snapshot(pg_snapshot(), shape_opts()) :: :ok

@doc """
Persist the per-dependency moves-applied-up-to positions for an outer
subquery consumer.
"""
@callback set_move_positions!(move_positions(), shape_opts()) :: :ok

@doc """
Fetch the per-dependency moves-applied-up-to positions for an outer subquery
consumer. Returns `{:ok, %{}}` when none have been persisted yet.
"""
@callback fetch_move_positions(shape_opts()) :: {:ok, move_positions()} | {:error, term()}

@doc "Check if snapshot for a given shape handle already exists"
@callback snapshot_started?(shape_opts()) :: boolean()

Expand Down Expand Up @@ -320,6 +337,16 @@ defmodule Electric.ShapeCache.Storage do
mod.set_pg_snapshot(pg_snapshot, shape_opts)
end

@impl __MODULE__
def set_move_positions!(move_positions, {mod, shape_opts}) do
mod.set_move_positions!(move_positions, shape_opts)
end

@impl __MODULE__
def fetch_move_positions({mod, shape_opts}) do
mod.fetch_move_positions(shape_opts)
end

@impl __MODULE__
def snapshot_started?({mod, shape_opts}) do
mod.snapshot_started?(shape_opts)
Expand Down
Loading
Loading