Skip to content
Merged
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
11 changes: 9 additions & 2 deletions lib/durable_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1000,10 +1000,17 @@ defmodule DurableServer do
init_arg: _init_arg,
boot_info: _boot_info,
supervisor_name: _supervisor_name,
config: _config
config: config
} = info
) do
GenServer.start_link(__MODULE__, info)
GenServer.start_link(__MODULE__, info, start_opts(info.module, config))
end

defp start_opts(module, config) do
case DurableServer.Supervisor.hibernate_after_for(config, module) do
nil -> []
ms -> [hibernate_after: ms]
end
end

@impl true
Expand Down
53 changes: 52 additions & 1 deletion lib/durable_server/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ defmodule DurableServer.Supervisor do
- `:name` - Required. Registered name for this supervisor instance
- `:prefix` - Required. Object storage prefix for scoping (should end with "/")
- `:max_children` - Maximum concurrent DurableServer processes (default: :infinity)
- `:hibernate_after` - Milliseconds without a message after which a
DurableServer process hibernates, compacting its heap to live data. Either a
positive integer for every module, or a map of `Module => ms`, like
`:max_children`, where a `:default` key covers modules not listed. Applies
wherever this supervisor starts a child, including restarts and rehoming.
Default: `nil` (never hibernate).
- `:discovery_interval_ms` - How often to scan for orphaned servers (default: 60_000)
- `:initial_discovery_delay_ms` - Initial delay before the first discovery sweep.
Accepts either a fixed integer delay or a `{min_ms, max_ms}` jitter tuple
Expand Down Expand Up @@ -647,6 +653,12 @@ defmodule DurableServer.Supervisor do
- `:name` - Required. The registered name for this supervisor
- `:prefix` - Required. Object storage prefix (should end with "/")
- `:max_children` - Maximum concurrent children (default: :infinity)
- `:hibernate_after` - Milliseconds without a message after which a
DurableServer process hibernates, compacting its heap to live data. Either a
positive integer for every module, or a map of `Module => ms`, like
`:max_children`, where a `:default` key covers modules not listed. Applies
wherever this supervisor starts a child, including restarts and rehoming.
Default: `nil` (never hibernate).
- `:discovery_interval_ms` - Lifecycle discovery interval (default: 60_000)
- `:initial_discovery_delay_ms` - Initial discovery delay as a fixed integer or
`{min_ms, max_ms}` jitter tuple (default: `{1_000, 6_000}`)
Expand Down Expand Up @@ -3347,7 +3359,8 @@ defmodule DurableServer.Supervisor do
:placement_region,
:placement_erpc_timeout_same_region_ms,
:placement_erpc_timeout_cross_region_ms,
:max_singleflight_waiters_per_key_module
:max_singleflight_waiters_per_key_module,
:hibernate_after
])

name = Keyword.fetch!(opts, :name)
Expand Down Expand Up @@ -3509,6 +3522,8 @@ defmodule DurableServer.Supervisor do
max_singleflight_waiters_per_key_module =
extract_max_singleflight_waiters_per_key_module_config(opts)

hibernate_after = extract_hibernate_after_config(opts)

if placement_erpc_timeout_same_region_ms > placement_erpc_timeout_cross_region_ms do
Logger.warning(
"placement_erpc_timeout_same_region_ms (#{placement_erpc_timeout_same_region_ms}) is greater than " <>
Expand Down Expand Up @@ -3595,6 +3610,7 @@ defmodule DurableServer.Supervisor do
placement_erpc_timeout_same_region_ms: placement_erpc_timeout_same_region_ms,
placement_erpc_timeout_cross_region_ms: placement_erpc_timeout_cross_region_ms,
max_singleflight_waiters_per_key_module: max_singleflight_waiters_per_key_module,
hibernate_after: hibernate_after,
circuit_breaker: circuit_breaker,
ets_table: table_name
}
Expand Down Expand Up @@ -4319,6 +4335,41 @@ defmodule DurableServer.Supervisor do
{same_region_timeout_ms, cross_region_timeout_ms}
end

defp extract_hibernate_after_config(opts) do
case Keyword.get(opts, :hibernate_after) do
nil ->
nil

ms when is_integer(ms) and ms > 0 ->
ms

%{} = per_module ->
Enum.each(per_module, fn
{key, ms} when is_atom(key) and is_integer(ms) and ms > 0 ->
:ok

entry ->
raise ArgumentError,
"hibernate_after map entries must be Module (or :default) => positive integer, got: #{inspect(entry)}"
end)

per_module

