Skip to content

Commit 742b73c

Browse files
committed
Fix shape refresh
1 parent c285d34 commit 742b73c

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

packages/sync-service/lib/electric/shape_cache/shape_status.ex

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ defmodule Electric.ShapeCache.ShapeStatus do
108108
generation = System.unique_integer([:positive, :monotonic])
109109
populate_shape_meta_table(stack_id, generation)
110110

111+
# Rows still on an old generation belong to handles that no longer exist
112+
# (removed by another instance). Prune their reverse {id, handle} mappings
113+
# before deleting the meta rows, otherwise the stale ids keep resolving via
114+
# handle_for_id/2 and leak across refreshes.
115+
stale_ids =
116+
:ets.select(shape_meta_table(stack_id), [
117+
{{:_, :_, :_, :_, :"$1", :"$2"}, [{:"/=", :"$1", generation}], [:"$2"]}
118+
])
119+
120+
Enum.each(stale_ids, &:ets.delete(shape_id_table(stack_id), &1))
121+
111122
:ets.select_delete(shape_meta_table(stack_id), [
112123
{{:_, :_, :_, :_, :"$1", :_}, [{:"/=", :"$1", generation}], [true]}
113124
])
@@ -549,8 +560,22 @@ defmodule Electric.ShapeCache.ShapeStatus do
549560
# any shapes where the snapshot didn't complete have been deleted
550561
# so there is no intermediate started-but-not-complete state
551562
# and completed implies started
552-
id = mint_id(stack_id)
553-
:ets.insert(shape_id_table(stack_id), {id, handle})
563+
564+
# Reuse the existing id for a handle already known to us (the refresh/1
565+
# case) so ids stay stable across a refresh; only mint for handles we've
566+
# not seen (first boot via initialize/1, or shapes a previous instance
567+
# added while we were read-only). Re-minting here would orphan the old
568+
# {id, handle} reverse mapping (see the sweep in refresh/1).
569+
id =
570+
case id_for_handle(stack_id, handle) do
571+
{:ok, existing_id} ->
572+
existing_id
573+
574+
:error ->
575+
new_id = mint_id(stack_id)
576+
:ets.insert(shape_id_table(stack_id), {new_id, handle})
577+
new_id
578+
end
554579

555580
true =
556581
:ets.insert(table, {handle, hash, snapshot_complete?, start_time, generation, id})

packages/sync-service/test/electric/shape_cache/shape_status_test.exs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,40 @@ defmodule Electric.ShapeCache.ShapeStatusTest do
437437
assert ShapeStatus.shape_handle_for_log(stack_id, id) == "unknown, id: #{id}"
438438
end
439439

440-
test "ids minted on the restore path are resolvable both ways", %{stack_id: stack_id} do
441-
{:ok, {handle, _id}} = ShapeStatus.add_shape(stack_id, shape!())
440+
test "refresh preserves the id of an existing shape", %{stack_id: stack_id} do
441+
{:ok, {handle, id}} = ShapeStatus.add_shape(stack_id, shape!())
442442
ShapeStatus.ShapeDb.mark_snapshot_complete(stack_id, handle)
443443

444444
# refresh/1 repopulates the meta table from SQLite via
445-
# populate_shape_meta_table/2, which mints a fresh id per restored handle.
445+
# populate_shape_meta_table/2, reusing (not re-minting) the id for a handle
446+
# it already knows, so the id stays stable across a refresh.
447+
:ok = ShapeStatus.refresh(stack_id)
448+
449+
assert {:ok, ^id} = ShapeStatus.id_for_handle(stack_id, handle)
450+
assert {:ok, ^handle} = ShapeStatus.handle_for_id(stack_id, id)
451+
end
452+
453+
test "refresh prunes the reverse id mapping for handles removed elsewhere", %{
454+
stack_id: stack_id
455+
} do
456+
{:ok, {removed_handle, removed_id}} = ShapeStatus.add_shape(stack_id, shape!())
457+
{:ok, {kept_handle, kept_id}} = ShapeStatus.add_shape(stack_id, shape2!())
458+
ShapeStatus.ShapeDb.mark_snapshot_complete(stack_id, removed_handle)
459+
ShapeStatus.ShapeDb.mark_snapshot_complete(stack_id, kept_handle)
460+
461+
# Simulate another instance deleting the shape from the shared SQLite while
462+
# our in-memory meta/reverse tables still reference it.
463+
:ok = ShapeStatus.ShapeDb.remove_shape(stack_id, removed_handle)
464+
446465
:ok = ShapeStatus.refresh(stack_id)
447466

448-
assert {:ok, restored_id} = ShapeStatus.id_for_handle(stack_id, handle)
449-
assert is_integer(restored_id)
450-
assert {:ok, ^handle} = ShapeStatus.handle_for_id(stack_id, restored_id)
467+
# the removed handle's pre-refresh id no longer resolves in either direction
468+
assert :error = ShapeStatus.id_for_handle(stack_id, removed_handle)
469+
assert :error = ShapeStatus.handle_for_id(stack_id, removed_id)
470+
471+
# the surviving shape keeps its id
472+
assert {:ok, ^kept_id} = ShapeStatus.id_for_handle(stack_id, kept_handle)
473+
assert {:ok, ^kept_handle} = ShapeStatus.handle_for_id(stack_id, kept_id)
451474
end
452475

453476
test "list_shapes_with_ids returns each shape's handle, id and shape", %{stack_id: stack_id} do

0 commit comments

Comments
 (0)