Machines root
Package Trax.Api.GraphQLSurface GraphQL schemaSource MachineTypeModule.cs, MachineSurface.cs, MachineChanged.cs
Each state machine marked [TraxMachine] is served as its own typed API under the machines field of both roots,
however the host registered it. Nothing is served for a machine that is not marked: the attribute is the only
switch, as [TraxQuery] and [TraxMutation] are for a train. Trains keep their own [TraxMutation] and [TraxQuery] fields beside it: a host serves machines and plain
trains together. The decision is
ADR 0048.
Moving a client off the generic stateMachine mutations, which are no longer served by default, is covered in
Generic State-Machine Mutations.
Members
| Field | Root | Arguments | Answers |
|---|---|---|---|
machines | both | none | The namespace of every exposed machine. It and each machine's field group operations, so the operation cap counts the operations under them, not them. |
machines.<machine> | both | none | One field per exposed machine, named from its kebab-case id in camelCase (topic-map is topicMap). |
start | mutation | input: <StartInput>!, requestId: String! | Creates an instance for the caller from the machine's declared start. The server mints the id. input is the record Start<TStartInput> declares, never the context type, and is left out when that record has no field. A retry with the same requestId and input answers with the same instance; the same requestId with another input is REQUEST_ID_REUSED. |
<event> | mutation | id: UUID!, input: <Input>!, requestId: String!, clientResult: String | Sends one client event, named from its trigger in camelCase (CancelBuild is cancelBuild). input is the record the event's WithInput<T>() declares, and is absent for an event declared without one: never a JSON scalar. |
<effect event> | mutation | id: UUID!, requestId: String! | An event bound to the machine's RunsOnce effect. The server runs the effect, once per instance however many retries arrive with one requestId, and records its result; the client sends no input, and no clientResult, since it cannot predict the effect's receipt. |
discard | mutation | id: UUID!, requestId: String! | Deletes the caller's instance, held to the machine's Discard(...) rule. A missing id, and another user's, answers discarded: true. A row the server put in its state (an effect's record, an invoked train's outcome, an event gated by Requires or RequiresPolicy, carried on by a timer), an instance in an invoking state, and one with an effect pending on it are refused as DISCARD_NOT_ALLOWED and kept. Answers MachineDiscardResult { id discarded problem }. |
get | query | id: UUID! | The caller's instance, or null. Another user's id, and one the machine's Get rule refuses the caller, read exactly as an unknown id. |
An outcome of an invoked train and a timer have no field: only the server fires them.
Every operation field carries @authorize, so an anonymous caller is refused before any resolver runs. Each event's
own rule (Requires, RequiresPolicy, AnyAuthenticated) is checked below the field, where every surface meets,
and a caller it refuses is answered with the problem FORBIDDEN, not an error.
Answers
Every operation but discard answers with the machine's <Machine>MachineResult:
| Field | Type | Description |
|---|---|---|
id | UUID | The instance's id; on a refusal, the id the request named. |
revision | Long | The revision the write left the instance at, bumped by the database on every write. It travels beside the snapshot, never inside the context. |
state | <Machine>MachineState | The state, an enum whose values are the machine's state names as it declares them (BuildFailed). |
context | <Machine>MachineContext | Every field any state's context record declares, by its JSON name, each nullable: a field the state the instance is in does not hold is null. Absent for a machine whose states declare no context record. |
dueAt | DateTime | When the timer of the state the instance is in fires, from the row's due_at, or null when that state has no timer or the server cleared it. Every answer carries it, start and get included, beside the snapshot and never inside it. |
dueAtKnown | Boolean! | False when the answer replays a request the instance has moved past since: the server records a request's result, not its due time, so dueAt is null and means nothing. Treat dueAt as absent then. True on every other answer. |
invokedRun | MachineInvokedRun | On a machine with a state that invokes a train: { runId }, the external id of the run the instance's current state queued, while it sits in that state; null in any other state, on a refusal, and for anyone but the instance's user owner. The owner follows the run with runProgress. Read from the row when the answer is resolved, so a replayed answer shows where the instance is now. |
problem | SnapshotProblem | { code message } when the operation was refused, else null. When it is set, nothing was written. |
A refusal is data. The codes are those of the result codes:
INVALID_INPUT, GUARD_FAILED (its message is the guard's Because text), NO_TRANSITION, FORBIDDEN,
REQUEST_ID_REUSED, INVALID_REQUEST_ID, CLIENT_DIVERGENCE, TOO_LARGE, NOT_FOUND and the rest. A failure the
server did not expect, such as the database going away, is INTERNAL_ERROR with a fixed message and a reference
the server logs the failure under; the answer carries no exception text, SQL or host.
Behavior
Exposing a machine requires persisted operations: each machine's operations are
served as persisted documents that carry its version, and a node orders a machine's versions
across a rolling deploy through them. A host that exposes a machine without UsePersistedOperations refuses to start,
naming the machines and the call to add. SingleNode() suits one process; more than one node uses
UseRabbitMqInvalidation(connectionString).
Names
The input types are the CLR records, named as HotChocolate names them (WizardStartInput), and registered once
however many machines and trains take one record. Their fields, and the context's, carry the record's JSON names:
camelCase, or the [JsonPropertyName] a property declares (note_text). A TimeSpan crosses as the ISO-8601
duration a timer reads ("PT5M"), both in an input and in the context, so a timer whose duration the context holds is
armed from a typed event. An enum a machine's context, start input or event inputs hold is spelled as the server
stores it, the C# member name (HighPriority), not HotChocolate's HIGH_PRIORITY: the twin applies an input as the
client sends it, so it predicts what the server stores only when the two spellings are one. HotChocolate gives a CLR
enum one GraphQL type, so any field outside the machines root that carries the same enum would see the same values:
a train's, a query model's or a type extension's. The host refuses to start, naming the enum and the field, unless it
accepts the new spelling with ShareEnumWithMachines<TEnum>().
An enum declared in a Trax assembly, such as TrainState, is never shared, since Trax's own fields
(TrainLifecycleEvent.trainState, the operations filters) carry it: a machine that holds one refuses the start,
naming the enum, whether or not the host called ShareEnumWithMachines for it, and the call itself throws. Give the
machine its own enum. Trax tells its own assemblies by name: one named Trax or Trax.* counts as Trax's, so a
consumer assembly named Trax.Billing has its enums refused the same way. The samples (Trax.Samples.*) and test
projects (a Tests segment) count as consumer code. A GraphQL enum type the host binds
to such an enum itself refuses the start too. So does a member whose stored name is not a GraphQL name, is true,
false or null, or starts with __. Only System and the namespaces under it are treated as the framework's; a
record in Systematic.Orders is walked like any other. An enum held inside a class deriving from a list, a dictionary or a tuple
is reached too. The types the root generates carry the
machine's name and the word Machine (TopicMapMachineState, TopicMapMachineContext, TopicMapMachineResult,
TopicMapMachineChange, TopicMapMachineMutations, TopicMapMachineQueries), so a CLR type the schema already holds, such as a machine's
own state enum a train returns, keeps its name.
The host refuses to build the schema, naming both, when two exposed machines' ids give one field (map-2d and
map2d), when two events of a machine give one field, and when an event is named start, discard, get or
changed. A
host's own field named machines on either root is refused at startup, and so is a machine whose push field lands on a
subscription field already there (a machine on-train-state on Trax's onTrainStateChanged, or order on a host's
own orderChanged). Two registered machine types with one id,
served or not, refuse the host's start too, naming both: a subclass has its base's id, so a host serving a machine
through a marked subclass registers only the subclass
(AddStateMachines).
The machine's types come from IMachine.Triggers (each trigger's kind and input record), IMachine.States (each
state's MachineState: its Name and the ContextType its Context<TContext>() declares) and
IMachine.StartInputType, so any machine derived from Machine<TState, TTrigger> that has an IR can be exposed.
How an operation runs
Each write dispatches the internal train it stands for through the mediator, as the generic mutations do:
start runs IStartSnapshot, an event IAdvanceSnapshot, an effect event ISendSnapshot, and discard
IDiscardSnapshot. So a typed event is authorized, recorded as a run, audited and mapped to a problem on the one
path every surface shares, and the machine's rules and input validation run below it. Those internal trains are
never published on the lifecycle subscriptions, whatever the host streams, and no lifecycle hook receives their
output: the broadcaster (RabbitMQ) and SignalR carry their runs' state changes with output null, so an instance's
context reaches its owner only through get and its push. Their runs are recorded as any other train's.
- One write per operation. The fields under a namespace resolve in parallel, so an operation that selects more
than one
start,discardor event, on one machine or several, is refused at validation withTRAX_ONE_MACHINE_EVENTbefore any of them runs. - Reads share the request's data context.
getreads through the machine's draft service in the request's scope, one read of a request at a time, so two aliasedgets are safe. requestIdis required onstart,discardand every event: 1 to 200 characters, not blank, with no control character (C0, DEL or C1) and no unpaired surrogate, or the answer isINVALID_REQUEST_IDand nothing runs. Two copies of one request racing each other answer with one result.- Input is bounded. An input or a
clientResultlarger than 64 KiB isTOO_LARGEbefore it is parsed. - Sensitive input is masked. A field the input record marks
[TraxSensitive], or one whose type reaches a marked member, travels to the train inAdvanceSnapshotInput.SensitiveInput(StartSnapshotInput.SensitiveInputfor a start), which every recorded copy of the run's input writes as{"_redacted": true}. clientResultis the canonical snapshot the client's twin predicts for the event. A different result is refused asCLIENT_DIVERGENCE, nothing is written, and thetrax.machine.client_divergencemeter counts it; the server logs only its length and SHA-256 hash. It is compared canonically, with a null member equal to an absent one, so a prediction rebuilt from a typed answer matches. A retried request is compared with the result recorded for it.
The push
subscription { topicMapChanged(id: "...") { rowId revision state context { ... } dueAt dueAtKnown gone reload problem { code message } } }
pushes the caller's instance whenever any path on any host writes it: a step from another tab, an invoked train's
outcome applied on another node, a recovery. Each exposed machine has its own field, named from its id
(topic-map is topicMapChanged), and its events are typed as the machine's get answers: the state as the
machine's state enum, the context as its context type, field for field, with dueAtKnown (always true on a push)
beside dueAt. An event that ends the stream carries no state and no context. Over the socket only, by its persisted
id when persisted operations are on.
- Verified once, routed by row. The user key is the socket's principal (the host's
ISnapshotPrincipal), never an argument, and the machine'sWatchrule is checked once, at subscribe. A missing id, another user's, a system instance's and one the rule refuses all get the same answer: one event with the problemNOT_FOUND, then the end. The stream then follows the row'srow_id, so a row deleted and created again under the same id never reaches it. - Order. The watch registers before the instance is read, and the first event is the instance as it is then.
Each later event is a newer revision, whole, migrated and validated as
getreads it; a burst may arrive as its latest revision only. Drop an event, or an answer to your own step, at or below the revision you hold: a skipped revision loses nothing.reloadasks the client to read the instance again; the server sends whole snapshots and does not set it today. An event'sdueAtis always the row's: null means the state has no timer, or the server cleared it while the instance stayed in it. - The end. A delete or an expiry ends the stream with
gone: true. A row written by a newer version of the machine than the node runs ends it withCLIENT_OUTDATED(reload); one the node cannot read otherwise ends it with the rehydration code. Ending one stream ends no other. A stream also ends with its socket's credential. - Caps. One principal holds at most 10 streams on one instance and 2,000 in all on a node; past either the
answer is
TOO_MANY_SUBSCRIPTIONS, and a stream that ends frees its place. Each stream holds one of its socket's 100 operations. - Cost. A node holds one listening session however many streams it serves, and reads a written row once per burst of notices, only when something on the node watches it (Snapshot change feed).
Operators watch every instance through onMachineInstanceChanged instead, behind the operations gate, which carries
an instance's state, revision and runs and never its context or owner
(Subscriptions).
Persisted documents
With persisted operations on, each exposed machine's operations are persisted documents the host generates and registers itself, so a host that requires persisted operations serves its machines with no upload, and an ad-hoc machine document is refused on every transport like any other.
- What is registered. After the schema builds, and before the host serves anything, one document per operation:
start,discard, every client event,getand the push, registered aschanged(never an outcome or a timer). The push document takes only$id. A document takes its values as variables named like its field's arguments (input,requestId,id,clientResult) and selects every field of the answer. Its text is built from the schema alone, so every process building one schema writes the same bytes. - The id is
trax_machine_<machine>_<operation>_<irHash><textHash>: the machine's id, the operation's field, the 64 hex characters of the machine's IR hash, and 16 of the SHA-256 of the IR hash and the text. A change to the machine changes every id; a change to the GraphQL names alone changes the ids of the documents whose text changed. The documents live under the null tenant, and thetrax_machine_prefix is reserved: upload, deactivate and restore refuse it (RESERVED_ID). - Registration runs under one lock for every node. A document already stored as generated is left alone: no
write, no history row, no broadcast. A stored body that does not match its id is written back, logged and counted
on
trax.persisted_operations.machine_document_repairs(meterTrax.Api.PersistedOperations). Each new set of a machine's documents is numbered (the row'sversion) one past every set stored before it. - A lookup verifies the stored body against its id. A body that does not hash to its id is refused, logged and
counted on
trax.persisted_operations.machine_document_mismatches. - Each node runs only its own documents. An id of an older set answers
CLIENT_OUTDATED(the client reloads); an id of a newer set, registered by a node running a newer version, answersSERVER_OUTDATED(retry: another node may run it). Both are GraphQL errors whoseextensions.codeis the code, and neither runs anything. Neither is a server error:CLIENT_OUTDATEDis HTTP 400 andSERVER_OUTDATED409, so a load balancer counting 5xx answers does not count them, and a client reads the code, not the status. An id of a machine this node does not serve is answered from the store:SERVER_OUTDATEDwhile the store holds it active (a node may serve it),CLIENT_OUTDATEDonce it was collected. An id that is not shaped like a generated one answersCLIENT_OUTDATEDwithout a read. An id this node has not seen is answered at once from its last read of the store, never by waiting for one: until a read that began after the id was first asked for has finished, it answersSERVER_OUTDATED, and once such a read does not hold it,CLIENT_OUTDATED. The store is read again in the background, at most once a second however many such ids arrive, and the ids waiting for a read are bounded (4096), the oldest forgotten first: a flood of more made-up ids than that within a second keeps a real stale client onSERVER_OUTDATEDuntil the flood stops. A document id's machine is the segment between thetrax_machine_prefix and the next underscore; a machine id never contains one. - A version is collected once no node registers it. Each running node registers its documents again every
hour (or every quarter of the grace period, if that is shorter), under the same lock, and each registration writes
the
updated_atof the node's own documents again once it is a quarter of the grace period old. A version of a machine the node serves, older or newer than its own, that no node has registered forKeepOlderMachineDocumentsFor(seven days by default) plus that quarter is collected, with no restart. After a rolling deploy the older version goes; after a rollback the newer one goes, and its ids then answerCLIENT_OUTDATED, so its clients reload rather than retry. A node still running another version keeps its documents for as long as it runs, so two hosts on one database that serve two versions of one machine keep both. The time is counted from no earlier than the registering node's own version last became active (first stored, or restored after a rollback), so a node starting inside the grace period deactivates nothing. A registration also marks the active documents of a machine the registering node does not serve as unexposed (their deprecation reason startsunexposed:), and a node that serves the machine clears the mark when it registers, so a machine any running node serves is never collected; one still marked after the grace period is collected, and its clients then reload. A node makes its own collected documents active again whenever it registers them. Only rows a host generated take part: a row under a reserved prefix whose body does not hash to its id, such as one uploaded before the prefix was reserved, is never numbered against, marked or collected. A node that serves no machine registers too, with no documents of its own, so a deploy that stops serving every machine still collects the older documents after the grace period; a registration that fails there is logged and tried again an interval later, and does not stop the host. - The manifest is the client manifest the host
exports with
--trax-export-client: every document this node serves, which a client reads at build time. It is written by the code that registers the documents, so its ids are the ones the host serves.
The id carries the IR hash, so the typed surface needs no schemaHash: the optional handshake stays on the generic
mutations only.
The TypeScript client
@traxsharp/state-machine/client drives this root with createMachineExecutor. Call a machine from TypeScript shows it.
Driving an instance from React
createMachineServer and useServerMachine drive one instance from a React component, with the server applying every transition. Drive a machine instance from React shows them.
Example
A host serving a machine:
[TraxMachine]
public sealed class TopicMapMachine : Machine<TopicMapState, TopicMapTrigger> { ... }
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString))
.AddStateMachines(typeof(TopicMapMachine).Assembly)
.AddMediator(typeof(TopicMapMachine).Assembly));
builder.Services.AddTraxGraphQL(graphql => graphql
.UsePersistedOperations(po => po.SingleNode()));The topic-map machine then has:
mutation {
machines {
topicMap {
start(requestId: "r-1") { id revision state context { fields } problem { code message } }
chooseFields(id: "...", input: { fields: ["Hydrology"] }, requestId: "r-2") { revision state }
discard(id: "...", requestId: "r-3") { id discarded problem { code message } }
}
}
}
query { machines { topicMap { get(id: "...") { id revision state context { fields mapId } } } } }