other ->
raise ArgumentError,
"hibernate_after must be a positive integer or a map of Module => positive integer, got: #{inspect(other)}"
end
end

@doc false
# The hibernate_after a supervisor's config gives `module`, or nil.
def hibernate_after_for(%{hibernate_after: ms}, _module) when is_integer(ms), do: ms

def hibernate_after_for(%{hibernate_after: %{} = per_module}, module),
do: Map.get(per_module, module, Map.get(per_module, :default))

def hibernate_after_for(_config, _module), do: nil

defp extract_max_singleflight_waiters_per_key_module_config(opts) do
case Keyword.fetch(opts, :max_singleflight_waiters_per_key_module) do
:error ->
Expand Down
112 changes: 112 additions & 0 deletions test/durable_server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,118 @@ defmodule DurableServerTest do
end
end

defmodule IdleServer do
use DurableServer, vsn: 1

def dump_state(state), do: Map.take(state, [:key])
def load_state(_old_vsn, persisted_state), do: DurableServerTest.atomify_keys(persisted_state)
def init(state, info), do: {:ok, Map.put(state, :key, info.key)}
end

defmodule OtherIdleServer do
use DurableServer, vsn: 1

def dump_state(state), do: Map.take(state, [:key])
def load_state(_old_vsn, persisted_state), do: DurableServerTest.atomify_keys(persisted_state)
def init(state, info), do: {:ok, Map.put(state, :key, info.key)}
end

describe "hibernate_after" do
defp start_hibernate_sup(hibernate_after) do
name = :"hibernate_sup_#{System.unique_integer([:positive])}"

opts = [
name: name,
prefix: "hibernate-#{DurableServer.UUID.uuid4()}/",
object_store: test_object_store_opts(),
hibernate_after: hibernate_after
]

start_supervised!(%{
id: {DurableServer.Supervisor, name},
start: {DurableServer.Supervisor, :start_link, [opts]},
type: :supervisor
})

name
end

defp start_idle(sup, module) do
key = "hibernate-#{DurableServer.UUID.uuid4()}"

{:ok, {pid, _meta}} =
DurableServer.Supervisor.start_child(sup, {module, key: key, initial_state: %{}})

pid
end

# OTP 28 hibernates a gen_server in place; older releases use erlang:hibernate/3.
defp hibernating?(pid) do
case Process.info(pid, :current_function) do
{:current_function, {:gen_server, :loop_hibernate, _arity}} -> true
{:current_function, {:erlang, :hibernate, 3}} -> true
_other -> false
end
end

defp eventually(fun, attempts \\ 50) do
cond do
fun.() -> true
attempts == 0 -> false
true -> Process.sleep(20) && eventually(fun, attempts - 1)
end
end

test "a per-module setting hibernates only the listed modules" do
sup = start_hibernate_sup(%{IdleServer => 50})
listed = start_idle(sup, IdleServer)
unlisted = start_idle(sup, OtherIdleServer)

assert eventually(fn -> hibernating?(listed) end)
Process.sleep(300)
refute hibernating?(unlisted)

# A hibernated server still serves calls.
assert %{key: "hibernate-" <> _} = :sys.get_state(listed).user_state
end

test "a :default entry covers modules the map does not list" do
sup = start_hibernate_sup(%{IdleServer => 60_000, default: 50})
listed = start_idle(sup, IdleServer)
other = start_idle(sup, OtherIdleServer)

assert eventually(fn -> hibernating?(other) end)
refute hibernating?(listed)
end

test "an integer setting applies to every module" do
sup = start_hibernate_sup(50)
pid = start_idle(sup, OtherIdleServer)
assert eventually(fn -> hibernating?(pid) end)
end

test "without the option servers never hibernate" do
sup = start_hibernate_sup(nil)
pid = start_idle(sup, IdleServer)
Process.sleep(300)
refute hibernating?(pid)
end

test "invalid settings are rejected when the supervisor starts" do
Process.flag(:trap_exit, true)

for invalid <- [0, -5, "15000", %{IdleServer => 0}, %{"IdleServer" => 100}, %{default: -1}] do
assert {:error, _reason} =
DurableServer.Supervisor.start_link(
name: :"hibernate_bad_#{System.unique_integer([:positive])}",
prefix: "hibernate-bad/",
object_store: test_object_store_opts(),
hibernate_after: invalid
)
end
end
end

describe "basic GenServer behavior" do
setup %{supervisor_name: supervisor_name, prefix: _prefix} do
key = "basic-server-#{DurableServer.UUID.uuid4()}"
Expand Down