diff --git a/CHANGELOG.md b/CHANGELOG.md index 325fd8f..36ac8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Enforce cumulative sticky-placement gates during request-driven remote placement so an existing server cannot move to a fallback node before that level unlocks. - Allow an expired restart attempt to be reclaimed immediately by another node at an allowed sticky-placement level, while preserving strict placement when no level matches. +- Advertise both `DurableServer` and the user callback module in child specifications so OTP release upgrades can discover running durable processes and invoke their delegated `code_change/3` callbacks. ## 0.1.5 (2026-08-27) - Treat explicit `:sync`, `{:sync, metadata}`, and `sync: true` callback returns as strict durability boundaries. Built-in backends first exhaust their bounded transient retry policy; if the write still fails, the DurableServer exits with a structured `{:sync_failed, reason}` fatal-exit reason before acknowledging the callback. Automatic and periodic sync remain best effort for transient failures, while storage conflicts remain fatal. diff --git a/lib/durable_server.ex b/lib/durable_server.ex index 34bfbc8..ff8c9e2 100644 --- a/lib/durable_server.ex +++ b/lib/durable_server.ex @@ -982,13 +982,16 @@ defmodule DurableServer do end end - def child_spec(init_arg) do + def child_spec(%{module: module} = init_arg) when is_atom(module) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}, type: :worker, restart: :temporary, - shutdown: 30_000 + shutdown: 30_000, + # release_handler selects processes for code_change/3 from this list. + # The wrapper delegates that callback to the user's server module. + modules: [__MODULE__, module] } end diff --git a/lib/durable_server/circuit_breaker.ex b/lib/durable_server/circuit_breaker.ex index 004f9dc..92c3bfd 100644 --- a/lib/durable_server/circuit_breaker.ex +++ b/lib/durable_server/circuit_breaker.ex @@ -226,15 +226,7 @@ defmodule DurableServer.CircuitBreaker do end defp inc(table, key, current_time) do - # Use atomic update_counter to avoid race conditions. - try do - :ets.update_counter(table, key, {2, 1}) - catch - :error, :badarg -> - # Key doesn't exist, insert initial entry and try again. - :ets.insert(table, {key, 0, current_time, 0}) - :ets.update_counter(table, key, {2, 1}) - end + :ets.update_counter(table, key, {2, 1}, {key, 0, current_time, 0}) :ok end diff --git a/lib/durable_server/terminator.ex b/lib/durable_server/terminator.ex index 9d0c320..bebe8d3 100644 --- a/lib/durable_server/terminator.ex +++ b/lib/durable_server/terminator.ex @@ -203,8 +203,8 @@ defmodule DurableServer.Terminator do |> DynamicSupervisor.which_children() |> Enum.filter(fn # Filter out non-DurableServer children (LifecycleManager, Terminator, TaskSupervisor) - {_id, pid, _type, [DurableServer]} when is_pid(pid) -> - true + {_id, pid, _type, modules} when is_pid(pid) and is_list(modules) -> + DurableServer in modules _ -> false diff --git a/test/durable_server/circuit_breaker_test.exs b/test/durable_server/circuit_breaker_test.exs index 5a5aff8..69ff011 100644 --- a/test/durable_server/circuit_breaker_test.exs +++ b/test/durable_server/circuit_breaker_test.exs @@ -790,26 +790,22 @@ defmodule DurableServer.CircuitBreakerTest do assert :ets.info(circuit_breaker.table_name, :size) == 0 end - test "verifies match specification logic with exact boundary conditions", %{ + test "prunes stale entries while preserving recent entries and active cooldowns", %{ circuit_breaker: circuit_breaker } do current_time = System.system_time(:millisecond) window_ms = @default_config.module_circuit_breaker_window_ms - window_start = current_time - window_ms - # Test exact boundary conditions for the match spec: - # Delete where: last_reset < window_start AND cooldown_until <= current_time + :ets.insert(circuit_breaker.table_name, {BoundaryModule1, 1, current_time, 0}) - # Boundary case 1: last_reset exactly equals window_start (should NOT be removed) - :ets.insert(circuit_breaker.table_name, {BoundaryModule1, 1, window_start, 0}) - - # Boundary case 2: last_reset is 1ms before window_start (should be removed) - :ets.insert(circuit_breaker.table_name, {BoundaryModule2, 1, window_start - 1, 0}) + :ets.insert( + circuit_breaker.table_name, + {BoundaryModule2, 1, current_time - 2 * window_ms, 0} + ) - # Boundary case 3: cooldown_until is 1ms in future (should NOT be removed even if old) :ets.insert( circuit_breaker.table_name, - {BoundaryModule3, 1, window_start - 1000, current_time + 1} + {BoundaryModule3, 1, current_time - 2 * window_ms, current_time + window_ms} ) CircuitBreaker.prune_stale_entries(circuit_breaker) @@ -817,12 +813,8 @@ defmodule DurableServer.CircuitBreakerTest do remaining = :ets.tab2list(circuit_breaker.table_name) remaining_modules = Enum.map(remaining, fn {module, _, _, _} -> module end) - # Verify boundary conditions - # last_reset == window_start assert BoundaryModule1 in remaining_modules - # last_reset < window_start AND cooldown <= current_time refute BoundaryModule2 in remaining_modules - # cooldown_until > current_time assert BoundaryModule3 in remaining_modules assert length(remaining) == 2 diff --git a/test/durable_server/relup_test.exs b/test/durable_server/relup_test.exs new file mode 100644 index 0000000..7c560be --- /dev/null +++ b/test/durable_server/relup_test.exs @@ -0,0 +1,28 @@ +defmodule DurableServer.RelupTest do + use ExUnit.Case, async: true + + defmodule CallbackServer do + use DurableServer, vsn: 1 + + @impl true + def dump_state(state), do: state + + @impl true + def load_state(_old_vsn, state), do: state + end + + test "child specs identify the wrapper and callback modules used by the process" do + init_arg = %{ + module: CallbackServer, + init_from: {make_ref(), self()}, + init_arg: [], + boot_info: %{}, + supervisor_name: __MODULE__, + config: %{} + } + + assert %{modules: modules} = DurableServer.child_spec(init_arg) + assert DurableServer in modules + assert CallbackServer in modules + end +end diff --git a/test/durable_server_test.exs b/test/durable_server_test.exs index 3ef61ce..3854f0b 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -3600,7 +3600,7 @@ defmodule DurableServerTest do ref = Process.monitor(pid) assert GenServer.call(pid, :stop_abnormal) == :ok - assert_receive {:DOWN, ^ref, :process, ^pid, {:error, :abnormal_reason}} + assert_receive {:DOWN, ^ref, :process, ^pid, {:error, :abnormal_reason}}, 1_000 store = test_object_store() {:ok, data} = DurableServer.fetch_stored_state(store, %{key: key, prefix: prefix}) @@ -3623,7 +3623,7 @@ defmodule DurableServerTest do ref = Process.monitor(pid) GenServer.cast(pid, :crash) - assert_receive {:DOWN, ^ref, :process, ^pid, _reason} + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1_000 store = test_object_store() {:ok, data} = DurableServer.fetch_stored_state(store, %{key: key, prefix: prefix}) @@ -3646,7 +3646,7 @@ defmodule DurableServerTest do ref = Process.monitor(pid) send(pid, :crash) - assert_receive {:DOWN, ^ref, :process, ^pid, _reason} + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1_000 store = test_object_store() {:ok, data} = DurableServer.fetch_stored_state(store, %{key: key, prefix: prefix})