From 6762daaba8da4808e1c344974a34e5c8f1c7dd4a Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Tue, 3 Mar 2026 15:53:21 +0100 Subject: [PATCH 1/3] Fix a key lookup error in Materializer for a change that updates the primary key --- .../electric/shapes/consumer/materializer.ex | 7 ++- .../shapes/consumer/materializer_test.exs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/sync-service/lib/electric/shapes/consumer/materializer.ex b/packages/sync-service/lib/electric/shapes/consumer/materializer.ex index c668f17745..59ec774e42 100644 --- a/packages/sync-service/lib/electric/shapes/consumer/materializer.ex +++ b/packages/sync-service/lib/electric/shapes/consumer/materializer.ex @@ -377,11 +377,14 @@ defmodule Electric.Shapes.Consumer.Materializer do %Changes.UpdatedRecord{ key: key, + old_key: old_key, record: record, move_tags: move_tags, removed_move_tags: removed_move_tags }, {{index, tag_indices}, counts_and_events} -> + # When the primary key doesn't change, old_key may be nil; default to key + old_key = old_key || key # TODO: this is written as if it supports multiple selected columns, but it doesn't for now columns_present = Enum.any?(state.columns, &is_map_key(record, &1)) has_tag_updates = removed_move_tags != [] @@ -389,12 +392,12 @@ defmodule Electric.Shapes.Consumer.Materializer do if columns_present or has_tag_updates do tag_indices = tag_indices - |> remove_row_from_tag_indices(key, removed_move_tags) + |> remove_row_from_tag_indices(old_key, removed_move_tags) |> add_row_to_tag_indices(key, move_tags) if columns_present do {value, original_string} = cast!(record, state) - old_value = Map.fetch!(index, key) + {old_value, index} = Map.pop!(index, old_key) index = Map.put(index, key, value) # Skip decrement/increment dance if value hasn't changed to avoid diff --git a/packages/sync-service/test/electric/shapes/consumer/materializer_test.exs b/packages/sync-service/test/electric/shapes/consumer/materializer_test.exs index 5f4df78afc..213f57d90a 100644 --- a/packages/sync-service/test/electric/shapes/consumer/materializer_test.exs +++ b/packages/sync-service/test/electric/shapes/consumer/materializer_test.exs @@ -350,6 +350,60 @@ defmodule Electric.Shapes.Consumer.MaterializerTest do end) end + @tag snapshot_data: { + [%Changes.NewRecord{record: %{"id" => "1", "value" => "10"}}], + [pk_cols: ["id"]] + } + test "update that changes the primary key is handled correctly", ctx do + ctx = with_materializer(ctx) + + assert Materializer.get_link_values(ctx) == MapSet.new([10]) + + # Update where the PK changes from "1" to "2" + Materializer.new_changes( + ctx, + [ + %Changes.UpdatedRecord{ + record: %{"id" => "2", "value" => "20"}, + old_record: %{"id" => "1", "value" => "10"} + } + ] + |> prep_changes() + ) + + assert Materializer.get_link_values(ctx) == MapSet.new([20]) + + assert_receive {:materializer_changes, _, %{move_out: [{10, "10"}], move_in: [{20, "20"}]}} + end + + @tag snapshot_data: { + [%Changes.NewRecord{record: %{"id" => "1", "value" => "10"}}], + [pk_cols: ["id"]] + } + test "update that changes the primary key but keeps the same value", ctx do + ctx = with_materializer(ctx) + + assert Materializer.get_link_values(ctx) == MapSet.new([10]) + + # Update where the PK changes but tracked value stays the same + Materializer.new_changes( + ctx, + [ + %Changes.UpdatedRecord{ + record: %{"id" => "2", "value" => "10"}, + old_record: %{"id" => "1", "value" => "10"} + } + ] + |> prep_changes() + ) + + # Value should still be present + assert Materializer.get_link_values(ctx) == MapSet.new([10]) + + # No events since the tracked value didn't change + refute_received {:materializer_changes, _, _} + end + test "events are accumulated across uncommitted fragments", ctx do ctx = with_materializer(ctx) From d4a202fc8ee8804c00186dc2ccb0d5340e9a1bf6 Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Tue, 3 Mar 2026 16:02:42 +0100 Subject: [PATCH 2/3] Add changeset --- .changeset/nine-eagles-fetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nine-eagles-fetch.md diff --git a/.changeset/nine-eagles-fetch.md b/.changeset/nine-eagles-fetch.md new file mode 100644 index 0000000000..e4102c04fb --- /dev/null +++ b/.changeset/nine-eagles-fetch.md @@ -0,0 +1,5 @@ +--- +'@core/sync-service': patch +--- + +Fix Materializer crash that was happening when processing changes with PK updates. From 349f4ac420be69933f740085cd84a76a8b9a446d Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Tue, 3 Mar 2026 16:02:59 +0100 Subject: [PATCH 3/3] mix format --- .../sync-service/lib/electric/shapes/consumer/materializer.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sync-service/lib/electric/shapes/consumer/materializer.ex b/packages/sync-service/lib/electric/shapes/consumer/materializer.ex index 59ec774e42..df6baf2f5e 100644 --- a/packages/sync-service/lib/electric/shapes/consumer/materializer.ex +++ b/packages/sync-service/lib/electric/shapes/consumer/materializer.ex @@ -385,6 +385,7 @@ defmodule Electric.Shapes.Consumer.Materializer do {{index, tag_indices}, counts_and_events} -> # When the primary key doesn't change, old_key may be nil; default to key old_key = old_key || key + # TODO: this is written as if it supports multiple selected columns, but it doesn't for now columns_present = Enum.any?(state.columns, &is_map_key(record, &1)) has_tag_updates = removed_move_tags != []