From 6402c09b4feef870b2f742571a84333461d3441b Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 30 Sep 2026 10:50:08 -0400 Subject: [PATCH 1/4] Add a hibernate_after supervisor option A DurableServer process never hibernated, so an idle server kept its heap at its high-water mark: in one production deployment an idle server held about 1 MB, of which 54 KB survived a GC. DurableServer.Supervisor now accepts :hibernate_after, either one positive integer for every module or a map of module => ms. Every child the supervisor starts, including restarts and rehoming, passes it to GenServer.start_link, so an idle server hibernates and its heap shrinks to live data. Default nil keeps the current behaviour. --- lib/durable_server.ex | 11 +++- lib/durable_server/supervisor.ex | 51 ++++++++++++++- test/durable_server_test.exs | 103 +++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) 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..3efc1d8 100644 --- a/lib/durable_server/supervisor.ex +++ b/lib/durable_server/supervisor.ex @@ -65,6 +65,11 @@ 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` - Hibernate DurableServer processes after this many + milliseconds without a message, compacting their heap to live data. Either a + positive integer for every module or a map of `module => ms` for the listed + modules only. 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 +652,11 @@ 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` - Hibernate DurableServer processes after this many + milliseconds without a message, compacting their heap to live data. Either a + positive integer for every module or a map of `module => ms` for the listed + modules only. 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 +3357,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 +3520,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 +3608,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 +4333,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 + {module, ms} when is_atom(module) and is_integer(ms) and ms > 0 -> + :ok + + entry -> + raise ArgumentError, + "hibernate_after map entries must be module => 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) + + 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..22fe627 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -1742,6 +1742,109 @@ 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 "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}] 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()}" From 3a57dfc10df83e5d634a68a081c68790f6493a2a Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 30 Sep 2026 11:03:55 -0400 Subject: [PATCH 2/4] Set hibernate_after per module, with the supervisor as default A server's idle behaviour belongs to its module, which travels with it however the supervisor starts it. use DurableServer, hibernate_after: ms sets it for that module and hibernate_after: false opts out; the supervisor's :hibernate_after is now one integer used as the default for modules that set nothing. The per-module map on the supervisor is gone. --- lib/durable_server.ex | 29 ++++++++++++++++- lib/durable_server/supervisor.ex | 54 +++++++++++++++----------------- test/durable_server_test.exs | 51 ++++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 43 deletions(-) diff --git a/lib/durable_server.ex b/lib/durable_server.ex index 18c44fd..47038ba 100644 --- a/lib/durable_server.ex +++ b/lib/durable_server.ex @@ -93,6 +93,16 @@ defmodule DurableServer do ] end + ## `use DurableServer` Options + + - `:vsn` - Required. The current persisted state version, passed to + `load_state/2` for migrations. + - `:hibernate_after` - Milliseconds without a message after which the server + hibernates, compacting its heap to live data. `false` never hibernates, even + when the supervisor sets a default. Unset uses the supervisor's + `:hibernate_after` default. Because it is part of the module, it applies + however the supervisor starts the server, including restarts and rehoming. + ## Advanced Example: Session Manager defmodule UserSessionServer do @@ -911,6 +921,22 @@ defmodule DurableServer do "the current :vsn must be provided:, ie: `use DurableServer, vsn: 1`" end + hibernate_after = + case Keyword.get(opts, :hibernate_after) do + nil -> + nil + + false -> + false + + ms when is_integer(ms) and ms > 0 -> + ms + + other -> + raise ArgumentError, + "hibernate_after must be a positive integer or false, got: #{inspect(other)}" + end + quote do @behaviour DurableServer @vsn unquote(vsn) @@ -918,7 +944,8 @@ defmodule DurableServer do def __durable_server_config__() do %{ - vsn: @vsn + vsn: @vsn, + hibernate_after: unquote(hibernate_after) } end diff --git a/lib/durable_server/supervisor.ex b/lib/durable_server/supervisor.ex index 3efc1d8..51a0efb 100644 --- a/lib/durable_server/supervisor.ex +++ b/lib/durable_server/supervisor.ex @@ -65,11 +65,11 @@ 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` - Hibernate DurableServer processes after this many - milliseconds without a message, compacting their heap to live data. Either a - positive integer for every module or a map of `module => ms` for the listed - modules only. Applies wherever this supervisor starts a child, including - restarts and rehoming. Default: `nil` (never hibernate). + - `:hibernate_after` - Default milliseconds without a message after which a + DurableServer process hibernates, compacting its heap to live data. A module + overrides it with `use DurableServer, hibernate_after: ms`, or opts out with + `hibernate_after: false`. 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 @@ -652,11 +652,11 @@ 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` - Hibernate DurableServer processes after this many - milliseconds without a message, compacting their heap to live data. Either a - positive integer for every module or a map of `module => ms` for the listed - modules only. Applies wherever this supervisor starts a child, including - restarts and rehoming. Default: `nil` (never hibernate). + - `:hibernate_after` - Default milliseconds without a message after which a + DurableServer process hibernates, compacting its heap to live data. A module + overrides it with `use DurableServer, hibernate_after: ms`, or opts out with + `hibernate_after: false`. 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}`) @@ -4341,32 +4341,28 @@ defmodule DurableServer.Supervisor do ms when is_integer(ms) and ms > 0 -> ms - %{} = per_module -> - Enum.each(per_module, fn - {module, ms} when is_atom(module) and is_integer(ms) and ms > 0 -> - :ok - - entry -> - raise ArgumentError, - "hibernate_after map entries must be module => 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)}" + "hibernate_after must be a 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) + # A module's own `use DurableServer, hibernate_after: ...` wins; `false` opts + # out; otherwise the supervisor's default applies. + def hibernate_after_for(config, module) do + case module_hibernate_after(module) do + ms when is_integer(ms) -> ms + false -> nil + nil -> Map.get(config, :hibernate_after) + end + end - def hibernate_after_for(_config, _module), do: nil + defp module_hibernate_after(module) do + if function_exported?(module, :__durable_server_config__, 0), + do: Map.get(module.__durable_server_config__(), :hibernate_after), + else: nil + end defp extract_max_singleflight_waiters_per_key_module_config(opts) do case Keyword.fetch(opts, :max_singleflight_waiters_per_key_module) do diff --git a/test/durable_server_test.exs b/test/durable_server_test.exs index 22fe627..a949672 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -1750,8 +1750,16 @@ defmodule DurableServerTest do def init(state, info), do: {:ok, Map.put(state, :key, info.key)} end - defmodule OtherIdleServer do - use DurableServer, vsn: 1 + defmodule SelfHibernatingServer do + use DurableServer, vsn: 1, hibernate_after: 50 + + 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 NeverHibernatingServer do + use DurableServer, vsn: 1, hibernate_after: false def dump_state(state), do: Map.take(state, [:key]) def load_state(_old_vsn, persisted_state), do: DurableServerTest.atomify_keys(persisted_state) @@ -1804,36 +1812,53 @@ defmodule DurableServerTest do 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) + test "a module's own setting applies without a supervisor default" do + sup = start_hibernate_sup(nil) + own = start_idle(sup, SelfHibernatingServer) + default = start_idle(sup, IdleServer) - assert eventually(fn -> hibernating?(listed) end) + assert eventually(fn -> hibernating?(own) end) Process.sleep(300) - refute hibernating?(unlisted) + refute hibernating?(default) # A hibernated server still serves calls. - assert %{key: "hibernate-" <> _} = :sys.get_state(listed).user_state + assert %{key: "hibernate-" <> _} = :sys.get_state(own).user_state end - test "an integer setting applies to every module" do + test "the supervisor default applies to modules that set nothing" do sup = start_hibernate_sup(50) - pid = start_idle(sup, OtherIdleServer) + pid = start_idle(sup, IdleServer) assert eventually(fn -> hibernating?(pid) end) end - test "without the option servers never hibernate" do + test "a module can opt out of the supervisor default" do + sup = start_hibernate_sup(50) + opted_out = start_idle(sup, NeverHibernatingServer) + Process.sleep(300) + refute hibernating?(opted_out) + end + + test "without any setting servers never hibernate" do sup = start_hibernate_sup(nil) pid = start_idle(sup, IdleServer) Process.sleep(300) refute hibernating?(pid) end + test "an invalid module setting fails at compile time" do + assert_raise ArgumentError, ~r/hibernate_after must be a positive integer or false/, fn -> + Code.compile_string(""" + defmodule DurableServerTest.BadHibernate do + use DurableServer, vsn: 1, hibernate_after: 0 + end + """) + end + 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}] do + for invalid <- [0, -5, "15000", %{IdleServer => 100}] do assert {:error, _reason} = DurableServer.Supervisor.start_link( name: :"hibernate_bad_#{System.unique_integer([:positive])}", From f5b2e6b9f91cdaee9c9c25f33af3e2db55762fcc Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 30 Sep 2026 11:07:36 -0400 Subject: [PATCH 3/4] Load a module before reading its hibernate_after function_exported?/3 is false for a module that is not loaded yet, so a server type whose module had not been loaded on that node got no hibernate_after on its first start. Load the module before reading it. --- lib/durable_server/supervisor.ex | 4 +++- test/durable_server_test.exs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/durable_server/supervisor.ex b/lib/durable_server/supervisor.ex index 51a0efb..8fa46ee 100644 --- a/lib/durable_server/supervisor.ex +++ b/lib/durable_server/supervisor.ex @@ -4358,8 +4358,10 @@ defmodule DurableServer.Supervisor do end end + # function_exported?/3 is false for a module that is not loaded yet, which + # would silently drop its setting on a first start, so load it first. defp module_hibernate_after(module) do - if function_exported?(module, :__durable_server_config__, 0), + if Code.ensure_loaded?(module) and function_exported?(module, :__durable_server_config__, 0), do: Map.get(module.__durable_server_config__(), :hibernate_after), else: nil end diff --git a/test/durable_server_test.exs b/test/durable_server_test.exs index a949672..c2ed266 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -1845,6 +1845,30 @@ defmodule DurableServerTest do refute hibernating?(pid) end + @tag :tmp_dir + test "a module's setting is found even before the module is loaded", %{tmp_dir: dir} do + [{module, beam}] = + Code.compile_string(""" + defmodule DurableServerTest.OnDiskHibernating do + use DurableServer, vsn: 1, hibernate_after: 70 + + def dump_state(state), do: state + def load_state(_old_vsn, state), do: state + end + """) + + File.write!(Path.join(dir, "#{module}.beam"), beam) + true = :code.add_patha(to_charlist(dir)) + on_exit(fn -> :code.del_path(to_charlist(dir)) end) + + :code.purge(module) + :code.delete(module) + refute :erlang.module_loaded(module) + + # As on a node's first start of this server type: the module loads on demand. + assert DurableServer.Supervisor.hibernate_after_for(%{hibernate_after: nil}, module) == 70 + end + test "an invalid module setting fails at compile time" do assert_raise ArgumentError, ~r/hibernate_after must be a positive integer or false/, fn -> Code.compile_string(""" From db8a80cd250ecf2c0c6a12db04862bdfdc1cd99d Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 30 Sep 2026 11:50:30 -0400 Subject: [PATCH 4/4] Configure hibernate_after on the supervisor mount only Per-module settings already live on the supervisor mount (max_children, sticky_placement), so hibernate_after moves there too and the use option goes away: an integer for every module, or a map of Module => ms with an optional :default for modules not listed. The lookup is by module name, so it no longer needs the module loaded. --- lib/durable_server.ex | 29 +---------- lib/durable_server/supervisor.ex | 58 +++++++++++----------- test/durable_server_test.exs | 82 ++++++++------------------------ 3 files changed, 53 insertions(+), 116 deletions(-) diff --git a/lib/durable_server.ex b/lib/durable_server.ex index 47038ba..18c44fd 100644 --- a/lib/durable_server.ex +++ b/lib/durable_server.ex @@ -93,16 +93,6 @@ defmodule DurableServer do ] end - ## `use DurableServer` Options - - - `:vsn` - Required. The current persisted state version, passed to - `load_state/2` for migrations. - - `:hibernate_after` - Milliseconds without a message after which the server - hibernates, compacting its heap to live data. `false` never hibernates, even - when the supervisor sets a default. Unset uses the supervisor's - `:hibernate_after` default. Because it is part of the module, it applies - however the supervisor starts the server, including restarts and rehoming. - ## Advanced Example: Session Manager defmodule UserSessionServer do @@ -921,22 +911,6 @@ defmodule DurableServer do "the current :vsn must be provided:, ie: `use DurableServer, vsn: 1`" end - hibernate_after = - case Keyword.get(opts, :hibernate_after) do - nil -> - nil - - false -> - false - - ms when is_integer(ms) and ms > 0 -> - ms - - other -> - raise ArgumentError, - "hibernate_after must be a positive integer or false, got: #{inspect(other)}" - end - quote do @behaviour DurableServer @vsn unquote(vsn) @@ -944,8 +918,7 @@ defmodule DurableServer do def __durable_server_config__() do %{ - vsn: @vsn, - hibernate_after: unquote(hibernate_after) + vsn: @vsn } end diff --git a/lib/durable_server/supervisor.ex b/lib/durable_server/supervisor.ex index 8fa46ee..a8c8646 100644 --- a/lib/durable_server/supervisor.ex +++ b/lib/durable_server/supervisor.ex @@ -65,11 +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` - Default milliseconds without a message after which a - DurableServer process hibernates, compacting its heap to live data. A module - overrides it with `use DurableServer, hibernate_after: ms`, or opts out with - `hibernate_after: false`. Applies wherever this supervisor starts a child, - including restarts and rehoming. Default: `nil` (never hibernate). + - `: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 @@ -652,11 +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` - Default milliseconds without a message after which a - DurableServer process hibernates, compacting its heap to live data. A module - overrides it with `use DurableServer, hibernate_after: ms`, or opts out with - `hibernate_after: false`. Applies wherever this supervisor starts a child, - including restarts and rehoming. Default: `nil` (never hibernate). + - `: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}`) @@ -4341,30 +4343,32 @@ defmodule DurableServer.Supervisor do 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, got: #{inspect(other)}" + "hibernate_after must be a positive integer or a map of Module => positive integer, got: #{inspect(other)}" end end @doc false - # A module's own `use DurableServer, hibernate_after: ...` wins; `false` opts - # out; otherwise the supervisor's default applies. - def hibernate_after_for(config, module) do - case module_hibernate_after(module) do - ms when is_integer(ms) -> ms - false -> nil - nil -> Map.get(config, :hibernate_after) - end - end + # 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 - # function_exported?/3 is false for a module that is not loaded yet, which - # would silently drop its setting on a first start, so load it first. - defp module_hibernate_after(module) do - if Code.ensure_loaded?(module) and function_exported?(module, :__durable_server_config__, 0), - do: Map.get(module.__durable_server_config__(), :hibernate_after), - else: nil - end + 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 diff --git a/test/durable_server_test.exs b/test/durable_server_test.exs index c2ed266..07ecb0c 100644 --- a/test/durable_server_test.exs +++ b/test/durable_server_test.exs @@ -1750,16 +1750,8 @@ defmodule DurableServerTest do def init(state, info), do: {:ok, Map.put(state, :key, info.key)} end - defmodule SelfHibernatingServer do - use DurableServer, vsn: 1, hibernate_after: 50 - - 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 NeverHibernatingServer do - use DurableServer, vsn: 1, hibernate_after: false + 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) @@ -1812,77 +1804,45 @@ defmodule DurableServerTest do end end - test "a module's own setting applies without a supervisor default" do - sup = start_hibernate_sup(nil) - own = start_idle(sup, SelfHibernatingServer) - default = start_idle(sup, IdleServer) + 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?(own) end) + assert eventually(fn -> hibernating?(listed) end) Process.sleep(300) - refute hibernating?(default) + refute hibernating?(unlisted) # A hibernated server still serves calls. - assert %{key: "hibernate-" <> _} = :sys.get_state(own).user_state + assert %{key: "hibernate-" <> _} = :sys.get_state(listed).user_state end - test "the supervisor default applies to modules that set nothing" do - sup = start_hibernate_sup(50) - pid = start_idle(sup, IdleServer) - assert eventually(fn -> hibernating?(pid) 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 "a module can opt out of the supervisor default" do + test "an integer setting applies to every module" do sup = start_hibernate_sup(50) - opted_out = start_idle(sup, NeverHibernatingServer) - Process.sleep(300) - refute hibernating?(opted_out) + pid = start_idle(sup, OtherIdleServer) + assert eventually(fn -> hibernating?(pid) end) end - test "without any setting servers never hibernate" do + 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 - @tag :tmp_dir - test "a module's setting is found even before the module is loaded", %{tmp_dir: dir} do - [{module, beam}] = - Code.compile_string(""" - defmodule DurableServerTest.OnDiskHibernating do - use DurableServer, vsn: 1, hibernate_after: 70 - - def dump_state(state), do: state - def load_state(_old_vsn, state), do: state - end - """) - - File.write!(Path.join(dir, "#{module}.beam"), beam) - true = :code.add_patha(to_charlist(dir)) - on_exit(fn -> :code.del_path(to_charlist(dir)) end) - - :code.purge(module) - :code.delete(module) - refute :erlang.module_loaded(module) - - # As on a node's first start of this server type: the module loads on demand. - assert DurableServer.Supervisor.hibernate_after_for(%{hibernate_after: nil}, module) == 70 - end - - test "an invalid module setting fails at compile time" do - assert_raise ArgumentError, ~r/hibernate_after must be a positive integer or false/, fn -> - Code.compile_string(""" - defmodule DurableServerTest.BadHibernate do - use DurableServer, vsn: 1, hibernate_after: 0 - end - """) - end - end - test "invalid settings are rejected when the supervisor starts" do Process.flag(:trap_exit, true) - for invalid <- [0, -5, "15000", %{IdleServer => 100}] do + 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])}",