Skip to content

Commit ec39d48

Browse files
committed
Fix legacy move-out handling without active conditions
1 parent 07e1a0c commit ec39d48

3 files changed

Lines changed: 156 additions & 19 deletions

File tree

‎packages/elixir-client/lib/electric/client/tag_tracker.ex‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -186,25 +186,41 @@ defmodule Electric.Client.TagTracker do
186186
{deletes, kd_acc, ttk_acc}
187187

188188
%{tags: current_entries, msg: msg} = data ->
189-
deactivated_positions =
190-
MapSet.new(removed_entries, fn {pos, _} -> pos end)
191-
192-
updated_ac =
193-
data.active_conditions
194-
|> Enum.with_index()
195-
|> Enum.map(fn {val, idx} ->
196-
if MapSet.member?(deactivated_positions, idx), do: false, else: val
197-
end)
198-
199-
visible = row_visible?(updated_ac, disjunct_positions)
200-
201-
if not visible do
202-
ttk_acc = remove_key_from_tags(ttk_acc, current_entries, key)
203-
204-
{[{key, msg} | deletes], Map.delete(kd_acc, key), ttk_acc}
205-
else
206-
updated_data = %{data | active_conditions: updated_ac}
207-
{deletes, Map.put(kd_acc, key, updated_data), ttk_acc}
189+
case data.active_conditions do
190+
nil ->
191+
# Legacy/simple shapes without active_conditions still use
192+
# empty-tag-set deletion semantics rather than DNF visibility.
193+
remaining_entries = MapSet.difference(current_entries, removed_entries)
194+
ttk_acc = remove_key_from_tags(ttk_acc, removed_entries, key)
195+
196+
if MapSet.size(remaining_entries) == 0 do
197+
{[{key, msg} | deletes], Map.delete(kd_acc, key), ttk_acc}
198+
else
199+
updated_data = %{data | tags: remaining_entries}
200+
{deletes, Map.put(kd_acc, key, updated_data), ttk_acc}
201+
end
202+
203+
active_conditions ->
204+
deactivated_positions =
205+
MapSet.new(removed_entries, fn {pos, _} -> pos end)
206+
207+
updated_ac =
208+
active_conditions
209+
|> Enum.with_index()
210+
|> Enum.map(fn {val, idx} ->
211+
if MapSet.member?(deactivated_positions, idx), do: false, else: val
212+
end)
213+
214+
visible = row_visible?(updated_ac, disjunct_positions)
215+
216+
if not visible do
217+
ttk_acc = remove_key_from_tags(ttk_acc, current_entries, key)
218+
219+
{[{key, msg} | deletes], Map.delete(kd_acc, key), ttk_acc}
220+
else
221+
updated_data = %{data | active_conditions: updated_ac}
222+
{deletes, Map.put(kd_acc, key, updated_data), ttk_acc}
223+
end
208224
end
209225
end
210226
end)

‎packages/elixir-client/test/electric/client/tag_tracker_test.exs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,46 @@ defmodule Electric.Client.TagTrackerTest do
221221
assert new_tag_to_keys == %{}
222222
assert new_key_data == %{}
223223
end
224+
225+
test "falls back to empty-tag-set deletion when active_conditions are missing" do
226+
msg =
227+
make_change_msg("key1", :insert,
228+
tags: ["tag_a", "tag_b"],
229+
value: %{"id" => "1"}
230+
)
231+
232+
{tag_to_keys, key_data, dp} = TagTracker.update_tag_index(%{}, %{}, nil, msg)
233+
234+
assert key_data["key1"].active_conditions == nil
235+
236+
{deletes, tag_to_keys, key_data} =
237+
TagTracker.generate_synthetic_deletes(
238+
tag_to_keys,
239+
key_data,
240+
dp,
241+
[%{pos: 0, value: "tag_a"}],
242+
DateTime.utc_now()
243+
)
244+
245+
assert deletes == []
246+
assert key_data["key1"].active_conditions == nil
247+
assert key_data["key1"].tags == MapSet.new([{0, "tag_b"}])
248+
assert tag_to_keys == %{{0, "tag_b"} => MapSet.new(["key1"])}
249+
250+
{deletes, tag_to_keys, key_data} =
251+
TagTracker.generate_synthetic_deletes(
252+
tag_to_keys,
253+
key_data,
254+
dp,
255+
[%{pos: 0, value: "tag_b"}],
256+
DateTime.utc_now()
257+
)
258+
259+
assert length(deletes) == 1
260+
assert hd(deletes).key == "key1"
261+
assert tag_to_keys == %{}
262+
assert key_data == %{}
263+
end
224264
end
225265

