Skip to content

Commit 2157355

Browse files
fahchenclaude
andcommitted
feat(runner/enactment): snapshot self-heal + crash circuit breaker
Wires the storage primitives from the prior commit into the GenServer boot/teardown path: * `init/1` queries `Storage.consecutive_crashes_since_progress/1`. Three or more consecutive `:crash` events without an intervening `Occurrence` flips `enactments.state` to `:exception` (reason `:restart_loop`) and aborts via `:ignore`, preventing the supervisor from spinning up a hot restart loop. * `handle_continue(:populate_state, ...)` routes `{:error, {:snapshot_corrupt, _}}` through `load_or_recover_snapshot/1`. The runner records a non-fatal `:running` log row, deletes the bad snapshot, and replays from the enactment's initial markings; the next `:take_snapshot` rewrites the row. * `terminate/2` calls `Storage.record_crash/2` on any non-`:normal`, non-`:shutdown` exit so the circuit breaker has data on the next boot. Normal and shutdown exits stay silent. Tests in `test/coloured_flow/runner/enactment/error_handling_test.exs` cover all three new flows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 376cb0d commit 2157355

2 files changed

Lines changed: 206 additions & 14 deletions

File tree

lib/coloured_flow/runner/enactment/enactment.ex

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ defmodule ColouredFlow.Runner.Enactment do
5454
alias ColouredFlow.Runner.Storage
5555
alias ColouredFlow.Runner.Telemetry
5656

57+
require Logger
58+
59+
@consecutive_crash_threshold 3
60+
5761
@typep enactment_id() :: Storage.enactment_id()
5862

5963
@typedoc "The markings map of the enactment."
@@ -119,22 +123,28 @@ defmodule ColouredFlow.Runner.Enactment do
119123

120124
state = struct(__MODULE__, options)
121125

122-
{:ok, state, {:continue, :populate_state}}
126+
case Storage.consecutive_crashes_since_progress(state.enactment_id) do
127+
count when count >= @consecutive_crash_threshold ->
128+
exception =
129+
Exceptions.RestartLoop.exception(
130+
enactment_id: state.enactment_id,
131+
count: count
132+
)
133+
134+
:ok = Storage.exception_occurs(state.enactment_id, :restart_loop, exception)
135+
136+
Logger.warning(Exception.message(exception))
137+
138+
:ignore
139+
140+
_count ->
141+
{:ok, state, {:continue, :populate_state}}
142+
end
123143
end
124144

125145
@impl GenServer
126146
def handle_continue(:populate_state, %__MODULE__{} = state) do
127-
snapshot =
128-
case Storage.read_enactment_snapshot(state.enactment_id) do
129-
{:ok, snapshot} ->
130-
snapshot
131-
132-
:error ->
133-
%Snapshot{
134-
version: 0,
135-
markings: Storage.get_initial_markings(state.enactment_id)
136-
}
137-
end
147+
snapshot = load_or_recover_snapshot(state)
138148

139149
snapshot = catchup_snapshot(state.enactment_id, snapshot)
140150
Storage.take_enactment_snapshot(state.enactment_id, snapshot)
@@ -201,6 +211,43 @@ defmodule ColouredFlow.Runner.Enactment do
201211
%{snapshot | version: snapshot.version + steps, markings: markings}
202212
end
203213

214+
@spec load_or_recover_snapshot(state()) :: Snapshot.t()
215+
defp load_or_recover_snapshot(%__MODULE__{} = state) do
216+
case Storage.read_enactment_snapshot(state.enactment_id) do
217+
{:ok, snapshot} ->
218+
snapshot
219+
220+
:error ->
221+
initial_snapshot(state)
222+
223+
{:error, {:snapshot_corrupt, underlying}} ->
224+
exception =
225+
Exceptions.SnapshotCorrupt.exception(
226+
enactment_id: state.enactment_id,
227+
underlying: underlying
228+
)
229+
230+
:ok = Storage.recover_from_corrupt_snapshot(state.enactment_id, exception)
231+
232+
Logger.warning(Exception.message(exception))
233+
234+
emit_event(:exception, state, %{
235+
exception_reason: :snapshot_corrupt,
236+
exception: exception
237+
})
238+
239+
initial_snapshot(state)
240+
end
241+
end
242+
243+
@spec initial_snapshot(state()) :: Snapshot.t()
244+
defp initial_snapshot(%__MODULE__{} = state) do
245+
%Snapshot{
246+
version: 0,
247+
markings: Storage.get_initial_markings(state.enactment_id)
248+
}
249+
end
250+
204251
defp apply_calibration(%WorkitemCalibration{state: %__MODULE__{} = state} = calibration) do
205252
# We don't need to ensure `withdraw_workitems` and `produce_workitems` are atomic,
206253
# because the gen_server will restart if the process crashes.
@@ -573,8 +620,14 @@ defmodule ColouredFlow.Runner.Enactment do
573620
emit_event(:stop, state, %{reason: reason})
574621
end
575622

576-
def terminate(_reason, state) do
577-
emit_event(:stop, state, %{reason: "unknown"})
623+
def terminate(:normal, state) do
624+
emit_event(:stop, state, %{reason: :normal})
625+
end
626+
627+
def terminate(reason, state) do
628+
exception = Exceptions.AbnormalExit.exception(reason: reason)
629+
:ok = Storage.record_crash(state.enactment_id, exception)
630+
emit_event(:stop, state, %{reason: reason})
578631
end
579632

