Recovery state machines
Part of the Recovery sample tour. Three state machines run trains through
Invokes: a
state names a train, entering the state queues one run in the transaction that moves the machine, and only
that entry of the state receives the run's outcome. The host adds AddStateMachines(...) before
AddMediator(...), AddJunctionProgress() so a run can be cancelled from any host, and an
ISnapshotPrincipal that maps each demo key to a user.
One instance per index partition
The host also seeds 18 made-up records from two scholarly indexes, OpenAlex and Crossref, three months
each: six partitions. DiscoverPartitionsTrain (the operator mutation discoverPartitions) lists them and
starts one source-partition instance per partition with IMachineInstances.Start, keyed by
(source, month). The machine is SystemOwned(): no user owns an instance, and no stateMachine
mutation reaches one.
Discovered --Ingest--> Ingesting --done, unsure--> NeedsReview --Approve--> Approved
│ ▲ --done-------> Ingested
│ └─Retry── Failed (the run failed, or was reaped)
│ └─Retry── Cancelled (an operator cancelled the run)From Trax.Samples/samples/Recovery/Trax.Samples.Recovery/Machines/SourcePartitionMachine.cs
m.In(PartitionState.Ingesting)
.Context<PartitionContext>()
.Invokes<IIngestPartitionTrain, IngestPartitionInput, IngestPartitionResult>(
ctx => new IngestPartitionInput(
ctx["source"]!.GetValue<string>(),
ctx["month"]!.GetValue<string>()
)
)
// Tried in order: an unsure ingest goes to review, any other to Ingested.
.OnDone(
PartitionState.NeedsReview,
when: Input((IngestPartitionResult o) => o.Unsure).IsTrue(),
reduce: KeepWhatWasWritten
)
.OnDone(PartitionState.Ingested, reduce: KeepWhatWasWritten)
.OnFailed(PartitionState.Failed)
.OnCancelled(PartitionState.Cancelled);An unsure ingest goes to review through a guarded OnDone. A run ends in one result, so "unsure" is
not an outcome of its own: the ingest says so in its output, and the OnDone edges are tried in order.
Crossref's February partition holds a paper whose title is close to one in the corpus, the model is unsure
whether they are the same work, and the instance lands in NeedsReview; the other five land in
Ingested. Either way the reduction keeps pointers, not rows: a fingerprint of what was written and the
counts of works created, merged and held for review. The works themselves are in
topic_map.ingested_works.
Discovery is idempotent. An instance's id is derived from its key, so running discovery again finds the
six instances it started, creates none and queues nothing. Starting an instance and sending it into
Ingesting are two writes; an instance a crash left in Discovered between them is sent on by the next
discovery, and one already past Discovered is left alone.
A failed ingest is retried by entering Ingesting again, not by the scheduler. An invoked run has no
manifest, so nothing retries it. Its failure moves the instance to Failed, and Retry moves it back into
Ingesting, which queues a new run under a new token. The old run's token is gone, so a late delivery of
its outcome lands nowhere. Because the machine is system-owned, the sample fires Retry (and Approve,
out of NeedsReview) through its own operator-only mutation, partitionAction, which calls
IMachineInstances.Advance as the system. That call makes the same checks a user's advance makes: Retry
only from Failed or Cancelled, and never an outcome trigger.
mutation { dispatch { discoverPartitions(input: {}) {
output { instancesStarted instancesSentToIngest partitions { source month records } } } } }
mutation { dispatch { partitionAction(input: { source: "OpenAlex", month: "2025-03", action: RETRY }) {
output { state problem } } } }
query { operations { machineInstances(machine: "source-partition", ownerKind: SYSTEM) {
totalCount items { id state hasLiveInvokedRun } } } }Operators see the instances on the dashboard's State machines page and through
operations.machineInstances: state, timestamps and whether a run is live, never the context.
Build my topic map
The topic-map machine is a wizard each user drives from the page: choose the fields, choose the years,
build. It is user-owned, so each demo key has drafts of its own, stored in trax.snapshot_draft. A draft is
created by the machine's declared start, Start<TopicMapStart>(s => s.Reduce(Keep()).AnyAuthenticated()): the
start input is its own record, never the context type, the server mints the draft's id, and the declared
reduction builds the first step from the initial context. No client writes a snapshot.
ChoosingFields --ChooseFields--> ChoosingRange --Build--> Building --done-------> Built
▲ ◀──Back── ▲ ◀──CancelBuild── │ --failed-----> BuildFailed
│ └───────Edit─────────── Built, BuildFailed, BuildCancelled
│ └──── --cancelled--> BuildCancelled
Building ◀──Rebuild── Built, BuildFailed, BuildCancelled, Expired
Built --after 7 days--> Expired --Edit--> ChoosingRange
ChoosingFields ◀──StartOver── Building, Built, BuildFailed, BuildCancelled, ExpiredA built map waits seven days for review. Built declares .After(ReviewWindow).GoTo(Expired), a timer the
server runs: entering Built sets the draft's due time from the database clock, and the host's reconciler moves a
draft whose time has come to Expired, whether or not anyone has the page open. No client can send the timer's
trigger, and the page shows the move when the server pushes it. Until then it shows the review deadline, the
dueAt the server sends beside the Built snapshot. The reconciler wakes when the next due timer
falls due, so a timer fires on time. Beyond that it sweeps once a minute while it hears Postgres' notifications, which
matters only for a due time rewritten behind its back; Recovery:MachineSweepInterval sets a shorter sweep (the
browser tests use one second, because they make a timer due by rewriting the row, which the reconciler is not told
of).
Building invokes IBuildTopicMapTrain. The run's input is built on the server from the draft's choices,
with a run id of its own, which the pairs the join writes are keyed by; OnDone keeps that id as mapId,
with the number of papers and pairs and the co-citation track.
Every event declares who may send it. Build and Rebuild enter Building, so both declare
.Requires(RecoveryRoles.User), the role BuildTopicMapTrain asks for; the other events declare
.AnyAuthenticated(), and only a draft's owner reads it. At startup the host compares every event that
enters an invoking state with the train's [TraxAuthorize], and refuses to start when one would let a
caller run a train they could not run directly. The rule is checked on the server for every event, so a
viewer's Build is refused as FORBIDDEN before anything is written.
Old maps are swept. A rebuild writes a new map and moves mapId to it, which leaves the old one
behind, and a demo run's map is pointed at by nothing once its run ends. TopicMapSweeper deletes the
pairs of every map no stored topic-map draft points at, every five minutes
(Recovery:TopicMapSweepInterval). It decides from the drafts the server holds and never from what a
client sent. The build does not delete the map it replaces: another draft of the same user may still point at
it. A build writes its pairs a moment before its outcome points the draft at them, so a map is deleted only
once two sweeps an interval apart both found it unreferenced. Start a new map leaves no draft behind. A draft
in Building, or one a build's outcome put in Built, BuildFailed or BuildCancelled, is the record of that
build, so the server refuses to discard it (DISCARD_NOT_ALLOWED). From those states, and from Expired, the page
sends StartOver (any signed-in user), which resets the same draft to the first step with nothing chosen: from
Building it cancels the run, and leaving Built cancels the review timer. Before a build, the page discards the
draft and starts a new one. If the server refuses either, the page shows why and stays on the draft rather than
starting another. A map no draft points at any more goes with the next two sweeps.
The demo stays under the draft cap. Every visitor signs in with the same demo user key, so every draft
the page starts belongs to one user, and the server caps the drafts one user holds (50 by default). Besides
leaving no draft behind when it starts a new map, the host sets DraftTtl to one day, so a draft a closed browser
abandons stops counting toward the cap after a day rather than the default 30, and a demo database that lives for
weeks does not fill it. One abandoned before a build is deleted then; one a build moved is kept as the record of
that build (one in Built until its review timer moves it to Expired). The cap itself stays on.
The page drives the wizard with a TypeScript twin generated from the machine by the trax machine CLI:
From Trax.Samples/samples/Recovery/README.md
dotnet run --project Trax.Cli/src/Trax.Cli -- machine generate \
--assembly Trax.Samples/samples/Recovery/Trax.Samples.Recovery/bin/Debug/net10.0/Trax.Samples.Recovery.dll \
--machine Trax.Samples.Recovery.Machines.TopicMapMachine \
--ir-out Trax.Samples/samples/Recovery/Trax.Samples.Recovery.Client/src/topicMap \
--twin-out Trax.Samples/samples/Recovery/Trax.Samples.Recovery.Client/src/topicMap \
--corpus-out Trax.Samples/samples/Recovery/Trax.Samples.Recovery.Client/src/topicMap \
--engine-src Trax.StateMachine/ts/src --import-style specifierThe topic map is marked [TraxMachine], so it is served as its own typed operations
under the machines root,
mutation { machines { topicMap { start chooseFields back build cancelBuild rebuild edit startOver discard } } } and
query { machines { topicMap { get } } }, and the generic stateMachine mutations are not served at all. The host also runs only persisted documents: it registers the topic map's own when it starts,
and the page sends each by the id its generated client holds (src/trax/, written by trax client from the manifest
the host exports with --trax-export-client; the sample's README has both commands).
The page drives its draft with useServerMachine from @traxsharp/state-machine/react, over
createMachineServer from @traxsharp/state-machine/client
(Driving an instance from React). The
server owns the draft; the twin only predicts:
- It starts the draft with
machines { topicMap { start } }, which answers with the id the server minted, and moves it only with the machine's declared events, each its own mutation (chooseFields(id, input: { fields }, requestId),build(id, input: { fromYear toYear }, requestId), ...), sent one at a time, each under its own request id. - The twin tells the page which step it may take and what the step will show. The page shows that at once and
sends it as
clientResult; the server applies the event itself, refuses a result that differs from its own, and answers what it stored, which replaces the prediction. A refused step is rolled back and its code shown. - Whatever else moves the draft (the build's outcome, the review timer, a recovery after a crash, a step from another
tab) reaches the page on
topicMapChanged, subscribed by its persisted id over the socket. The page never polls: while the draft isBuildingit sends nothing until the outcome is pushed. Every answer and push carries the draft's revision, and anything at or below the revision shown is dropped, so a slow answer never puts back a draft a push has moved on. - The draft id is kept in the browser, so closing the tab and coming back opens the same draft at the same step. The
start's request id is kept too until the start answers, so a reload in the middle retries it and gets the same
draft rather than a second one. A draft the server no longer holds (discarded elsewhere, or expired) ends the
stream with
gone, and the page forgets it and starts a new one. - After a deploy that changes the machine, the host answers the page's documents
CLIENT_OUTDATED: the page hides the wizard, shows the code and sends nothing more until it is reloaded.
A client cannot write a build's result. The host serves no save, so no client writes a topic map whatever state it
names, and the run's outcome (Building.done) has no client field: only the run's outcome moves a draft out of
Building. CancelBuild leaves Building, which cancels
the run: a run still queued is marked cancelled, and one already running stops at its next junction.
What "recovers" means after a restart. A run a machine invoked has no manifest, so the scheduler does
not retry it. When the host running a build dies, the run stays InProgress until a host starts and fails
it on startup recovery (or the stale-run reaper fails it, after StaleInProgressTimeout). The outcome
reconciler, which every host that registers machines runs, then moves the draft from Building to
BuildFailed. The draft and the seeded data are in Postgres, so both are still there, and Rebuild from
BuildFailed queues a new run on the host that is up.
The food order
OrderMachine (order) is the smallest of the three, written to be read line by line beside the page's Food
order view, which drives it live, and beside its recording on traxsharp.net (scripts/recordings/order.mjs). A user fills a cart (addItem, priced by the server through a custom
reduction) and sends placeOrder, which enters Ordered; Ordered invokes DeliverOrderTrain, whose outcome ends
the order in Delivered or Cancelled. A cart nobody orders is cleared by the server's timer after 30 minutes
(Recovery:CartWait shortens it, for the recording and to watch Left in the cart on the page). The train's only rule is a signed-in caller (a bare
[TraxAuthorize]), matching placeOrder's AnyAuthenticated().
Cart --PlaceOrder--> Ordered --done-----------------> Delivered
│ --failed or cancelled--> Cancelled
└──after 30 minutes──> ClearedTwo things the recording shows are worth knowing. Place order pressed twice under one request id is answered once:
the second answer replays the first, and a press under a new request id is NO_TRANSITION from Ordered, so one
order makes one run and one charge. And the scheduler never retries a run a machine invoked, so a step that fails
fails the run, which is the order's Cancelled. The restaurant's tablet that does not answer
(dispatch { orderTrouble(input: { trouble: TABLET_TIMES_OUT }) }, armed for the caller's next order) is therefore tried
again inside SendToRestaurant, and the machine sees one run that recovered; discover { orders { tabletCalls } }
lists both attempts, for the caller's own orders only. A closed restaurant (RESTAURANT_CLOSED) fails the run at SendToRestaurant, before
ChargeCard, so the order is Cancelled with nothing charged.
SDK Reference
Invoking a train | IMachineInstances | AddStateMachines | Machines