Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
The post-append fan-out that fires durable flows from arriving
activities (fed-sx-triggers-loop.md Phases 2+3), native into next/flow
— no cross-guest FFI.
- pipeline.erl: apply_triggers/3 runs AFTER the kernel append (rejected
activities never reach it). It looks the activity's type up in the
trigger registry, drops specs whose guard/actor-scope fails or whose
{activity_cid, trigger_cid} pair already fired (federation can deliver
the same activity twice — dedup is keyed on that pair, read from the
actor's :triggers_fired), and dispatches the rest. Returns the audit
triples for the kernel to fold into :triggers_fired + its projection.
Must not be called inside a `try` (it does gen_server:calls, which
deadlock the scheduler inside a try); running post-append in its own
step satisfies that.
- flow_dispatch.erl: bridges a matched trigger to flow_store:start, with
the activity bound into the flow's input env. guard_passes/3 gates on
actor-scope + guard. Failures (unknown flow, crashing first step) come
back as {error, _}, never raised — one flow can't take down the rest.
- flow_store.erl: drive wrapped in try (the drive is pure, so the try is
safe) so a flow whose step raises yields {error, {flow_crashed, _}}
instead of crashing the store.
Tests: flow_dispatch.sh (12), pipeline_triggers.sh (10). lib/erlang
771/771, next/flow 34/34.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.7 KiB
Erlang
162 lines
6.7 KiB
Erlang
-module(flow_store).
|
|
-export([start_link/0, start_link/1, stop/0,
|
|
register_flow/2, resolve_flow/1, registered_flows/0,
|
|
start/2, resume/2, status/1, instances/0]).
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
|
-behaviour(gen_server).
|
|
|
|
%% flow-on-erlang durable store — the named-flow registry plus the
|
|
%% instance table that makes suspend/resume durable. flow.erl is the
|
|
%% pure replay driver; this gen_server is the stateful shell around it,
|
|
%% holding the registry (so triggers can reference flows by name, and
|
|
%% so an instance can be re-resolved + replayed after a restart) and
|
|
%% each instance's accumulated replay log.
|
|
%%
|
|
%% Crucially the driver stays OUT of any blocking context: start/resume
|
|
%% call flow:drive/3 (pure — no receive, no gen_server:call) from inside
|
|
%% handle_call, and the only message-passing is the caller's
|
|
%% gen_server:call into this store. (A blocking receive inside a `try`
|
|
%% deadlocks this cooperative scheduler, so the engine never does one.)
|
|
%%
|
|
%% State: {Registry, Instances, NextId}
|
|
%% Registry = [{Name, FlowFun}, ...]
|
|
%% Instances = [{Id, {Name, Input, Log, Status}}, ...]
|
|
%% Status = {suspended, Tag} | {done, Result}
|
|
%% Log = [{Tag, ResolvedValue}, ...] (the replay log — plain
|
|
%% data, so an instance is fully described by its log and
|
|
%% survives process restart by re-driving the named flow)
|
|
%%
|
|
%% v1 backs the store in gen_server memory; persisting the instance
|
|
%% logs to the kernel's durable log (so flows survive an OS restart) is
|
|
%% a later layer — the data shape is already restart-ready.
|
|
|
|
start_link() ->
|
|
start_link([]).
|
|
|
|
start_link(InitialFlows) ->
|
|
Pid = gen_server:start_link(flow_store, [InitialFlows]),
|
|
erlang:register(flow_store, Pid),
|
|
Pid.
|
|
|
|
stop() ->
|
|
R = gen_server:call(flow_store, '$gen_stop'),
|
|
erlang:unregister(flow_store),
|
|
R.
|
|
|
|
%% register_flow(Name, Flow) — register a named flow (a node fun). Named
|
|
%% rather than `register` to avoid the erlang:register/2 auto-import.
|
|
register_flow(Name, Flow) ->
|
|
gen_server:call(flow_store, {register_flow, Name, Flow}).
|
|
|
|
resolve_flow(Name) ->
|
|
gen_server:call(flow_store, {resolve_flow, Name}).
|
|
|
|
registered_flows() ->
|
|
gen_server:call(flow_store, registered_flows).
|
|
|
|
%% start(Name, Input) -> {ok, Id, Result} | {error, no_such_flow}.
|
|
%% Result is {flow_done, V} | {flow_suspended, Tag}; the instance is
|
|
%% recorded either way so a suspended flow can be resumed by Id.
|
|
start(Name, Input) ->
|
|
gen_server:call(flow_store, {start, Name, Input}).
|
|
|
|
%% resume(Id, Value) -> {ok, Result} | {error, Reason}. Resolves the
|
|
%% instance's current suspend tag with Value (appends {Tag, Value} to
|
|
%% its replay log) and re-drives from the top.
|
|
resume(Id, Value) ->
|
|
gen_server:call(flow_store, {resume, Id, Value}).
|
|
|
|
%% status(Id) -> {ok, {suspended, Tag}} | {ok, {done, Result}} | not_found
|
|
status(Id) ->
|
|
gen_server:call(flow_store, {status, Id}).
|
|
|
|
instances() ->
|
|
gen_server:call(flow_store, instances).
|
|
|
|
%% ── gen_server ──────────────────────────────────────────────────
|
|
|
|
init([InitialFlows]) ->
|
|
{ok, {InitialFlows, [], 1}}.
|
|
|
|
handle_call({register_flow, Name, Flow}, _From, {Reg, Ins, N}) ->
|
|
{reply, ok, {set_keyed(Name, Flow, Reg), Ins, N}};
|
|
handle_call({resolve_flow, Name}, _From, {Reg, Ins, N}) ->
|
|
{reply, find_keyed(Name, Reg), {Reg, Ins, N}};
|
|
handle_call(registered_flows, _From, {Reg, Ins, N}) ->
|
|
{reply, [Name || {Name, _} <- Reg], {Reg, Ins, N}};
|
|
handle_call({start, Name, Input}, _From, {Reg, Ins, N}) ->
|
|
case find_keyed(Name, Reg) of
|
|
not_found ->
|
|
{reply, {error, no_such_flow}, {Reg, Ins, N}};
|
|
{ok, Flow} ->
|
|
case safe_drive(Flow, Input, []) of
|
|
{ok, R} ->
|
|
Status = result_status(R),
|
|
Ins2 = set_keyed(N, {Name, Input, [], Status}, Ins),
|
|
{reply, {ok, N, R}, {Reg, Ins2, N + 1}};
|
|
{error, Crash} ->
|
|
{reply, {error, {flow_crashed, Crash}}, {Reg, Ins, N}}
|
|
end
|
|
end;
|
|
handle_call({resume, Id, Value}, _From, {Reg, Ins, N}) ->
|
|
case find_keyed(Id, Ins) of
|
|
not_found ->
|
|
{reply, {error, no_such_instance}, {Reg, Ins, N}};
|
|
{ok, {_Name, _Input, _Log, {done, _}}} ->
|
|
{reply, {error, already_done}, {Reg, Ins, N}};
|
|
{ok, {Name, Input, Log, {suspended, Tag}}} ->
|
|
case find_keyed(Name, Reg) of
|
|
not_found ->
|
|
{reply, {error, no_such_flow}, {Reg, Ins, N}};
|
|
{ok, Flow} ->
|
|
NewLog = log_append(Log, Tag, Value),
|
|
case safe_drive(Flow, Input, NewLog) of
|
|
{ok, R} ->
|
|
Status = result_status(R),
|
|
Ins2 = set_keyed(Id, {Name, Input, NewLog, Status}, Ins),
|
|
{reply, {ok, R}, {Reg, Ins2, N}};
|
|
{error, Crash} ->
|
|
{reply, {error, {flow_crashed, Crash}}, {Reg, Ins, N}}
|
|
end
|
|
end
|
|
end;
|
|
handle_call({status, Id}, _From, {Reg, Ins, N}) ->
|
|
case find_keyed(Id, Ins) of
|
|
{ok, {_Name, _Input, _Log, Status}} -> {reply, {ok, Status}, {Reg, Ins, N}};
|
|
not_found -> {reply, not_found, {Reg, Ins, N}}
|
|
end;
|
|
handle_call(instances, _From, {Reg, Ins, N}) ->
|
|
{reply, [Id || {Id, _} <- Ins], {Reg, Ins, N}}.
|
|
|
|
handle_cast(_, S) -> {noreply, S}.
|
|
|
|
handle_info(_, S) -> {noreply, S}.
|
|
|
|
%% ── helpers ─────────────────────────────────────────────────────
|
|
|
|
result_status({flow_done, R}) -> {done, R};
|
|
result_status({flow_suspended, T}) -> {suspended, T}.
|
|
|
|
%% safe_drive/3 — flow:drive is pure (no blocking receive), so a `try`
|
|
%% around it is safe in this runtime and isolates a flow whose step
|
|
%% raises: the store returns {error, {flow_crashed, _}} instead of the
|
|
%% gen_server crashing, keeping one bad flow from taking down others.
|
|
safe_drive(Flow, Input, Log) ->
|
|
try {ok, flow:drive(Flow, Input, Log)}
|
|
catch
|
|
throw:R -> {error, {throw, R}};
|
|
error:R -> {error, {error, R}};
|
|
exit:R -> {error, {exit, R}}
|
|
end.
|
|
|
|
log_append([], Tag, Value) -> [{Tag, Value}];
|
|
log_append([H | T], Tag, Value) -> [H | log_append(T, Tag, Value)].
|
|
|
|
find_keyed(_, []) -> not_found;
|
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
|
|
|
set_keyed(K, V, []) -> [{K, V}];
|
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|