fed-sx-types Phase 7: pipeline trigger fan-out + flow_dispatch
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
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>
This commit is contained in:
76
next/kernel/flow_dispatch.erl
Normal file
76
next/kernel/flow_dispatch.erl
Normal file
@@ -0,0 +1,76 @@
|
||||
-module(flow_dispatch).
|
||||
-export([start/4, guard_passes/3]).
|
||||
|
||||
%% Bridge from "an activity matched a trigger" to "a flow started with
|
||||
%% that activity as input" (fed-sx-triggers Phase 3). A NATIVE call into
|
||||
%% next/flow (flow_store) — the engine is Erlang-on-SX too, so there is
|
||||
%% no cross-guest FFI: the kernel and the workflow engine share one
|
||||
%% runtime.
|
||||
%%
|
||||
%% start(Spec, Activity, ActorState, Cfg)
|
||||
%% -> {ok, FlowId, {ActivityCid, TriggerCid, FlowId}} (audit triple)
|
||||
%% | {error, Reason}
|
||||
%%
|
||||
%% The flow named in Spec is started with the activity bound into its
|
||||
%% input environment, so flow steps can read the activity, the actor id,
|
||||
%% and the trigger cid (the audit chain). Flow-start failures — an
|
||||
%% unknown flow name, or a crashing first step (flow_store isolates the
|
||||
%% raise) — come back as {error, Reason}, never raised, so the fan-out
|
||||
%% caller is insulated from one flow's failure.
|
||||
|
||||
start(Spec, Activity, ActorState, _Cfg) ->
|
||||
FlowName = trigger_registry:spec_flow_name(Spec),
|
||||
TriggerCid = trigger_registry:spec_cid(Spec),
|
||||
ActivityCid = activity_cid(Activity),
|
||||
Input = [{activity, Activity},
|
||||
{actor, actor_id_of(ActorState, Activity)},
|
||||
{trigger_cid, TriggerCid}],
|
||||
case flow_store:start(FlowName, Input) of
|
||||
{ok, FlowId, _Result} ->
|
||||
{ok, FlowId, {ActivityCid, TriggerCid, FlowId}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
%% guard_passes(Spec, Activity, ActorState) — a spec fires when its
|
||||
%% actor-scope admits the activity's actor AND its guard (if any)
|
||||
%% returns true. An `any` scope and an `undefined` guard always pass;
|
||||
%% the guard lets one activity-type bind multiple flows with
|
||||
%% discriminators.
|
||||
guard_passes(Spec, Activity, ActorState) ->
|
||||
scope_ok(trigger_registry:spec_actor_scope(Spec), Activity) andalso
|
||||
guard_ok(trigger_registry:spec_guard(Spec), Activity, ActorState).
|
||||
|
||||
scope_ok(any, _Activity) -> true;
|
||||
scope_ok(Scope, Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, Scope} -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
guard_ok(undefined, _Activity, _ActorState) -> true;
|
||||
guard_ok(Guard, Activity, ActorState) when is_function(Guard, 2) ->
|
||||
Guard(Activity, ActorState);
|
||||
guard_ok(_, _, _) -> false.
|
||||
|
||||
%% ── helpers ─────────────────────────────────────────────────────
|
||||
|
||||
activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
%% actor_id_of/2 — prefer the receiving actor's id (ActorState carries
|
||||
%% {actor_id, _}); fall back to the activity's :actor. Reading
|
||||
%% ActorState as a proplist keeps this decoupled from actor_state's
|
||||
%% internal shape and testable with a plain [{actor_id, _}] stand-in.
|
||||
actor_id_of(ActorState, Activity) ->
|
||||
case envelope:get_field(actor_id, ActorState) of
|
||||
{ok, Id} -> Id;
|
||||
_ ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, A} -> A;
|
||||
_ -> undefined
|
||||
end
|
||||
end.
|
||||
@@ -7,7 +7,8 @@
|
||||
stage_signature/1, stage_signature/2,
|
||||
stage_replay/1, stage_replay/2,
|
||||
stage_schema/1, stage_schema/2,
|
||||
apply_object_schema/2, stage_object_schema/1]).
|
||||
apply_object_schema/2, stage_object_schema/1,
|
||||
apply_triggers/3]).
|
||||
|
||||
%% Validation pipeline per design §14.
|
||||
%%
|
||||
@@ -301,3 +302,98 @@ stage_field(_, []) -> nil.
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
%% ── fed-sx triggers Step 2: post-append fan-out ─────────────────
|
||||
%%
|
||||
%% apply_triggers/3 — fires the durable flows bound to an activity's
|
||||
%% type AFTER it has been accepted and appended (rejected activities
|
||||
%% never reach here, so a flow only runs for an activity that really
|
||||
%% landed). For each spec the activity's type is bound to, the spec
|
||||
%% must pass its guard/actor-scope, and its {ActivityCid, TriggerCid}
|
||||
%% pair must not already have fired (federation can deliver the same
|
||||
%% activity twice via different peers — dedup is keyed on that pair,
|
||||
%% read from the receiving actor's :triggers_fired). Surviving specs are
|
||||
%% dispatched via flow_dispatch:start (a native flow_store:start), which
|
||||
%% never raises.
|
||||
%%
|
||||
%% Returns {ok, Results} where Results is one
|
||||
%% {ActivityCid, TriggerCid, {ok, FlowId} | {error, Reason}}
|
||||
%% per spec actually dispatched (guard-passed, not a duplicate). The
|
||||
%% kernel folds the {ActivityCid, TriggerCid} pairs into the actor's
|
||||
%% :triggers_fired (dedup) and the audit triples into its projection.
|
||||
%% No matching/ready registry yields {ok, []}.
|
||||
%%
|
||||
%% Cfg gates the fan-out on {trigger_registry, trigger_registry} (the
|
||||
%% registered gen_server), mirroring the object-schema stage's
|
||||
%% {peer_types, _} gate. apply_triggers must NOT be called inside a
|
||||
%% `try` — flow_dispatch does gen_server:calls, and a blocking call
|
||||
%% inside a try deadlocks this scheduler; the fan-out runs after append,
|
||||
%% in its own step, so this is naturally satisfied.
|
||||
|
||||
apply_triggers(Activity, ActorState, Cfg) ->
|
||||
case trigger_registry_ready(Cfg) of
|
||||
false -> {ok, []};
|
||||
true ->
|
||||
Type = activity_type_of(Activity),
|
||||
Specs = trigger_registry:lookup(Type),
|
||||
ActCid = trigger_activity_cid(Activity),
|
||||
Fired = field_or_default(triggers_fired, ActorState, []),
|
||||
fire_each(Specs, Activity, ActorState, ActCid, Fired, Cfg, [])
|
||||
end.
|
||||
|
||||
trigger_registry_ready(Cfg) ->
|
||||
case stage_field(trigger_registry, Cfg) of
|
||||
nil -> false;
|
||||
_ ->
|
||||
case erlang:whereis(trigger_registry) of
|
||||
undefined -> false;
|
||||
_ -> true
|
||||
end
|
||||
end.
|
||||
|
||||
fire_each([], _A, _AS, _ACid, _Fired, _Cfg, Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
fire_each([Spec | Rest], A, AS, ACid, Fired, Cfg, Acc) ->
|
||||
TCid = trigger_registry:spec_cid(Spec),
|
||||
Pair = {ACid, TCid},
|
||||
AlreadyFired = pair_member(Pair, Fired) orelse acc_member(Pair, Acc),
|
||||
Pass = (not AlreadyFired) andalso flow_dispatch:guard_passes(Spec, A, AS),
|
||||
case Pass of
|
||||
false ->
|
||||
fire_each(Rest, A, AS, ACid, Fired, Cfg, Acc);
|
||||
true ->
|
||||
Outcome = case flow_dispatch:start(Spec, A, AS, Cfg) of
|
||||
{ok, FlowId, _Audit} -> {ok, FlowId};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end,
|
||||
fire_each(Rest, A, AS, ACid, Fired, Cfg, [{ACid, TCid, Outcome} | Acc])
|
||||
end.
|
||||
|
||||
activity_type_of(Activity) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, Type} -> Type;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
trigger_activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
field_or_default(Key, Proplist, Default) ->
|
||||
case envelope:get_field(Key, Proplist) of
|
||||
{ok, V} -> V;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
%% pair_member/2 — {ACid, TCid} present in a [{ACid, TCid}] fired list.
|
||||
pair_member(_, []) -> false;
|
||||
pair_member(P, [P | _]) -> true;
|
||||
pair_member(P, [_ | Rest]) -> pair_member(P, Rest).
|
||||
|
||||
%% acc_member/2 — {ACid, TCid} already dispatched this call (Acc holds
|
||||
%% {ACid, TCid, Outcome} triples).
|
||||
acc_member(_, []) -> false;
|
||||
acc_member({A, T}, [{A, T, _} | _]) -> true;
|
||||
acc_member(P, [_ | Rest]) -> acc_member(P, Rest).
|
||||
|
||||
Reference in New Issue
Block a user