Skip to content

Commit 363b52a

Browse files
fahchenclaude
andcommitted
feat(runner): state-drift Tier 2 funnel via storage tuples
Detect divergence between the runner's in-memory workitem cache and the database without rescuing exceptions in the GenServer. Storage layer: * `transition_workitems/2` and `complete_workitems/4` now return `{:error, {:state_drift, ctx}}` only when the Multi `:result` step rejects an `:unexpected_updated_rows` mismatch. Other Multi failures raise so they surface as crashes counted by the circuit breaker. * `Storage.write_result()` and `Storage.state_drift_context()` types document the new contract; the keyword `ctx` carries `expected:` / `actual:` row counts that flow through to the persisted exception. Enactment layer: * `persist_or_drift!/3` funnels `{:error, {:state_drift, _}}` into `Storage.exception_occurs(:state_drift, _)` plus `exit({:shutdown, {:fatal, :state_drift}})`. The shutdown form keeps the supervisor's `max_restarts` counter untouched, so a single drifted enactment does not snowball into supervisor self-termination. * `apply_calibration/1`, `start_workitems/2`, and `complete_workitems/3` pipe their storage writes through `persist_or_drift!`. New `Exceptions.StateDrift` carries the action and ctx so operators see the row-count discrepancy in `enactment_logs.exception`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2157355 commit 363b52a

5 files changed

Lines changed: 195 additions & 60 deletions

File tree

lib/coloured_flow/runner/enactment/enactment.ex

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,31 @@ defmodule ColouredFlow.Runner.Enactment do
248248
}
249249
end
250250

251+
# Funnel storage writes that may report `:state_drift`. On drift, persist
252+
# `:exception` state and exit via `{:shutdown, _}` so the supervisor does
253+
# not restart and `terminate/2` does not record a `:crash`.
254+
@spec persist_or_drift!(Storage.write_result(), state(), Workitem.transition_action()) ::
255+
:ok | no_return()
256+
defp persist_or_drift!(:ok, %__MODULE__{}, _action), do: :ok
257+
258+
defp persist_or_drift!({:error, {:state_drift, ctx}}, %__MODULE__{} = state, action) do
259+
exception =
260+
Exceptions.StateDrift.exception(
261+
enactment_id: state.enactment_id,
262+
action: action,
263+
context: ctx
264+
)
265+
266+
:ok = Storage.exception_occurs(state.enactment_id, :state_drift, exception)
267+
268+
emit_event(:exception, state, %{
269+
exception_reason: :state_drift,
270+
exception: exception
271+
})
272+
273+
exit({:shutdown, {:fatal, :state_drift}})
274+
end
275+
251276
defp apply_calibration(%WorkitemCalibration{state: %__MODULE__{} = state} = calibration) do
252277
# We don't need to ensure `withdraw_workitems` and `produce_workitems` are atomic,
253278
# because the gen_server will restart if the process crashes.
@@ -266,10 +291,14 @@ defmodule ColouredFlow.Runner.Enactment do
266291

267292
Enum.each(grouped_wokitems, fn
268293
{:enabled, workitems} ->
269-
:ok = Storage.withdraw_workitems(workitems, action: :withdraw)
294+
workitems
295+
|> Storage.withdraw_workitems(action: :withdraw)
296+
|> persist_or_drift!(state, :withdraw)
270297

271298
{:started, workitems} ->
272-
:ok = Storage.withdraw_workitems(workitems, action: :withdraw_s)
299+
workitems
300+
|> Storage.withdraw_workitems(action: :withdraw_s)
301+
|> persist_or_drift!(state, :withdraw_s)
273302
end)
274303

275304
{:ok, workitems, %{workitems: workitems}}
@@ -349,7 +378,9 @@ defmodule ColouredFlow.Runner.Enactment do
349378
:start_workitems
350379
|> with_span(state, %{workitem_ids: workitem_ids}, fn ->
351380
with {:ok, {started_workitems, new_state}} <- start_workitems(state, workitem_ids) do
352-
:ok = Storage.start_workitems(started_workitems, action: :start)
381+
started_workitems
382+
|> Storage.start_workitems(action: :start)
383+
|> persist_or_drift!(state, :start)
353384

354385
{:ok, {started_workitems, new_state}, %{workitems: started_workitems}}
355386
end
@@ -385,13 +416,13 @@ defmodule ColouredFlow.Runner.Enactment do
385416
{:ok, {workitem_occurrences, new_state, calibration_options}} <-
386417
complete_workitems(state, workitem_ids, workitem_id_and_outputs)
387418
) do
388-
:ok =
389-
Storage.complete_workitems(
390-
new_state.enactment_id,
391-
new_state.version,
392-
workitem_occurrences,
393-
action: transition_action
394-
)
419+
new_state.enactment_id
420+
|> Storage.complete_workitems(
421+
new_state.version,
422+
workitem_occurrences,
423+
action: transition_action
424+
)
425+
|> persist_or_drift!(state, transition_action)
395426

