Skip to content

Commit 72d7767

Browse files
authored
feat(enactment): hibernate_after to reduce idle memory (audit #6) (#77)
1 parent 160e19b commit 72d7767

6 files changed

Lines changed: 194 additions & 8 deletions

File tree

lib/coloured_flow/runner/enactment/enactment.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,18 @@ defmodule ColouredFlow.Runner.Enactment do
7777
field :timeout, timeout(),
7878
enforce: false,
7979
doc: "The enactment timeout; see `ColouredFlow.Runner.Enactment.Lifespan` for details."
80+
81+
field :hibernate_after, timeout(),
82+
enforce: false,
83+
doc:
84+
"The idle duration after which the GenServer hibernates; " <>
85+
"see `ColouredFlow.Runner.Enactment.Lifespan` for details."
8086
end
8187

8288
@type option() ::
8389
{:enactment_id, enactment_id()}
8490
| {:timeout, timeout()}
91+
| {:hibernate_after, timeout()}
8592

8693
@type options() :: [option()]
8794

@@ -92,13 +99,17 @@ defmodule ColouredFlow.Runner.Enactment do
9299
GenServer.start_link(
93100
__MODULE__,
94101
options,
95-
name: Registry.via_name({:enactment, enactment_id})
102+
name: Registry.via_name({:enactment, enactment_id}),
103+
hibernate_after: Lifespan.hibernate_after_from_options(options)
96104
)
97105
end
98106

99107
@impl GenServer
100108
def init(options) do
101-
state = struct(__MODULE__, options)
109+
state =
110+
__MODULE__
111+
|> struct(options)
112+
|> Map.put(:hibernate_after, Lifespan.hibernate_after_from_options(options))
102113

103114
{:ok, state, {:continue, :populate_state}}
104115
end
Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,77 @@
11
defmodule ColouredFlow.Runner.Enactment.Lifespan do
22
@moduledoc """
33
The lifespan of a `ColouredFlow.Runner.Enactment` GenServer can be configured
4-
through the config. The default timeout is `:infinity`.
4+
through the config. Two knobs are exposed:
5+
6+
- `:timeout` — how long the process may sit idle before it shuts itself down.
7+
Defaults to `:infinity` (the enactment is long-lived).
8+
- `:hibernate_after` — how long the process may sit idle before BEAM moves its
9+
state into hibernation, compressing memory at the cost of a small wake-up
10+
latency on the next message. Defaults to `15_000` ms.
11+
12+
Both values follow the same precedence: per-`enactment` option (passed to
13+
`ColouredFlow.Runner.Enactment.start_link/1`) overrides application config,
14+
which in turn overrides the built-in default.
515
616
Example:
717
818
```elixir
919
config :coloured_flow,
10-
ColouredFlow.Runner.Enactment.Lifespan,
11-
timeout: 60 * 1000 # 1 minute
20+
ColouredFlow.Runner.Enactment,
21+
timeout: 60 * 1000, # 1 minute
22+
hibernate_after: 15 * 1000 # 15 seconds
1223
```
24+
25+
> #### `:timeout` shadows `:hibernate_after` {: .info}
26+
>
27+
> Per OTP `gen_server` semantics, `:hibernate_after` only takes effect when the
28+
> GenServer's `noreply` timeout is `:infinity`. With a finite `:timeout`, BEAM
29+
> uses that as the receive timeout (which fires the inactivity-shutdown path)
30+
> and ignores `:hibernate_after`. Hibernation is therefore most useful with the
31+
> default `:timeout` of `:infinity`.
1332
"""
1433

1534
alias ColouredFlow.Runner.Enactment
1635

1736
@default_timeout :infinity
37+
@default_hibernate_after 15_000
1838

1939
@spec timeout(Enactment.state()) :: timeout()
2040
def timeout(%Enactment{timeout: nil}) do
21-
:coloured_flow
22-
|> Application.get_env(ColouredFlow.Runner.Enactment, [])
23-
|> Keyword.get(:timeout, @default_timeout)
41+
fetch_env(:timeout, @default_timeout)
2442
end
2543

2644
def timeout(%Enactment{timeout: timeout}), do: timeout
45+
46+
@doc """
47+
Resolves the `:hibernate_after` value for a running enactment, falling back to
48+
application config and the built-in default when no per-enactment value was
49+
supplied.
50+
"""
51+
@spec hibernate_after(Enactment.state()) :: timeout()
52+
def hibernate_after(%Enactment{hibernate_after: nil}) do
53+
fetch_env(:hibernate_after, @default_hibernate_after)
54+
end
55+
56+
def hibernate_after(%Enactment{hibernate_after: hibernate_after}), do: hibernate_after
57+
58+
@doc """
59+
Resolves the `:hibernate_after` value at `start_link/1` time, before the
60+
GenServer state struct exists. Mirrors `hibernate_after/1` but reads from the
61+
raw options keyword list.
62+
"""
63+
@spec hibernate_after_from_options(Enactment.options()) :: timeout()
64+
def hibernate_after_from_options(options) when is_list(options) do
65+
case Keyword.get(options, :hibernate_after) do
66+
nil -> fetch_env(:hibernate_after, @default_hibernate_after)
67+
value -> value
68+
end
69+
end
70+
71+
@spec fetch_env(atom(), timeout()) :: timeout()
72+
defp fetch_env(key, default) do
73+
:coloured_flow
74+
|> Application.get_env(ColouredFlow.Runner.Enactment, [])
75+
|> Keyword.get(key, default)
76+
end
2777
end

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ defmodule ColouredFlow.MixProject do
5252
runtime: false
5353
]},
5454
{:kino, "~> 0.14.1", only: [:dev, :test]},
55+
{:mimic, "~> 1.7", only: :test},
5556
{:postgrex, ">= 0.0.0"},
5657
{:telemetry, "~> 1.0"},
5758
{:typed_structor, "~> 0.6", override: true}

