Commit 1c20fac
Fix Materializer startup race condition via offset coordination (#3794)
## Summary
Fixes #3787 - addresses the snapshot/replication race condition in the
Materializer that caused "Key already exists" crashes.
### The race condition
The Materializer subscribed to the Consumer **before** reading from
storage:
```elixir
# BEFORE: In handle_continue(:start_materializer, ...)
Consumer.subscribe_materializer(stack_id, shape_handle, self()) # <- Subscribes first
{:noreply, state, {:continue, {:read_stream, shape_storage}}} # <- Then reads ALL storage
```
During the window between subscribing and reading, any changes that
arrive via `Consumer.new_changes()` would be delivered to the
Materializer. If those changes included records that were also in the
snapshot being read, the Materializer received duplicates and crashed.
### Production example (maxwell instance, 27 Jan 2026)
```
18:10:10.437 [error] GenServer Materializer "97489818-..." terminating
** (RuntimeError) Key "public"."offers"/"d3c8d8a5-5060-4a36-a67d-240de0c95a88" already exists
```
The transaction that triggered it:
```elixir
%Electric.Replication.Changes.NewRecord{
relation: {"public", "offers"},
record: %{"id" => "d3c8d8a5-5060-4a36-a67d-240de0c95a88"},
key: "\"public\".\"offers\"/\"d3c8d8a5-5060-4a36-a67d-240de0c95a88\"",
move_tags: ["e12422d3af57a36d01a50b4645a517e4"] # <- Move-in event
}
```
The record was already in the snapshot (matched via `is_template = true`
OR the subquery), AND was delivered via replication with `move_tags` as
a move-in event.
### The fix
Consumer now returns its current offset when a Materializer subscribes.
The Materializer reads storage **only up to that offset**, not beyond.
Changes after that offset will be delivered via `new_changes` messages,
ensuring each change is delivered exactly once.
```elixir
# AFTER: Consumer returns offset on subscription
def handle_call({:subscribe_materializer, pid}, _from, state) do
{:reply, {:ok, state.latest_offset}, %{state | materializer_subscribed?: true}, ...}
end
# AFTER: Materializer uses offset to bound storage reads
{:ok, subscribed_offset} = Consumer.subscribe_materializer(stack_id, shape_handle, self())
{:noreply, %{state | subscribed_offset: subscribed_offset}, {:continue, {:read_stream, ...}}}
# In handle_continue({:read_stream, ...})
{:ok, offset, stream} = get_stream_up_to_offset(state.offset, state.subscribed_offset, storage)
```
## Test plan
- [x] Added test verifying offset coordination prevents duplicates
- [x] All existing Materializer tests pass (35 tests)
- [x] Full test suite passes (1334 tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>1 parent 2a0902e commit 1c20fac
File tree
4 files changed
+146
-24
lines changed- .changeset
- packages/sync-service
- lib/electric/shapes
- consumer
- test/electric/shapes/consumer
4 files changed
+146
-24
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
57 | | - | |
| 57 | + | |
| 58 | + | |
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
| |||
210 | 211 | | |
211 | 212 | | |
212 | 213 | | |
213 | | - | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
214 | 217 | | |
215 | 218 | | |
216 | 219 | | |
| |||
Lines changed: 21 additions & 19 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| 100 | + | |
100 | 101 | | |
101 | 102 | | |
102 | 103 | | |
| |||
113 | 114 | | |
114 | 115 | | |
115 | 116 | | |
116 | | - | |
| 117 | + | |
| 118 | + | |
117 | 119 | | |
118 | 120 | | |
119 | 121 | | |
120 | 122 | | |
121 | 123 | | |
122 | | - | |
| 124 | + | |
| 125 | + | |
123 | 126 | | |
124 | 127 | | |
125 | 128 | | |
| |||
133 | 136 | | |
134 | 137 | | |
135 | 138 | | |
136 | | - | |
| 139 | + | |
| 140 | + | |
137 | 141 | | |
138 | 142 | | |
139 | 143 | | |
| |||
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
146 | | - | |
147 | | - | |
148 | | - | |
149 | | - | |
150 | | - | |
151 | | - | |
152 | | - | |
153 | | - | |
154 | | - | |
155 | | - | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | | - | |
161 | | - | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
162 | 164 | | |
163 | 165 | | |
164 | 166 | | |
| |||
Lines changed: 113 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
65 | | - | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
66 | 70 | | |
67 | 71 | | |
68 | 72 | | |
| |||
79 | 83 | | |
80 | 84 | | |
81 | 85 | | |
82 | | - | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
83 | 91 | | |
84 | 92 | | |
85 | 93 | | |
| |||
763 | 771 | | |
764 | 772 | | |
765 | 773 | | |
766 | | - | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
767 | 779 | | |
768 | 780 | | |
769 | 781 | | |
| |||
790 | 802 | | |
791 | 803 | | |
792 | 804 | | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
793 | 903 | | |
794 | 904 | | |
795 | 905 | | |
| |||
0 commit comments