Skip to content

Commit 7cfa58d

Browse files
robacourtclaude
andauthored
feat(sync-service): add txids to move-in/move-out control messages (#4304)
## Summary Move-in/move-out control messages now carry `headers.txids`, mirroring the per-row `headers.txids` already emitted on insert/update/delete log entries. The list contains the upstream Postgres xid(s) whose commit caused the dependency boundary to flip. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0333175 commit 7cfa58d

13 files changed

Lines changed: 208 additions & 71 deletions

File tree

‎.changeset/move-message-txids.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+
Add `txids` to `move-in`/`move-out` control messages, mirroring the per-row `headers.txids` already emitted on insert/update/delete log entries. The list contains the upstream Postgres xid(s) whose commit caused the dependency boundary to flip.

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

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ defmodule Electric.Shapes.Consumer do
308308
end
309309

310310
def handle_info(
311-
{:materializer_changes, dep_handle, %{move_in: move_in, move_out: move_out}},
311+
{:materializer_changes, dep_handle, %{move_in: move_in, move_out: move_out} = payload},
312312
state
313313
) do
314314
Logger.debug(fn ->
@@ -317,10 +317,7 @@ defmodule Electric.Shapes.Consumer do
317317

318318
handle_apply_event_result(
319319
state,
320-
apply_event(
321-
state,
322-
{:materializer_changes, dep_handle, %{move_in: move_in, move_out: move_out}}
323-
)
320+
apply_event(state, {:materializer_changes, dep_handle, payload})
324321
)
325322
end
326323

@@ -649,7 +646,8 @@ defmodule Electric.Shapes.Consumer do
649646
# The Materializer must see all txn changes for correct tracking of move-ins and
650647
# move-outs for the outer shape. The commit=false flag ensure it doesn't yet notify
651648
# outer consumers about those changes.
652-
:ok = notify_materializer_of_new_changes(state, converted_changes, commit: false)
649+
:ok =
650+
notify_materializer_of_new_changes(state, converted_changes, commit: false, xid: xid)
653651

654652
txn =
655653
PendingTxn.update_with_changes(
@@ -720,7 +718,7 @@ defmodule Electric.Shapes.Consumer do
720718
# Signal commit to storage to allow it to advance its internal txn offset
721719
writer = ShapeCache.Storage.signal_txn_commit!(txn.xid, writer)
722720

723-
:ok = notify_new_changes(state, [], state.latest_offset)
721+
:ok = notify_new_changes_with_offset(state, [], state.latest_offset, xid: txn.xid)
724722

725723
lag = calculate_replication_lag(txn_fragment.commit.commit_timestamp)
726724

@@ -805,7 +803,7 @@ defmodule Electric.Shapes.Consumer do
805803

806804
{state, notification, num_changes, total_size} ->
807805
if notification do
808-
:ok = notify_new_changes(state, notification)
806+
:ok = notify_new_changes(state, notification, xid: txn.xid)
809807

810808
OpenTelemetry.add_span_attributes(%{
811809
num_bytes: total_size,
@@ -897,19 +895,21 @@ defmodule Electric.Shapes.Consumer do
897895
mark_for_removal(state)
898896
end
899897

900-
defp notify_new_changes(_state, nil), do: :ok
898+
defp notify_new_changes(state, notification, opts \\ [])
899+
defp notify_new_changes(_state, nil, _opts), do: :ok
901900

902-
defp notify_new_changes(state, {changes, upper_bound}) do
903-
notify_new_changes(state, changes, upper_bound)
901+
defp notify_new_changes(state, {changes, upper_bound}, opts) do
902+
notify_new_changes_with_offset(state, changes, upper_bound, opts)
904903
end
905904

906-
@spec notify_new_changes(
905+
@spec notify_new_changes_with_offset(
907906
state :: map(),
908907
changes_or_bounds :: list(Changes.change()) | {LogOffset.t(), LogOffset.t()},
909-
latest_log_offset :: LogOffset.t()
908+
latest_log_offset :: LogOffset.t(),
909+
opts :: keyword()
910910
) :: :ok
911-
defp notify_new_changes(state, changes_or_bounds, latest_log_offset) do
912-
:ok = notify_materializer_of_new_changes(state, changes_or_bounds)
911+
defp notify_new_changes_with_offset(state, changes_or_bounds, latest_log_offset, opts) do
912+
:ok = notify_materializer_of_new_changes(state, changes_or_bounds, opts)
913913
:ok = notify_clients_of_new_changes(state, latest_log_offset)
914914
end
915915

@@ -937,8 +937,6 @@ defmodule Electric.Shapes.Consumer do
937937
changes_or_bounds :: list(Changes.change()) | {LogOffset.t(), LogOffset.t()},
938938
opts :: keyword()
939939
) :: :ok
940-
defp notify_materializer_of_new_changes(state, changes_or_bounds, opts \\ [])
941-
942940
defp notify_materializer_of_new_changes(
943941
%{materializer_subscribed?: true} = state,
944942
changes_or_bounds,

‎packages/sync-service/lib/electric/shapes/consumer/event_handler/subqueries/buffering.ex‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ defmodule Electric.Shapes.Consumer.EventHandler.Subqueries.Buffering do
3737
%ShapeInfo{} = shape_info,
3838
views,
3939
%MoveQueue{} = queue,
40-
{dep_move_kind, dep_index, values} = move,
40+
{dep_move_kind, dep_index, values, txids} = move,
4141
subquery_ref,
4242
opts \\ []
4343
)
@@ -47,7 +47,7 @@ defmodule Electric.Shapes.Consumer.EventHandler.Subqueries.Buffering do
4747
queue: queue,
4848
active_move:
4949
views
50-
|> ActiveMove.start(dep_index, dep_move_kind, subquery_ref, values)
50+
|> ActiveMove.start(dep_index, dep_move_kind, subquery_ref, values, txids)
5151
|> ActiveMove.carry_latest_seen_lsn(Keyword.get(opts, :latest_seen_lsn))
5252
}
5353

@@ -129,7 +129,8 @@ defmodule Electric.Shapes.Consumer.EventHandler.Subqueries.Buffering do
129129
index_effects =
130130
IndexChanges.effects_for_complete(
131131
state.shape_info.dnf_plan,
132-
{active_move.dep_move_kind, active_move.dep_index, active_move.values},
132+
{active_move.dep_move_kind, active_move.dep_index, active_move.values,
133+
active_move.txids},
133134
active_move.subquery_ref
134135
)
135136

‎packages/sync-service/lib/electric/shapes/consumer/event_handler/subqueries/steady.ex‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ defmodule Electric.Shapes.Consumer.EventHandler.Subqueries.Steady do
7575
nil ->
7676
{:ok, state, effects}
7777

78-
{{dep_move_kind, dep_index, values} = move, queue} ->
78+
{{dep_move_kind, dep_index, values, txids} = move, queue} ->
7979
subquery_ref = RefResolver.ref_from_dep_index!(state.shape_info.ref_resolver, dep_index)
8080
subscription_active? = Keyword.get(opts, :subscription_active?, false)
8181
latest_seen_lsn = Keyword.get(opts, :latest_seen_lsn)
@@ -108,7 +108,7 @@ defmodule Electric.Shapes.Consumer.EventHandler.Subqueries.Steady do
108108
effects =
109109
effects
110110
|> EffectList.append(
111-
MoveBroadcast.effect_for_move_out(dep_index, values, state.shape_info)
111+
MoveBroadcast.effect_for_move_out(dep_index, values, txids, state.shape_info)
112112
)
113113
|> EffectList.append_all(index_effects)
114114

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ defmodule Electric.Shapes.Consumer.Materializer do
4141
:ok
4242
def new_changes(state, changes, opts \\ []) do
4343
commit? = Keyword.get(opts, :commit, true)
44-
GenServer.call(name(state), {:new_changes, changes, commit?}, :infinity)
44+
xid = Keyword.get(opts, :xid)
45+
GenServer.call(name(state), {:new_changes, changes, xid, commit?}, :infinity)
4546
end
4647

4748
def wait_until_ready(state) do
@@ -205,23 +206,23 @@ defmodule Electric.Shapes.Consumer.Materializer do
205206
{:reply, :ok, state}
206207
end
207208

208-
def handle_call({:new_changes, {range_start, range_end}, commit?}, _from, state) do
209+
def handle_call({:new_changes, {range_start, range_end}, xid, commit?}, _from, state) do
209210
stack_storage = Storage.for_stack(state.stack_id)
210211
storage = Storage.for_shape(state.shape_handle, stack_storage)
211212

212213
state =
213214
Storage.get_log_stream(range_start, range_end, storage)
214215
|> decode_json_stream()
215-
|> apply_and_accumulate_events(state)
216+
|> apply_and_accumulate_events(xid, state)
216217
|> maybe_flush_pending_events(commit?)
217218

218219
{:reply, :ok, state}
219220
end
220221

221-
def handle_call({:new_changes, changes, commit?}, _from, state) when is_list(changes) do
222+
def handle_call({:new_changes, changes, xid, commit?}, _from, state) when is_list(changes) do
222223
state =
223224
changes
224-
|> apply_and_accumulate_events(state)
225+
|> apply_and_accumulate_events(xid, state)
225226
|> maybe_flush_pending_events(commit?)
226227

227228
{:reply, :ok, state}
@@ -343,21 +344,21 @@ defmodule Electric.Shapes.Consumer.Materializer do
343344
}
344345
end
345346

346-
%{"headers" => %{"event" => "move-out", "patterns" => patterns}} ->
347+
%{"headers" => %{"event" => "move-out", "patterns" => patterns} = headers} ->
347348
patterns =
348349
Enum.map(patterns, fn %{"pos" => pos, "value" => value} ->
349350
%{pos: pos, value: value}
350351
end)
351352

352-
%{headers: %{event: "move-out", patterns: patterns}}
353+
%{headers: %{event: "move-out", patterns: patterns, txids: Map.get(headers, "txids", [])}}
353354

354-
%{"headers" => %{"event" => "move-in", "patterns" => patterns}} ->
355+
%{"headers" => %{"event" => "move-in", "patterns" => patterns} = headers} ->
355356
patterns =
356357
Enum.map(patterns, fn %{"pos" => pos, "value" => value} ->
357358
%{pos: pos, value: value}
358359
end)
359360

360-
%{headers: %{event: "move-in", patterns: patterns}}
361+
%{headers: %{event: "move-in", patterns: patterns, txids: Map.get(headers, "txids", [])}}
361362
end)
362363
end
363364

@@ -390,16 +391,28 @@ defmodule Electric.Shapes.Consumer.Materializer do
390391
Eval.Env.const_to_pg_string(Eval.Env.new(), value, type)
391392
end
392393

393-
defp apply_and_accumulate_events(changes, state) do
394+
defp apply_and_accumulate_events(changes, xid, state) do
394395
{state, events} = apply_changes(changes, state)
396+
events = with_txids(events, xid)
395397
%{state | pending_events: merge_events(state.pending_events, events)}
396398
end
397399

400+
defp with_txids(events, _xid) when events == %{}, do: events
401+
402+
defp with_txids(events, xid) do
403+
Map.put(events, :txids, xid_set(xid))
404+
end
405+
406+
defp xid_set(nil), do: MapSet.new()
407+
defp xid_set(xid) when is_integer(xid) and xid > 0, do: MapSet.new([xid])
408+
398409
defp maybe_flush_pending_events(state, true) do
399410
events =
400411
cancel_matching_move_events(state.pending_events)
401412

402413
if events != %{} do
414+
events = finalize_txids(events)
415+
403416
for pid <- state.subscribers do
404417
send(pid, {:materializer_changes, state.shape_handle, events})
405418
end
@@ -412,13 +425,19 @@ defmodule Electric.Shapes.Consumer.Materializer do
412425

413426
defp maybe_flush_pending_events(state, _commit?), do: state
414427

428+
defp finalize_txids(events) do
429+
Map.update(events, :txids, [], &Enum.sort(&1))
430+
end
431+
415432
defp merge_events(pending, new) when pending == %{}, do: new
416433
defp merge_events(pending, new) when new == %{}, do: pending
417434

418435
defp merge_events(pending, new) do
419436
%{
420437
move_in: Map.get(new, :move_in, []) ++ Map.get(pending, :move_in, []),
421-
move_out: Map.get(new, :move_out, []) ++ Map.get(pending, :move_out, [])
438+
move_out: Map.get(new, :move_out, []) ++ Map.get(pending, :move_out, []),
439+
txids:
440+
MapSet.union(Map.get(pending, :txids, MapSet.new()), Map.get(new, :txids, MapSet.new()))
422441
}
423442
end
424443

@@ -433,7 +452,11 @@ defmodule Electric.Shapes.Consumer.Materializer do
433452
defp cancel_matching_move_events(events) do
434453
ins = events |> Map.get(:move_in, []) |> Enum.sort_by(fn {v, _} -> v end)
435454
outs = events |> Map.get(:move_out, []) |> Enum.sort_by(fn {v, _} -> v end)
436-
cancel_sorted_pairs(ins, outs, %{move_in: [], move_out: []})
455+
456+
case cancel_sorted_pairs(ins, outs, %{move_in: [], move_out: []}) do
457+
empty when empty == %{} -> empty
458+
result -> Map.put(result, :txids, Map.get(events, :txids, MapSet.new()))
459+
end
437460
end
438461

439462
defp cancel_sorted_pairs([{v, _} | ins], [{v, _} | outs], acc),

‎packages/sync-service/lib/electric/shapes/consumer/subqueries/active_move.ex‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.ActiveMove do
2222
:values,
2323
:views_before_move,
2424
:views_after_move,
25+
txids: [],
2526
snapshot: nil,
2627
move_in_snapshot_name: nil,
2728
move_in_row_count: nil,
@@ -40,6 +41,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.ActiveMove do
4041
values: [move_value()],
4142
views_before_move: Views.t(),
4243
views_after_move: Views.t(),
44+
txids: [non_neg_integer()],
4345
snapshot: {term(), term(), [term()]} | nil,
4446
move_in_snapshot_name: String.t() | nil,
4547
move_in_row_count: non_neg_integer() | nil,
@@ -51,14 +53,22 @@ defmodule Electric.Shapes.Consumer.Subqueries.ActiveMove do
5153
buffered_txns: [Transaction.t()]
5254
}
5355

54-
@spec start(Views.t(), non_neg_integer(), :move_in | :move_out, [String.t()], [move_value()]) ::
55-
t()
56-
def start(views, dep_index, dep_move_kind, subquery_ref, values) when is_map(views) do
56+
@spec start(
57+
Views.t(),
58+
non_neg_integer(),
59+
:move_in | :move_out,
60+
[String.t()],
61+
[move_value()],
62+
[non_neg_integer()]
63+
) :: t()
64+
def start(views, dep_index, dep_move_kind, subquery_ref, values, txids \\ [])
65+
when is_map(views) do
5766
%__MODULE__{
5867
dep_index: dep_index,
5968
dep_move_kind: dep_move_kind,
6069
subquery_ref: subquery_ref,
6170
values: values,
71+
txids: txids,
6272
views_before_move: views,
6373
views_after_move: Views.apply_move(views, subquery_ref, values, dep_move_kind)
6474
}

‎packages/sync-service/lib/electric/shapes/consumer/subqueries/index_changes.ex‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.IndexChanges do
5757
alias Electric.Shapes.Consumer.Effects
5858
alias Electric.Shapes.DnfPlan
5959

60-
@type move :: {:move_in | :move_out, non_neg_integer(), list()}
60+
@type move :: {:move_in | :move_out, non_neg_integer(), list(), [non_neg_integer()]}
6161

6262
@doc """
6363
Returns index effects to apply when a dependency move event starts buffering.
@@ -68,7 +68,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.IndexChanges do
6868
"""
6969
@spec effects_for_buffering(DnfPlan.t(), move(), [String.t()]) ::
7070
[Effects.AddToSubqueryIndex.t() | Effects.RemoveFromSubqueryIndex.t()]
71-
def effects_for_buffering(dnf_plan, {dep_move_kind, dep_index, values}, subquery_ref) do
71+
def effects_for_buffering(dnf_plan, {dep_move_kind, dep_index, values, _txids}, subquery_ref) do
7272
polarity = Map.get(dnf_plan.dependency_polarities, dep_index, :positive)
7373

7474
case {polarity, dep_move_kind} do
@@ -104,7 +104,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.IndexChanges do
104104
"""
105105
@spec effects_for_complete(DnfPlan.t(), move(), [String.t()]) ::
106106
[Effects.AddToSubqueryIndex.t() | Effects.RemoveFromSubqueryIndex.t()]
107-
def effects_for_complete(dnf_plan, {dep_move_kind, dep_index, values}, subquery_ref) do
107+
def effects_for_complete(dnf_plan, {dep_move_kind, dep_index, values, _txids}, subquery_ref) do
108108
polarity = Map.get(dnf_plan.dependency_polarities, dep_index, :positive)
109109

110110
case {polarity, dep_move_kind} do

‎packages/sync-service/lib/electric/shapes/consumer/subqueries/move_broadcast.ex‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.MoveBroadcast do
77
alias Electric.Shapes.SubqueryTags
88

99
@type move_value() :: {term(), term()}
10+
@type txid() :: pos_integer()
1011
@type move() :: %{dep_index: non_neg_integer(), values: [move_value()]}
1112

1213
@spec effect_for_move_in(move(), ShapeInfo.t()) :: %Effects.AppendControl{}
@@ -17,22 +18,28 @@ defmodule Electric.Shapes.Consumer.Subqueries.MoveBroadcast do
1718
shape_info.dnf_plan,
1819
active_move.dep_index,
1920
active_move.values,
21+
active_move.txids,
2022
"move-in",
2123
shape_info.stack_id,
2224
shape_info.shape_handle
2325
)
2426
}
2527
end
2628

27-
@spec effect_for_move_out(non_neg_integer(), [move_value()], ShapeInfo.t()) ::
28-
%Effects.AppendControl{}
29-
def effect_for_move_out(dep_index, values, %ShapeInfo{} = shape_info) do
29+
@spec effect_for_move_out(
30+
non_neg_integer(),
31+
[move_value()],
32+
[txid()],
33+
ShapeInfo.t()
34+
) :: %Effects.AppendControl{}
35+
def effect_for_move_out(dep_index, values, txids, %ShapeInfo{} = shape_info) do
3036
%Effects.AppendControl{
3137
message:
3238
make(
3339
shape_info.dnf_plan,
3440
dep_index,
3541
values,
42+
txids,
3643
"move-out",
3744
shape_info.stack_id,
3845
shape_info.shape_handle
@@ -44,11 +51,12 @@ defmodule Electric.Shapes.Consumer.Subqueries.MoveBroadcast do
4451
DnfPlan.t(),
4552
non_neg_integer(),
4653
[move_value()],
54+
[txid()],
4755
String.t(),
4856
String.t(),
4957
String.t()
5058
) :: map()
51-
defp make(plan, dep_index, values, event, stack_id, shape_handle)
59+
defp make(plan, dep_index, values, txids, event, stack_id, shape_handle)
5260
when event in ["move-in", "move-out"] do
5361
positions = Map.get(plan.dependency_positions, dep_index, [])
5462

@@ -61,7 +69,7 @@ defmodule Electric.Shapes.Consumer.Subqueries.MoveBroadcast do
6169
end)
6270
end)
6371

64-
%{headers: %{event: event, patterns: patterns}}
72+
%{headers: %{event: event, patterns: patterns, txids: txids}}
6573
end
6674

6775
defp make_hash(%{tag_columns: [_col]}, stack_id, shape_handle, value) do

0 commit comments

Comments
 (0)