ITrainExecutionService
Package Trax.MediatorNamespace Trax.Mediator.Services.TrainExecutionSource ITrainExecutionService.cs, TrainExecutionService.cs, PreparedTrain.cs
Queues or runs a train by name, from a JSON input. The service looks the train up, authorizes the
caller for it, reads the input, and then either writes a work queue entry for the scheduler or runs
the train through the registered IRunExecutor: on this machine through ITrainBus by default, or
on a remote endpoint under UseRemoteRun or
UseLambdaRun. PrepareAsync does only the
steps both paths start with, for a surface that submits the work some other way. AddMediator()
registers the default implementation, TrainExecutionService, as a scoped service.
Signature
namespace Trax.Mediator.Services.TrainExecution;
public interface ITrainExecutionService
{
Task<QueueTrainResult> QueueAsync(
string trainName,
string? inputJson,
int priority = 0,
DateTime? scheduledAt = null,
CancellationToken ct = default
);
Task<QueueTrainResult> QueueAsync(
string trainName,
string? inputJson,
QueueTrainOptions options,
CancellationToken ct = default
);
Task<RunTrainResult> RunAsync(
string trainName,
string inputJson,
CancellationToken ct = default
);
Task<RunTrainResult> RunAsync(
string trainName,
string inputJson,
RunTrainOptions options,
CancellationToken ct = default
);
Task<PreparedTrain> PrepareAsync(
string trainName,
string? inputJson,
CancellationToken ct = default
);
}
public record QueueTrainResult(long WorkQueueId, string ExternalId);
public record RunTrainResult(long MetadataId, string ExternalId, object? Output = null);
public sealed record QueueTrainOptions
{
public int Priority { get; init; }
public DateTime? ScheduledAt { get; init; }
public long? ReplayDecisionsOf { get; }
public long? ResumeFrom { get; }
public string? ResumeAt { get; }
public bool RecordCaller { get; init; }
}
public sealed record RunTrainOptions
{
public bool RecordCaller { get; init; }
}
public sealed class PreparedTrain
{
public TrainRegistration Registration { get; }
public object Input { get; }
}Members
| Member | Description |
|---|---|
QueueAsync | Writes a work queue entry the scheduler dispatches |
QueueAsync with QueueTrainOptions | The same, with the options the first overload has no parameter for |
RunAsync | Runs the train now and returns its output |
RunAsync with RunTrainOptions | The same; RecordCaller records the caller as the run's starter on its metadata, as QueueTrainOptions.RecordCaller does. An implementation that does not override it runs through the overload above and records nobody |
PrepareAsync | Looks up, authorizes and reads the input, and writes nothing |
Train lookup, in every member, matches the fully-qualified canonical name first
(ServiceType.FullName), then the friendly name (ServiceTypeName). There is no short-name
fallback.
QueueAsync
Creates a work queue entry. The scheduler picks it up on its next polling cycle and dispatches the train.
scheduledAtwas added beforect, and a JSONnullinput throwsJsonException. A call passing the token positionally afterpriorityno longer compiles; name it (ct: ct). See Enqueue and Outcome Changes.
| Parameter | Type | Default | Description |
|---|---|---|---|
trainName | string | required | The train's name, matched by canonical name, then friendly name (for example "MyApp.Trains.IProcessOrderTrain") |
inputJson | string? | required | JSON of the train's InputType. Null or blank is read as an empty object, {}, which is refused when the input type needs values |
priority | int | 0 | Dispatch priority, 0 to 31, higher first |
scheduledAt | DateTime? | null | The earliest time the entry may be dispatched, stored as UTC. A Local value is converted; an Unspecified one is taken to be UTC already, which is how a timestamp without an offset arrives from JSON. Null dispatches as soon as a worker is free |
ct | CancellationToken | default | Cancels the enqueue |
Returns: QueueTrainResult, with WorkQueueId (the entry's database id) and ExternalId
(the external id assigned to the entry, which the run later executes under).
Throws:
TrainNotFoundExceptionwhen no train is registered with the name, andAmbiguousTrainNameExceptionwhen it matches more than one train's friendly name (see Mediator exceptions).TrainInputValidationExceptionwheninputJsonis over the size cap (WithMaxInputJsonBytes, 256 KiB by default), or the input as it would be stored (step 4 below) is larger thanTrainInputReader.StoredInputGrowthFactor(4) times that cap. For the second,MaxBytesis the stored cap andObservedByteshow much of the stored form had been written when writing stopped, which is more than the cap but not the stored form's full size.JsonExceptionwheninputJsondoes not deserialize to the train's input type, names a property twice ({"amount":1,"Amount":999}), is the JSON literalnull, or is null or blank for an input type that needs values: a constructor parameter with no default (a positional record such asrecord RenamePlayer(string Id, string NewName)) or arequiredmember.Unit, an input with only settable properties, and parameters with defaults are built from{}.TrainAuthorizationException(from Trax.Api, inTrax.Api.Exceptions, anUnauthorizedAccessExceptionthrown by itsITrainAuthorizationService) when the train's[TraxAuthorize]requirements refuse the caller. Authorization runs before the input is read, and applies to every caller-built enqueue, including the operations surface (queueTrain,requeueExecution). The dashboard's queue dialog and re-queue button also go through this method, inside a trusted execution scope, so per-train requirements are skipped there; the dashboard is gated by its host.Trax.Docs/adr/0017records why.TrainAuthorizationNotConfiguredExceptionwhen the train declares[TraxAuthorize]and noITrainAuthorizationServiceis registered, unless the call runs inside a trusted execution scope or the host calledAddMediator(m => m.AllowMissingAuthorizationService()). The mediator's startup authorization check already refuses to start such a host; the runtime check covers hosts where hosted services do not run, such as the Lambda runner.- Any exception the train's
QueueSubjectKeyoverride throws: a key that cannot be computed aborts the enqueue rather than becoming null. InvalidOperationExceptionwhenQueueSubjectKeyreturns an empty string, a key that is only whitespace, a key containing a NUL character or an unpaired surrogate, or a key longer than 512 Unicode characters (an emoji counts once). From Trax.Mediator 1.23.0 the rules areWorkQueue.Create's, and itsArgumentExceptionis the inner exception.- Any exception the train's
OnQueuehook throws. The enqueue is aborted and leaves no entry: on the default path the hook runs before the entry is committed, and for a train that defers promotion the staged entry is removed (only while it is still staged, never once promoted or dispatched), whether or not the caller has cancelled. A failure to remove it does not replace the hook's exception; the stale staged entry sweep resolves an entry left behind. QueueHookTimeoutException(from Trax.Mediator 1.23.0) when a train that does not defer promotion runs itsOnQueuehook longer thanMaxQueueHookDuration(30 seconds by default;WithMaxQueueHookDuration(TimeSpan)changes it,Timeout.InfiniteTimeSpanremoves it). The hook's token is cancelled at the limit and the enqueue stops waiting whether or not the hook stops: it rolls back, so no entry is written and nothing the hook wrote onIEnqueueContextAccessor.Currentis kept, even a write the hook had already saved, and the connection goes back to the pool. A hook that ignores its token keeps running, but an enqueue it starts after that is refused rather than committed on its own.Trax.Mediator/docs/adr/0004records the reasoning.QueuedWorkCancelledExceptionfor a train that defers promotion, when its staged entry was cancelled while the hook ran (by an operator, or by the stale staged entry sweep because the hook outlivedStaleStagedEntryTimeout). If the sweep promoted it instead (PromoteStaleStagedEntries()), the entry will run andQueueAsyncsucceeds. When it throws, the work will not run, but the hook's side-effect may already have been applied, and the message says so.
What it does
- Looks up the train by
trainNamethroughITrainDiscoveryService. - Authorizes the caller against the train's requirements, failing closed as described under Throws.
- Reads
inputJsoninto the train'sInputTypethroughTrainInputReader.Read, soOnQueueandQueueSubjectKeyalways receive a real input. Property names match whatever their case, a property given twice is refused, and JSON reference metadata ($id,$values,$ref) is not honoured. A run's saved input carries that metadata, so a re-queue resolves it first withTrainInputReader.ResolveSavedInput. - Re-serializes the input with the manifest serialization options (indented, every member
written), and refuses the enqueue with
TrainInputValidationExceptionwhen that stored form is larger than 4 timesMaxInputJsonBytes. The bytes are counted as they are written and writing stops the moment the cap is crossed, so a stored form far over the cap is never built in full. The check runs before the entry exists, so nothing is written. - Creates a
WorkQueueentry with the train name, serialized input, input type name, priority, andscheduledAtconverted to UTC. - Stamps the entry's subject key from the train's
QueueSubjectKeyoverride, if it has one. - Tracks the entry, then, if the train overrides
OnQueue, enters the enqueue context and calls the hook with the entry's metadata (itsExternalIdand input), then saves and commits, all in one transaction. A throw rolls the whole thing back, including anything the hook tracked onIEnqueueContextAccessor.Current. A train that does not override the hook is never resolved here, enters no context and opens no transaction: its enqueue is a single write. - Returns the entry's id and external id.
The train that steps 6 and 7 call is resolved once, on first use, in a DI scope the enqueue creates
and disposes before it returns, never from the caller's scope. A caller that holds its scope for a
long time, such as a Blazor circuit, keeps no train alive between enqueues, and the scoped services
a hook takes are fresh for each enqueue. Tracking the entry before the hook runs does not insert it
(Track is change tracking only), so the hook still runs before the row exists.
A train that sets DeferQueuePromotion
takes three steps instead: the entry is committed unconfirmed and undispatchable, the hook runs
outside that transaction, and a second commit promotes it. A throwing hook removes the staged entry
if it is still staged. Once the hook has returned the mutation counts as accepted, so the promotion
runs even if the caller cancels. If the entry was cancelled while the hook ran, the promotion finds
nothing to confirm and QueueAsync throws QueuedWorkCancelledException; if something else
already confirmed it (the sweep, with promotion opted in), it will run and QueueAsync succeeds.
IEnqueueContextAccessor.Current is null inside such a hook. A crash between the two commits
leaves the entry unconfirmed, and the scheduler's
stale staged entry sweep
cancels it (or promotes it, if the host opted in) once it is older than StaleStagedEntryTimeout.
The sweep runs in the ManifestManager, so it does not run while the ManifestManager is disabled
(SchedulerConfiguration.ManifestManagerEnabled = false, also the dashboard's Server Settings
switch); some host sharing the database must run it.
An enqueue started from inside another train's OnQueue hook, while that enqueue's transaction is
open, tracks its entry on the outer enqueue's context, runs its own hook, and flushes the entry
inside the outer transaction, so it commits or rolls back with the outer entry and uses no
connection of its own. A deferring train on this path is written confirmed rather than staged. If
the nested enqueue fails, the outer one fails too, even when the hook catches the exception; if the
hook returns while a nested enqueue it started is still running, the outer enqueue throws
InvalidOperationException.
On the in-memory provider, beginning the transaction succeeds but returns one whose commit and
rollback do nothing: the provider ignores EF's TransactionIgnoredWarning. The queue row and
anything the hook tracked on IEnqueueContextAccessor.Current still land together in one
SaveChanges, and an enqueue nested in a hook finds a transaction to join as it would on a
relational provider. A provider whose BeginTransaction throws InvalidOperationException or
NotSupportedException gets no transaction at all, with the same single SaveChanges.
QueueAsync with QueueTrainOptions
Queues exactly as the overload above does, reads the input and authorizes the same way, and throws the same exceptions for the same reasons.
QueueTrainOptions property | Type | Default | Description |
|---|---|---|---|
Priority | int | 0 | Dispatch priority, 0 to 31, higher first |
ScheduledAt | DateTime? | null | The earliest dispatch time, read as scheduledAt above |
ReplayDecisionsOf | long? | null | The metadata id of an earlier run whose recorded decisions the new run replays, so it takes the tracks that run took. Set by Trax only: IOperationsService.RequeueExecutionAsync sets it to the run being re-queued when that run has decisions to replay |
ResumeFrom | long? | null | The metadata id of a failed run the new run resumes, from the step after its latest checkpoint (or ResumeAt). Set by Trax only, by IOperationsService.ResumeExecutionAsync |
ResumeAt | string? | null | The step a resumed run resumes at, or null for after the latest checkpoint. Set by Trax only |
RecordCaller | bool | false | Record the current caller, as ICurrentPrincipalProvider gives it, as the run's starter: a hash of the principal id on the entry, which the run's metadata takes at dispatch. Only the starter may follow the run through runProgress. The GraphQL train mutations set it |
An ITrainExecutionService written before this overload existed, a custom one or a decorator
around the mediator's, gets a default implementation that queues through the overload above. When
ReplayDecisionsOf is set, that default throws DecisionReplayNotSupportedException, carrying the
implementation's type, rather than queue a run that would ask its deciders afresh.
RunAsync
Runs a train now, through the registered IRunExecutor (on this machine through ITrainBus unless
a remote executor is configured), and returns when the train completes. It creates no work queue
entry, so it does not fire OnQueue and does not consult QueueSubjectKey: a direct run can
overlap queued work for the same subject. It is subject to the
concurrency limits, which make it wait
rather than refuse.
| Parameter | Type | Default | Description |
|---|---|---|---|
trainName | string | required | The train's name, matched by canonical name, then friendly name |
inputJson | string | required | JSON of the train's InputType. Blank is read as QueueAsync reads a missing input |
ct | CancellationToken | default | Forwarded to the train's Run |
Returns: RunTrainResult, with MetadataId (the run's metadata record), ExternalId (the
run's external id) and Output (the train's output, or null for a Unit train).
Throws:
TrainNotFoundExceptionorAmbiguousTrainNameException, as forQueueAsync.TrainInputValidationExceptionwheninputJsonis over the size cap.JsonExceptionwheninputJsoncannot be read, as forQueueAsync.TrainAuthorizationExceptionwhen the train's[TraxAuthorize]requirements refuse the caller.TrainAuthorizationNotConfiguredException, under the same rule and trusted-scope exemption asQueueAsync.InvalidOperationExceptionfrom the DI container when the train cannot be built, for example because a constructor dependency is not registered. The train is resolved before its metadata is written, so this leaves noPendingrecord.NotSupportedExceptionwhen the host registered its ownITrainBusand it keeps the interface's defaultRunByNameAsync. It is thrown before any record is written.TrainExceptionwhen the train itself fails (propagated fromITrainBus).
What it does
- Looks up the train by
trainNamethroughITrainDiscoveryService. - Authorizes the caller against the train's requirements.
- Reads
inputJsoninto the train'sInputType, asQueueAsyncdoes. - Resolves the train found in step 1 by its canonical name, in a child DI scope. The train that runs is the one that was authorized, even when another train takes the same input type. A train that cannot be built throws here, before anything is written.
- Creates a
Metadatarecord with a generated external id and persists it asPending. - Runs the train as that record, for the train's
OutputType, invoked by reflection. - Returns the metadata id and the train's output. The output is read through a generic method
closed over
OutputType, so an output type that is not public (aninternalrecord in the consumer's assembly) is returned like any other.
Steps 4 to 6 are the default LocalRunExecutor with the default ITrainBus. A host that registers
its own ITrainBus gets the record written first, then
ITrainBus.RunByNameAsync<TOut>(trainName, input, ct, metadata) called with it. Anything that
throws after the record is written and before the train takes it over (a replaced bus that cannot
build the train, a cancellation in that gap) leaves the record Failed with the exception
recorded, or Cancelled for an OperationCanceledException, rather than Pending. A record the
train already took over is left alone, because the train records its own outcome. A remote
executor (UseRemoteRun, UseLambdaRun) takes over steps 4 to 7.
PrepareAsync
Looks the train up by name, authorizes the current caller for it, and reads the caller's input into
the train's input type: steps 1 to 3 of QueueAsync and RunAsync, which call the same code.
Nothing is written. The scheduler's run operation uses it to accept and refuse exactly what a queue
or a run does.
| Parameter | Type | Default | Description |
|---|---|---|---|
trainName | string | required | The train's name, matched by canonical name, then friendly name |
inputJson | string? | required | JSON of the train's InputType, read as QueueAsync reads it, with the MaxInputJsonBytes cap applied |
ct | CancellationToken | default | Cancels the lookup |
Returns: PreparedTrain, with Registration (the train's
TrainRegistration)
and Input (an instance of Registration.InputType, never null). PreparedTrain has no public
constructor, so code holding one knows the authorization check ran.
Throws: the same as RunAsync up to step 3: TrainNotFoundException,
AmbiguousTrainNameException, an UnauthorizedAccessException such as TrainAuthorizationException,
TrainInputValidationException, JsonException, and TrainAuthorizationNotConfiguredException.
An implementation of ITrainExecutionService written before this method existed inherits a
default that throws NotSupportedException rather than skipping the check.
Authorization runs before the input is read, so a caller who may not use the train learns nothing about its input from a parse error.
Behavior
TrainInputReader
Every member reads input through TrainInputReader, so a host or package that reads train input on a path of its own accepts and refuses the same JSON by calling it.
Registered services
AddMediator registers TrainExecutionService as the scoped ITrainExecutionService, and the
LocalRunExecutor as the scoped IRunExecutor that RunAsync runs through.
UseRemoteRun and
UseLambdaRun replace the executor.
Examples
[ApiController]
public class OrderController(ITrainExecutionService execution) : ControllerBase
{
[HttpPost("orders/queue")]
public async Task<IActionResult> QueueOrder([FromBody] JsonElement input, CancellationToken ct)
{
var result = await execution.QueueAsync(
"MyApp.Trains.IProcessOrderTrain",
input.GetRawText(),
priority: 5,
ct: ct);
return Accepted(new { result.WorkQueueId, result.ExternalId });
}
[HttpPost("orders/run")]
public async Task<IActionResult> RunOrder([FromBody] JsonElement input, CancellationToken ct)
{
var result = await execution.RunAsync(
"MyApp.Trains.IProcessOrderTrain",
input.GetRawText(),
ct);
return Ok(new { result.MetadataId, result.Output });
}
}Checking the name against discovery first, to answer 404 without calling the service:
[ApiController]
public class TrainController(ITrainDiscoveryService discovery, ITrainExecutionService execution)
: ControllerBase
{
[HttpPost("trains/{trainName}/run")]
public async Task<IActionResult> RunByName(
string trainName,
[FromBody] JsonElement input,
CancellationToken ct)
{
var match = discovery.DiscoverTrains().FirstOrDefault(t => t.ServiceType.FullName == trainName);
if (match is null)
return NotFound();
var result = await execution.RunAsync(trainName, input.GetRawText(), ct);
return Ok(new { result.MetadataId });
}
}See also
- ITrainBus, the in-process bus
RunAsyncruns through - TrainInputReader
- Mediator exceptions
- Concurrency limits
- OnQueue