396427
completed_workitems = Enum.map(workitem_occurrences, &elem(&1, 0))
397428

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule ColouredFlow.Runner.Exceptions.StateDrift do
2+
@moduledoc """
3+
The runner detected divergence between its in-memory state and what the storage
4+
layer believed: a Multi rolled back, or the number of rows updated did not match
5+
the number of workitems we expected to transition.
6+
7+
Recovery is unsafe in-process: the workitem cache is stale. The runner records
8+
the event, marks `enactments.state = :exception`, and exits with
9+
`{:shutdown, {:fatal, :state_drift}}`. Operators can clear it via reoffer.
10+
"""
11+
12+
use TypedStructor
13+
14+
alias ColouredFlow.Runner.Enactment.Workitem
15+
alias ColouredFlow.Runner.Storage
16+
17+
typed_structor definer: :defexception, enforce: true do
18+
field :enactment_id, Storage.enactment_id()
19+
field :action, Workitem.transition_action()
20+
field :context, Storage.state_drift_context(), default: []
21+
end
22+
23+
@impl Exception
24+
def exception(arguments) do
25+
struct!(__MODULE__, arguments)
26+
end
27+
28+
@impl Exception
29+
def message(%__MODULE__{} = exception) do
30+
"State drift detected while handling action #{inspect(exception.action)} " <>
31+
"in enactment #{inspect(exception.enactment_id)}: " <>
32+
"the storage rejected the transition because the in-memory workitem cache " <>
33+
"is out of sync with the database. " <>
34+
"Context: #{inspect(exception.context)}."
35+
end
36+
end

lib/coloured_flow/runner/storage/default.ex

Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,6 @@ defmodule ColouredFlow.Runner.Storage.Default do
1919
@type enactment_id() :: ColouredFlow.Runner.Storage.enactment_id()
2020
@type flow_id() :: ColouredFlow.Runner.Storage.flow_id()
2121

22-
# Exception types raised by `Codec.Marking.decode/2` when it encounters a
23-
# payload it cannot parse. Listed explicitly so that connection errors
24-
# (`DBConnection.ConnectionError`, `Postgrex.Error`, etc.) are NOT
25-
# reclassified as `:snapshot_corrupt`.
26-
@snapshot_corrupt_exceptions [
27-
ArgumentError,
28-
FunctionClauseError,
29-
KeyError,
30-
MatchError,
31-
Protocol.UndefinedError,
32-
RuntimeError
33-
]
34-
3522
@impl ColouredFlow.Runner.Storage
3623
def get_flow_by_enactment(enactment_id) do
3724
Schemas.Flow
@@ -242,12 +229,13 @@ defmodule ColouredFlow.Runner.Storage.Default do
242229
end
243230

244231
@typep transition_option() :: ColouredFlow.Runner.Storage.transition_option()
232+
@typep write_result() :: ColouredFlow.Runner.Storage.write_result()
245233

