Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions lib/durable_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 1 addition & 9 deletions lib/durable_server/circuit_breaker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/durable_server/terminator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 7 additions & 15 deletions test/durable_server/circuit_breaker_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -790,39 +790,31 @@ 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)

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
Expand Down
28 changes: 28 additions & 0 deletions test/durable_server/relup_test.exs
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions test/durable_server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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})
Expand All @@ -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})
Expand Down