580633
@spec to_map(Enumerable.t(item)) :: %{Place.name() => item} when item: Marking.t()
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
defmodule ColouredFlow.Runner.Enactment.ErrorHandlingTest do
2+
use ColouredFlow.RepoCase, async: true
3+
use ColouredFlow.RunnerHelpers
4+
5+
alias ColouredFlow.Enactment.Marking
6+
alias ColouredFlow.Runner.Enactment, as: EnactmentServer
7+
alias ColouredFlow.Runner.Enactment.Snapshot
8+
alias ColouredFlow.Runner.Exceptions
9+
alias ColouredFlow.Runner.Storage
10+
11+
describe "snapshot_corrupt self-heal" do
12+
setup :setup_flow
13+
setup :setup_enactment
14+
15+
@describetag cpnet: :simple_sequence
16+
17+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
18+
test "deletes the corrupt snapshot row and replays from initial markings",
19+
%{enactment: enactment} do
20+
insert_corrupt_snapshot!(enactment.id, version: 1)
21+
22+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
23+
24+
:ok = wait_enactment_requests_handled!(enactment_server)
25+
26+
logs = Repo.all(Schemas.EnactmentLog, enactment_id: enactment.id)
27+
28+
assert Enum.any?(logs, fn log ->
29+
log.state === :running and
30+
match?(%{reason: :snapshot_corrupt}, log.exception)
31+
end)
32+
33+
[marking] = get_enactment_markings(enactment_server)
34+
assert marking.place === "input"
35+
assert marking.tokens === ~MS[1]
36+
end
37+
38+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
39+
test "rewrites a fresh snapshot after self-heal", %{enactment: enactment} do
40+
insert_corrupt_snapshot!(enactment.id, version: 5)
41+
42+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
43+
:ok = wait_enactment_requests_handled!(enactment_server)
44+
45+
# The runner overwrote the corrupt row on boot. Subsequent reads succeed
46+
# and reflect the replayed state (no occurrences exist yet, so version 0).
47+
assert {:ok, %Snapshot{version: 0}} = Storage.read_enactment_snapshot(enactment.id)
48+
end
49+
end
50+
51+
describe "consecutive crash circuit breaker" do
52+
setup :setup_flow
53+
setup :setup_enactment
54+
55+
@describetag cpnet: :simple_sequence
56+
57+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
58+
test "init aborts via :ignore once crash threshold is exceeded",
59+
%{enactment: enactment} do
60+
for _i <- 1..3 do
61+
:ok =
62+
Storage.record_crash(
63+
enactment.id,
64+
Exceptions.AbnormalExit.exception(reason: :boom)
65+
)
66+
end
67+
68+
# `start_supervised` records `:ignore` as `{:ok, :undefined}`.
69+
assert {:ok, :undefined} =
70+
start_supervised(
71+
{EnactmentServer, [enactment_id: enactment.id]},
72+
id: enactment.id
73+
)
74+
75+
schema = Repo.get(Schemas.Enactment, enactment.id)
76+
assert schema.state === :exception
77+
78+
[%{exception: %{reason: :restart_loop}}] =
79+
Schemas.EnactmentLog
80+
|> Repo.all(enactment_id: enactment.id)
81+
|> Enum.filter(&(&1.state === :exception))
82+
end
83+
84+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
85+
test "starts normally below threshold", %{enactment: enactment} do
86+
:ok =
87+
Storage.record_crash(
88+
enactment.id,
89+
Exceptions.AbnormalExit.exception(reason: :boom)
90+
)
91+
92+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
93+
94+
assert is_pid(enactment_server)
95+
:ok = wait_enactment_requests_handled!(enactment_server)
96+
end
97+
end
98+
99+
describe "abnormal terminate logs :crash" do
100+
setup :setup_flow
101+
setup :setup_enactment
102+
103+
@describetag cpnet: :simple_sequence
104+
105+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
106+
test "non-shutdown stop triggers a :crash log row", %{enactment: enactment} do
107+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
108+
:ok = wait_enactment_requests_handled!(enactment_server)
109+
110+
ref = Process.monitor(enactment_server)
111+
:ok = GenServer.stop(enactment_server, :crash_under_test, 500)
112+
113+
receive do
114+
{:DOWN, ^ref, :process, ^enactment_server, _reason} -> :ok
115+
after
116+
500 -> ExUnit.Assertions.flunk("enactment server did not stop")
117+
end
118+
119+
[crash_log] = Repo.all(Schemas.EnactmentLog, enactment_id: enactment.id)
120+
121+
assert crash_log.state === :running
122+
assert crash_log.exception.reason === :crash
123+
end
124+
end
125+
126+
defp insert_corrupt_snapshot!(enactment_id, version: version) do
127+
now = DateTime.utc_now()
128+
129+
Ecto.Adapters.SQL.query!(
130+
Repo,
131+
"""
132+
INSERT INTO coloured_flow.snapshots
133+
(enactment_id, version, markings, inserted_at, updated_at)
134+
VALUES ($1, $2, $3::jsonb, $4, $5)
135+
""",
136+
[Ecto.UUID.dump!(enactment_id), version, ~s(["not-a-marking-object"]), now, now]
137+
)
138+
end
139+
end

0 commit comments

Comments
 (0)