246234
@doc """
247235
Transition a workitem from one state to another. This is a shortcut for
248236
`ColouredFlow.Runner.Default.transition_workitems/2`.
249237
"""
250-
@spec transition_workitem(Workitem.t(), [transition_option()]) :: :ok
238+
@spec transition_workitem(Workitem.t(), [transition_option()]) :: write_result()
251239
def transition_workitem(workitem, options) do
252240
workitem
253241
|> List.wrap()
@@ -258,7 +246,7 @@ defmodule ColouredFlow.Runner.Storage.Default do
258246
Transition the workitems from one state to another in accordance with the state
259247
machine (See `t:ColouredFlow.Runner.Enactment.Workitem.state/0`).
260248
"""
261-
@spec transition_workitems([Workitem.t()], [transition_option()]) :: :ok
249+
@spec transition_workitems([Workitem.t()], [transition_option()]) :: write_result()
262250
def transition_workitems([], _options), do: :ok
263251

264252
def transition_workitems(workitems, options) when is_list(workitems) do
@@ -271,13 +259,18 @@ defmodule ColouredFlow.Runner.Storage.Default do
271259
{:ok, _changes} ->
272260
:ok
273261

274-
{
275-
:error,
276-
:result,
277-
{:unexpected_updated_rows, exception_ctx},
278-
_changes_so_far
279-
} ->
280-
unexpected_updated_rows!(workitems, action, exception_ctx)
262+
{:error, :result, {:unexpected_updated_rows, ctx}, _changes} ->
263+
{:error, {:state_drift, ctx}}
264+
265+
{:error, op, value, changes} ->
266+
raise """
267+
Workitem transition failed unexpectedly.
268+
269+
Action: #{inspect(action)}
270+
Operation: #{inspect(op)}
271+
Value: #{inspect(value)}
272+
Changes: #{inspect(changes)}
273+
"""
281274
end
282275
end
283276

@@ -346,21 +339,6 @@ defmodule ColouredFlow.Runner.Storage.Default do
346339
defp get_from_state(unquote(to), unquote(action)), do: unquote(from)
347340
end
348341

349-
defp unexpected_updated_rows!(workitems, transition, options) do
350-
# When the actual number is not equal to the expected number,
351-
# it means the workitems in the gen_server are not consistent with the database.
352-
# So we just raise an error to crash the process, and let the supervisor
353-
# restart the gen_server and retry.
354-
expected = Keyword.fetch!(options, :expected)
355-
actual = Keyword.fetch!(options, :actual)
356-
357-
raise """
358-
The number of workitems to #{transition} is not equal to the actual number.
359-
Expected: #{expected}, Actual: #{actual}
360-
Workitems: #{Enum.map_join(workitems, ", ", & &1.id)}
361-
"""
362-
end
363-
364342
@impl ColouredFlow.Runner.Storage
365343
def start_workitems(workitems, options) do
366344
transition_workitems(workitems, options)
@@ -395,13 +373,18 @@ defmodule ColouredFlow.Runner.Storage.Default do
395373
{:ok, _result} ->
396374
:ok
397375

398-
{
399-
:error,
400-
:result,
401-
{:unexpected_updated_rows, exception_ctx},
402-
_changes_so_far
403-
} ->
404-
unexpected_updated_rows!(completed_workitems, action, exception_ctx)
376+
{:error, :result, {:unexpected_updated_rows, ctx}, _changes} ->
377+
{:error, {:state_drift, ctx}}
378+
379+
{:error, op, value, changes} ->
380+
raise """
381+
Workitem completion failed unexpectedly.
382+
383+
Action: #{inspect(action)}
384+
Operation: #{inspect(op)}
385+
Value: #{inspect(value)}
386+
Changes: #{inspect(changes)}
387+
"""
405388
end
406389
end
407390

@@ -447,6 +430,19 @@ defmodule ColouredFlow.Runner.Storage.Default do
447430
:ok
448431
end
449432

433+
# Exception types raised by `Codec.Marking.decode/2` when it encounters a
434+
# payload it cannot parse. Listed explicitly so that connection errors
435+
# (`DBConnection.ConnectionError`, `Postgrex.Error`, etc.) are NOT
436+
# reclassified as `:snapshot_corrupt`.
437+
@snapshot_corrupt_exceptions [
438+
ArgumentError,
439+
FunctionClauseError,
440+
KeyError,
441+
MatchError,
442+
Protocol.UndefinedError,
443+
RuntimeError
444+
]
445+
450446
@impl ColouredFlow.Runner.Storage
451447
def read_enactment_snapshot(enactment_id) do
452448
# Codec decoding runs inside `Repo.get_by/2` when Ecto loads the

lib/coloured_flow/runner/storage/storage.ex

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ defmodule ColouredFlow.Runner.Storage do
2525
@type enactment_id() :: Ecto.UUID.t()
2626
@type flow_id() :: Ecto.UUID.t()
2727

28+
@typedoc """
29+
Mutating workitem operations may return `{:error, {:state_drift, ctx}}` when the
30+
in-memory state diverges from storage (Multi `:result` step rejecting an
31+
`:unexpected_updated_rows` mismatch). Callers funnel this into a Tier 2 fatal
32+
exit; the keyword `ctx` carries `expected:` / `actual:` row counts so operators
33+
see the discrepancy in the persisted exception.
34+
"""
35+
@type state_drift_context() :: [expected: pos_integer(), actual: non_neg_integer()]
36+
@type write_result() :: :ok | {:error, {:state_drift, state_drift_context()}}
37+
2838
@doc """
2939
Get the flow of an enactment.
3040
"""
@@ -101,21 +111,21 @@ defmodule ColouredFlow.Runner.Storage do
101111
@callback start_workitems(
102112
started_workitems :: [Workitem.t(:started)],
103113
options :: [transition_option()]
104-
) :: :ok
114+
) :: write_result()
105115

106116
@doc group: :workitem
107117
@callback withdraw_workitems(
108118
withdrawn_workitems :: [Workitem.t(:withdrawn)],
109119
options :: [transition_option()]
110-
) :: :ok
120+
) :: write_result()
111121

112122
@doc group: :workitem
113123
@callback complete_workitems(
114124
enactment_id(),
115125
current_version :: non_neg_integer(),
116126
workitem_occurrences :: [{Workitem.t(:completed), Occurrence.t()}],
117127
options :: [transition_option()]
118-
) :: :ok
128+
) :: write_result()
119129

120130
@doc """
121131
Takes a snapshot of the given enactment.
@@ -219,13 +229,13 @@ defmodule ColouredFlow.Runner.Storage do
219229
end
220230

