diff --git a/lib/durable_server.ex b/lib/durable_server.ex index 24c8b83..18c44fd 100644 --- a/lib/durable_server.ex +++ b/lib/durable_server.ex @@ -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 diff --git a/lib/durable_server/supervisor.ex b/lib/durable_server/supervisor.ex index 23e7642..a8c8646 100644 --- a/lib/durable_server/supervisor.ex +++ b/lib/durable_server/supervisor.ex @@ -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 @@ -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}`) @@ -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) @@ -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 " <> @@ -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 } @@ -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 -> diff --git a/test/durable_server_test.exs b/test/durable_server_test.exs index 73e3939..07ecb0c 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -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()}"