mix.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
"ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"},
1616
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
1717
"fss": {:hex, :fss, "0.1.1", "9db2344dbbb5d555ce442ac7c2f82dd975b605b50d169314a20f08ed21e08642", [:mix], [], "hexpm", "78ad5955c7919c3764065b21144913df7515d52e228c09427a004afe9c1a16b0"},
18+
"ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"},
1819
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
1920
"jet_credo": {:git, "https://github.com/Byzanteam/jet_credo.git", "7e5855de2e8b41abfb0a1f5870bbc768a325f4e8", [ref: "7e5855de2e8b41abfb0a1f5870bbc768a325f4e8"]},
2021
"kino": {:hex, :kino, "0.14.2", "46c5da03f2d62dc119ec5e1c1493f409f08998eac26015ecdfae322ffff46d76", [:mix], [{:fss, "~> 0.1.0", [hex: :fss, repo: "hexpm", optional: false]}, {:nx, "~> 0.1", [hex: :nx, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "f54924dd0800ee8b291fe437f942889e90309eb3541739578476f53c1d79c968"},
2122
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
2223
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
2324
"makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
25+
"mimic": {:hex, :mimic, "1.12.0", "34c9d1fb8e756df09ca5f96861d273f2bb01063df1a6a51a4c101f9ad7f07a9c", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "eaa43d495d6f3bc8099b28886e05a1b09a2a6be083f6385c3abc17599e5e2c43"},
2426
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
2527
"postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
2628
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"},

test/coloured_flow/runner/enactment/lifespan_test.exs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ defmodule ColouredFlow.Runner.Enactment.LifespanTest do
44

55
import ColouredFlow.MultiSet, only: :sigils
66

7+
alias ColouredFlow.Runner.Enactment.Lifespan
8+
79
describe "terminated due to inactivity" do
810
setup :setup_flow
911
setup :setup_enactment
@@ -28,4 +30,119 @@ defmodule ColouredFlow.Runner.Enactment.LifespanTest do
2830
}
2931
end
3032
end
33+
34+
describe "hibernate_after" do
35+
setup :setup_flow
36+
setup :setup_enactment
37+
38+
@describetag cpnet: :simple_sequence
39+
40+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
41+
test "hibernates the GenServer after the configured idle duration",
42+
%{enactment: enactment} do
43+
# `hibernate_after` only kicks in when the GenServer's `noreply` timeout
44+
# is `:infinity` (see OTP `gen_server` `loop/5`); a finite timeout takes
45+
# priority and produces a synthetic `:timeout` message instead. The
46+
# production default for `Lifespan.timeout/1` is already `:infinity`, but
47+
# the test config sets it to `60_000`, so we override per-enactment to
48+
# exercise the hibernate path here.
49+
[enactment_server: enactment_server] =
50+
start_enactment(%{enactment: enactment}, timeout: :infinity, hibernate_after: 100)
51+
52+
# Make sure the boot continues have completed before we start measuring
53+
# idle time, so the hibernate timer is actually counting down on an
54+
# empty mailbox.
55+
:ok = wait_enactment_requests_handled!(enactment_server)
56+
57+
assert eventually(fn -> hibernated?(enactment_server) end),
58+
"expected #{inspect(enactment_server)} to hibernate within the deadline; " <>
59+
"current_function=#{inspect(Process.info(enactment_server, :current_function))}, " <>
60+
"status=#{inspect(Process.info(enactment_server, :status))}, " <>
61+
"messages=#{inspect(Process.info(enactment_server, :messages))}"
62+
end
63+
end
64+
65+
describe "hibernate_after resolution" do
66+
use Mimic
67+
68+
test "hibernate_after_from_options prefers explicit option" do
69+
expect_enactment_env(hibernate_after: 7_000)
70+
71+
assert Lifespan.hibernate_after_from_options(hibernate_after: 42) === 42
72+
end
73+
74+
test "hibernate_after_from_options falls back to application env" do
75+
expect_enactment_env(hibernate_after: 7_000)
76+
77+
assert Lifespan.hibernate_after_from_options([]) === 7_000
78+
end
79+
80+
test "hibernate_after_from_options falls back to the built-in default" do
81+
expect_enactment_env([])
82+
83+
assert Lifespan.hibernate_after_from_options([]) === 15_000
84+
end
85+
86+
test "hibernate_after/1 prefers per-state value" do
87+
expect_enactment_env(hibernate_after: 7_000)
88+
89+
state = %Enactment{enactment_id: "irrelevant", hibernate_after: 42}
90+
91+
assert Lifespan.hibernate_after(state) === 42
92+
end
93+
94+
test "hibernate_after/1 falls back to application env when state value is nil" do
95+
expect_enactment_env(hibernate_after: 7_000)
96+
97+
state = %Enactment{enactment_id: "irrelevant"}
98+
99+
assert Lifespan.hibernate_after(state) === 7_000
100+
end
101+
end
102+
103+
# A hibernated GenServer is observable in two ways:
104+
#
105+
# - `:current_function` becomes `{:gen_server, :loop_hibernate, _}` once the
106+
# process has fully entered the hibernate loop, OR `{:erlang, :hibernate, _}`
107+
# while it is in the middle of the transition.
108+
# - `:status` is `:waiting` and the heap is compacted.
109+
#
110+
# We accept either current_function form so the assertion is robust against
111+
# the exact moment we sample.
112+
defp hibernated?(pid) do
113+
case Process.info(pid, :current_function) do
114+
{:current_function, {:gen_server, :loop_hibernate, _arity}} -> true
115+
{:current_function, {:erlang, :hibernate, _arity}} -> true
116+
_other -> false
117+
end
118+
end
119+
120+
# Poll the predicate up to ~1s, in 20ms slices, giving the runtime time to
121+
# transition into hibernation without burning a fixed long sleep.
122+
defp eventually(fun, deadline_ms \\ 1_000, slice_ms \\ 20) do
123+
wait_until(fun, System.monotonic_time(:millisecond) + deadline_ms, slice_ms)
124+
end
125+
126+
defp wait_until(fun, deadline, slice) do
127+
if fun.() do
128+
true
129+
else
130+
if System.monotonic_time(:millisecond) >= deadline do
131+
false
132+
else
133+
Process.sleep(slice)
134+
wait_until(fun, deadline, slice)
135+
end
136+
end
137+
end
138+
139+
# Stub `Application.get_env/3` for the runner-enactment keyword list. Used as
140+
# `stub` (not `expect`) because not every test under this describe block hits
141+
# the env path — tests that pass an explicit per-state value short-circuit
142+
# before reaching `Application.get_env/3`.
143+
defp expect_enactment_env(env) when is_list(env) do
144+
Mimic.stub(Application, :get_env, fn
145+
:coloured_flow, ColouredFlow.Runner.Enactment, [] -> env
146+
end)
147+
end
31148
end

test/test_helper.exs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ end
1212

1313
Ecto.Adapters.SQL.Sandbox.mode(ColouredFlow.TestRepo, :manual)
1414

15+
# Mimic-mockable modules. Tests that want to override behaviour call
16+
# `Mimic.copy/1` on these in their `setup` block (or `use Mimic` at the
17+
# top of the file) and `Mimic.expect/3` per test.
18+
Mimic.copy(Application)
19+
1520
ExUnit.start(capture_log: true)

0 commit comments

Comments
 (0)