221231
@doc false
222-
@spec start_workitems([Workitem.t(:started)], [transition_option()]) :: :ok
232+
@spec start_workitems([Workitem.t(:started)], [transition_option()]) :: write_result()
223233
def start_workitems(workitems, options) do
224234
__storage__().start_workitems(workitems, options)
225235
end
226236

227237
@doc false
228-
@spec withdraw_workitems([Workitem.t(:withdrawn)], [transition_option()]) :: :ok
238+
@spec withdraw_workitems([Workitem.t(:withdrawn)], [transition_option()]) :: write_result()
229239
def withdraw_workitems(workitems, options) do
230240
__storage__().withdraw_workitems(workitems, options)
231241
end
@@ -236,7 +246,7 @@ defmodule ColouredFlow.Runner.Storage do
236246
current_version :: non_neg_integer(),
237247
workitem_occurrences :: [{Workitem.t(:completed), Occurrence.t()}],
238248
[transition_option()]
239-
) :: :ok
249+
) :: write_result()
240250
def complete_workitems(enactment_id, current_version, workitem_occurrences, options) do
241251
__storage__().complete_workitems(enactment_id, current_version, workitem_occurrences, options)
242252
end

test/coloured_flow/runner/enactment/error_handling_test.exs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule ColouredFlow.Runner.Enactment.ErrorHandlingTest do
22
use ColouredFlow.RepoCase, async: true
33
use ColouredFlow.RunnerHelpers
44

5+
import Ecto.Query, only: [from: 2]
6+
57
alias ColouredFlow.Enactment.Marking
68
alias ColouredFlow.Runner.Enactment, as: EnactmentServer
79
alias ColouredFlow.Runner.Enactment.Snapshot
@@ -96,6 +98,66 @@ defmodule ColouredFlow.Runner.Enactment.ErrorHandlingTest do
9698
end
9799
end
98100

101+
describe "state_drift fatal funnel" do
102+
setup :setup_flow
103+
setup :setup_enactment
104+
105+
@describetag cpnet: :simple_sequence
106+
107+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
108+
test "start_workitems drift exits with :shutdown and persists :state_drift",
109+
%{enactment: enactment} do
110+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
111+
112+
[%Enactment.Workitem{state: :enabled} = workitem] =
113+
get_enactment_workitems(enactment_server)
114+
115+
# Delete the workitem row out from under the GenServer to simulate drift.
116+
delete_query = from(w in Schemas.Workitem, where: w.id == ^workitem.id)
117+
Repo.delete_all(delete_query)
118+
119+
ref = Process.monitor(enactment_server)
120+
catch_exit(GenServer.call(enactment_server, {:start_workitems, [workitem.id]}))
121+
122+
assert_receive {:DOWN, ^ref, :process, ^enactment_server,
123+
{:shutdown, {:fatal, :state_drift}}},
124+
500
125+
126+
schema = Repo.get(Schemas.Enactment, enactment.id)
127+
assert schema.state === :exception
128+
129+
[exception_log] =
130+
Schemas.EnactmentLog
131+
|> Repo.all(enactment_id: enactment.id)
132+
|> Enum.filter(&(&1.state === :exception))
133+
134+
assert exception_log.exception.reason === :state_drift
135+
end
136+
137+
@tag initial_markings: [%Marking{place: "input", tokens: ~MS[1]}]
138+
test "complete_workitems drift exits with :shutdown and persists :state_drift",
139+
%{enactment: enactment} do
140+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
141+
142+
[workitem] = get_enactment_workitems(enactment_server)
143+
workitem = start_workitem(workitem, enactment_server)
144+
145+
# Delete the started workitem row out from under the GenServer.
146+
delete_query = from(w in Schemas.Workitem, where: w.id == ^workitem.id)
147+
Repo.delete_all(delete_query)
148+
149+
ref = Process.monitor(enactment_server)
150+
catch_exit(GenServer.call(enactment_server, {:complete_workitems, %{workitem.id => []}}))
151+
152+
assert_receive {:DOWN, ^ref, :process, ^enactment_server,
153+
{:shutdown, {:fatal, :state_drift}}},
154+
500
155+
156+
schema = Repo.get(Schemas.Enactment, enactment.id)
157+
assert schema.state === :exception
158+
end
159+
end
160+
99161
describe "abnormal terminate logs :crash" do
100162
setup :setup_flow
101163
setup :setup_enactment

0 commit comments

Comments
 (0)