226266
describe "normalize_tags/1" do

‎packages/elixir-client/test/electric/client_test.exs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2401,6 +2401,87 @@ defmodule Electric.ClientTest do
24012401
[delete] = delete_msgs
24022402
assert delete.value["id"] == "1111"
24032403
end
2404+
2405+
test "resume preserves legacy move-out state when active_conditions are missing", ctx do
2406+
body1 =
2407+
Jason.encode!([
2408+
%{
2409+
"key" => "row-1",
2410+
"headers" => %{
2411+
"operation" => "insert",
2412+
"tags" => ["legacy-tag"]
2413+
},
2414+
"offset" => "1_0",
2415+
"value" => %{"id" => "1111"}
2416+
},
2417+
%{"headers" => %{"control" => "up-to-date", "global_last_seen_lsn" => 9998}}
2418+
])
2419+
2420+
schema = Jason.encode!(%{"id" => %{type: "text"}})
2421+
2422+
{:ok, responses} =
2423+
start_supervised(
2424+
{Agent,
2425+
fn ->
2426+
%{
2427+
{"-1", nil} => [
2428+
&bypass_resp(&1, body1,
2429+
shape_handle: "my-shape",
2430+
last_offset: "1_0",
2431+
schema: schema
2432+
)
2433+
]
2434+
}
2435+
end}
2436+
)
2437+
2438+
bypass_response(ctx, responses)
2439+
2440+
msgs = stream(ctx, live: false) |> Enum.to_list()
2441+
2442+
resume_msg = Enum.find(msgs, &match?(%ResumeMessage{}, &1))
2443+
assert resume_msg != nil
2444+
2445+
body2 =
2446+
Jason.encode!([
2447+
%{
2448+
"headers" => %{
2449+
"event" => "move-out",
2450+
"patterns" => [%{"pos" => 0, "value" => "legacy-tag"}]
2451+
}
2452+
},
2453+
%{"headers" => %{"control" => "up-to-date", "global_last_seen_lsn" => 9999}}
2454+
])
2455+
2456+
{:ok, responses2} =
2457+
start_supervised(
2458+
{Agent,
2459+
fn ->
2460+
%{
2461+
{"1_0", "my-shape"} => [
2462+
&bypass_resp(&1, body2,
2463+
shape_handle: "my-shape",
2464+
last_offset: "2_0"
2465+
)
2466+
]
2467+
}
2468+
end},
2469+
id: :legacy_responses2
2470+
)
2471+
2472+
bypass_response(ctx, responses2)
2473+
2474+
resumed_msgs = stream(ctx, resume: resume_msg, live: false) |> Enum.to_list()
2475+
2476+
delete_msgs =
2477+
Enum.filter(resumed_msgs, &match?(%ChangeMessage{headers: %{operation: :delete}}, &1))
2478+
2479+
assert length(delete_msgs) == 1,
2480+
"Legacy move-out after resume should generate synthetic delete, got: #{inspect(resumed_msgs)}"
2481+
2482+
[delete] = delete_msgs
2483+
assert delete.value["id"] == "1111"
2484+
end
24042485
end
24052486

24062487
defp bypass_response_endpoint(ctx, responses, opts) do

0 commit comments

Comments
 (0)