Invoke a train from a state
Source Invokes.cs, InvokedTrainLauncher.cs, InvokesStartupValidator.cs
A state can run a train. Entering the state queues one run, and the run's outcome comes back as a trigger only that entry of the state can apply. The state is a durable checkpoint between stages of long-running work (fetch, normalise, resolve, embed), so a stage that fails is retried by entering its state again, not by rerunning everything before it. The full behaviour, every outcome and every refusal, is in Invoked runs: outcomes and refusals. To wait on another machine instead of a train, see Spawn a child machine from a state.
1. Write the train to be invoked
The train is a ServiceTrain behind an interface, and the interface is the name the machine invokes it by.
- Make every junction an
EffectJunction, inside aParallelbranch or a routing step's tracks too. Only anEffectJunctionreads the run's cancel flag, so leaving the state can stop the run on another host. - Make every junction safe to repeat. Queueing a run is exactly once, but a run can execute more than once. An irreversible step inside the train takes its own claim; nothing checks this for you.
- Give it an output record with properties, not a tuple. Its fields are what the outcome's guards and reductions read.
- Keep
[TraxSensitive]members out of the output, and do not mark the train[TraxBroadcast]on a user-owned machine: the output is reduced into a context stored as plain JSON.
The host refuses to start, naming the machine, the state and the train, junction or member at fault, when the train breaks one of these; the list is under Host refusals.
2. Declare the invoking state
Invokes<TTrain, TInput, TOutput> names the train by its interface, and the lambda builds the run's input from the
context the state was entered with. Every invoking state says where each outcome goes:
using System.Text.Json.Nodes;
using Trax.StateMachine;
using Trax.StateMachine.Persistence;
using static Trax.StateMachine.Rules;
public enum Stage
{
Idle,
Fetching,
NeedsReview,
Fetched,
FetchFailed,
Cancelled,
}
public enum IngestTrigger
{
Fetch,
Abandon,
Retry,
}
public sealed record SourceInput
{
public string Source { get; init; } = "";
}
public sealed record SourceContext
{
public string Source { get; init; } = "";
}
public sealed record FetchedContext
{
public string Source { get; init; } = "";
public string Fingerprint { get; init; } = "";
}
public sealed class IngestMachine : Machine<Stage, IngestTrigger>
{
protected override void Configure(IMachineBuilder<Stage, IngestTrigger> m)
{
m.Id("ingest").Version(1).StartsAt(Stage.Idle, () => new JsonObject());
m.In(Stage.Idle)
.Context()
.On(IngestTrigger.Fetch)
.WithInput<SourceInput>()
.Reduce(Set((SourceContext c) => c.Source).FromInput((SourceInput i) => i.Source))
.To(Stage.Fetching);
m.In(Stage.Fetching)
.Context<SourceContext>()
.Invokes<IFetchTrain, FetchInput, FetchOutput>(ctx =>
new FetchInput(ctx["source"]!.GetValue<string>()))
.OnDone(Stage.NeedsReview,
when: Input((FetchOutput o) => o.Unsure).IsTrue(),
reduce: Set((FetchedContext c) => c.Fingerprint).FromInput((FetchOutput o) => o.Fingerprint))
.OnDone(Stage.Fetched,
reduce: Set((FetchedContext c) => c.Fingerprint).FromInput((FetchOutput o) => o.Fingerprint))
.OnFailed(Stage.FetchFailed)
.OnCancelled(Stage.Cancelled)
.On(IngestTrigger.Abandon)
.Reduce(Clear())
.To(Stage.Idle);
m.In(Stage.NeedsReview).Context<FetchedContext>();
m.In(Stage.Fetched).Context<FetchedContext>();
m.In(Stage.FetchFailed)
.Context<SourceContext>()
.On(IngestTrigger.Retry)
.To(Stage.Fetching);
m.In(Stage.Cancelled).Context<SourceContext>();
}
}OnDone, one or more, tried in the order declared: the first whose guard holds for the output is taken, so an unguardedOnDoneafter the guarded ones is the fallback. The guard reads the output as the outcome's input, and the reduction copies what the next state needs into the context. A train that can be unsure says so in its output, and a guardedOnDoneroutes it, asNeedsReviewdoes here. A reduction that fills several fields combines itsSets withSetAll.OnFailed, exactly once. A run the scheduler reaps arrives here too.OnCancelled, exactly once, and required.
Build refuses an invoking state that leaves one out, and an outcome that goes to the state the machine starts at or
to its effect's target. The builder's rules are listed under
IInvokeBuilder.
Two more rules shape the rest of the machine, and Build refuses a machine that breaks either:
- An ordinary event may not enter a state an outcome enters. Only the run's outcome puts an instance in its target. Give each user action its own event into a working state that invokes the train for it, and let the outcome bring the instance back.
- An event any caller may send (no rule,
AnyAuthenticated()orAnonymous()) may not copy from its input a field an outcome writes. A custom reducer (new Reduction.Custom(name)) counts as copying every field, so an open event with one is refused whenever the machine invokes a train. Copy each value the next run needs into its own field, withSetAllwhen there are several, and leave the fields outcomes write alone.
3. Meet the host's requirements
The host that registers the machine needs Postgres or SQLite (InMemory has no transactions to queue the run in),
AddJunctionProgress() (it registers the junction effect that reads the cancel flag) and the mediator:
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Progress.Extensions;
using Trax.StateMachine.Persistence;
using Trax.Mediator.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString).AddJunctionProgress())
.AddStateMachines(typeof(IngestMachine).Assembly)
.AddMediator(typeof(IngestMachine).Assembly));Leave the mediator's own ITrainExecutionService in place, and give the invoked train no OnQueue hook or
DeferQueuePromotion: the run is queued in the transaction that enters the state, and each of those commits on its
own.
A scheduler dispatches the run. Entering the state only queues the run in trax.work_queue, through the mediator,
which enqueues and does nothing more. Only Trax.Scheduler's job dispatcher (registered by AddScheduler() on a host
with a database provider) takes queued work and runs it, so there must be
a scheduler on this host or another host sharing the database. The host does not check for one at startup, since
the scheduler may live on a dedicated host; without one, the run stays queued and no outcome arrives. The instance
stays in the invoking state until something else moves it: an event the state declares, or a timer. An After(...)
on the invoking state fires with or without a scheduler, because timers are fired by the outcome reconciler that
every host registering machines runs, and leaving the state cancels the queued run.
Who a run belongs to
Decide whether the machine is user-owned (the default) or declares SystemOwned(), because that decides how its runs
are authorized.
- User-owned. The train is authorized against the user entering the state, at entry, through the host's train
authorization; a user the train refuses cannot enter the state (
INVOKE_FORBIDDEN). When the train's[TraxAuthorize]names roles or a policy, every event that enters the state (and the start, when the initial state invokes) must require at least as much, or the host refuses to start. An outcome may not enter another invoking state, and neither may a timer, since no user is present to authorize that run: chain stages through an event the user sends (a "continue" or "retry" edge into the next stage), asRetrydoes above. - System-owned. Only
IMachineInstances.Startcreates an instance, and only its runs' outcomes andIMachineInstances.Advancemove it. Its train is authorized inside Trax's trusted execution scope, as a scheduled manifest run is, so the train may not declare[TraxAuthorize]. Only a system-owned machine chains runs through outcomes.
The run itself has no caller: it executes on the scheduler, where TraxPrincipal cannot be injected. A junction that
acts on the user's data injects InvokedRun, whose UserKey is the
owner of the instance that queued the run (null for a system-owned one), and checks the data against it. Resolving it
outside a run a machine invoked throws.
One user holds at most 10 live invoked runs across every machine; a machine sets its own limit with
InvokedRunLimit(n). Past it, entering the state is refused as INVOKE_LIMIT_REACHED. A run counts until it ends,
even when its instance was discarded or swept in the meantime: each run records the user as its starter. System owners
are not capped here: the dispatcher's MaxActiveJobs bounds them.
Retry by entering the state again
The scheduler never retries or dead-letters an invoked run, and an operator cannot requeue one. Declare an edge from
the failure state back into the invoking state (FetchFailed -Retry-> Fetching above): entering it queues a new
run, and the old run's late completion is then a NO_TRANSITION.
How an outcome comes back
The host that ran the train delivers the outcome as soon as the run's terminal write commits, and every host that registers machines runs an outcome reconciler that sweeps for ended runs, which covers a crashed host, a reaped run and a train run on a host that registers no machines. The outcome is applied once, by a conditional update on the instance's invoke token, however many hosts deliver it. To change how often the sweep runs (5 seconds by default):
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Progress.Extensions;
using Trax.StateMachine.Persistence;
using Trax.Mediator.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString).AddJunctionProgress())
.AddStateMachines(o => o.InvokeOutcomeSweepInterval = TimeSpan.FromSeconds(10), assembly)
.AddMediator(assembly));Delivery describes both paths, and AddStateMachines options the intervals.
Reduce pointers, not data
The outcome is reduced into the context, which is stored with the snapshot and returned to the client. Reduce
pointers into it, such as fingerprints and dataset URIs, never rows, so the database writes grow with the number of
steps rather than with the data each step handles. A snapshot past 64 KiB is refused, and the run goes to OnFailed.
Operators
Operators see every instance and the runs it invoked, never its context, and can cancel only a system-owned instance's run. What each operator surface shows and refuses is under Operator actions.
SDK Reference
Invokes | OnDone / OnFailed / OnCancelled | SystemOwned / InvokedRunLimit | IMachineInstances | InvokeOutcomeSweepInterval | OutcomeSample