Enqueue hooks
Source ServiceTrain.cs, TrainExecutionService.cs, IEnqueueContextAccessor.cs
Three protected virtual members of ServiceTrain that the mediator's queue path
(ITrainExecutionService.QueueAsync) calls when it accepts work, before the train runs. Why they
exist and what they guarantee is in How queued work stays durable and serialized.
| Member | Default | Called |
|---|---|---|
Task OnQueue(Metadata metadata, CancellationToken ct) | no-op | Before the work queue row is committed, or, for a train that defers promotion, after the row is staged unconfirmed and before it is confirmed |
bool DeferQueuePromotion | false | Read once per enqueue |
string? QueueSubjectKey(Metadata metadata) | null | Before the work queue row is written |
Which paths call them
| Path | QueueSubjectKey, OnQueue |
|---|---|
ITrainExecutionService.QueueAsync (GraphQL queue mutations, the dashboard's queue dialog and re-queue button) | Called |
IOperationsService.RunTrainAsync (the dashboard's Run dialog) | OnQueue runs on the run's input before the run's row is written; a throw refuses the run. The key is not used: the run has no work queue entry. |
ITrainExecutionService.RunAsync, ITrainBus | Not called |
| The scheduler running the queued entry later | Not called |
Work queued from a manifest, a dormant dependent activated by its parent (IDormantDependentContext.ActivateAsync) | Not called |
The enqueue resolves the train once, in a DI scope it creates and disposes before it returns, and
calls QueueSubjectKey, DeferQueuePromotion and OnQueue on that one instance. [Inject]
properties are filled before the hooks run. Scoped services belong to the enqueue's scope, not
the caller's. A train that overrides neither OnQueue nor QueueSubjectKey is not resolved.
Inside the hooks, this.Metadata is null. The input is on the metadata argument
(metadata.GetInput<T>()), and TrainInput returns it too. metadata.ExternalId is the
ExternalId the eventual run executes under; Id, ManifestId and ScheduledTime are unset.
OnQueue
using Microsoft.EntityFrameworkCore;
using Trax.Core.Functional;
using Trax.Effect.Attributes;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Services.ServiceTrain;
public class ProcessMatchResultTrain
: ServiceTrain<ProcessMatchResultInput, ProcessMatchResultOutput>,
IProcessMatchResultTrain
{
[Inject]
public IDbContextFactory<GameDbContext>? GameDbFactory { get; set; }
protected override Task<Either<Exception, ProcessMatchResultOutput>> Junctions() =>
Chain<RecordMatchResultJunction>().Resolve();
protected override async Task OnQueue(Metadata metadata, CancellationToken ct)
{
var input = metadata.GetInput<ProcessMatchResultInput>();
if (input is null || GameDbFactory is null)
return;
await using var db = await GameDbFactory.CreateDbContextAsync(ct);
db.Matches.Add(new MatchRecord { MatchId = input.MatchId, Region = input.Region });
await db.SaveChangesAsync(ct);
}
}| Behaviour | Detail |
|---|---|
| Exceptions | Propagate and abort the enqueue; no work queue row is left. The operations surface (the dashboard, the GraphQL queueTrain and requeueExecution mutations) shows The enqueue was refused: <message> for a plain TrainException and for the mediator's own cancelled and timed-out refusals, and The enqueue was refused. for anything else, logging the exception at Warning. |
| Re-execution | The run executes the full Junctions() chain later. An effect both the hook and the chain perform happens twice. |
| Time limit | A hook running longer than MaxQueueHookDuration (30 seconds by default, set with AddMediator(m => m.WithMaxQueueHookDuration(TimeSpan))) fails its enqueue with QueueHookTimeoutException and rolls back what was written on the enqueue's context. The hook's token is cancelled at the limit. A hook still running after its enqueue failed has its later enqueues refused, and what it throws is logged. The limit does not apply to a deferring train's hook. |
| Caller cancellation | The enqueue stops waiting and rolls back, as at the time limit. |
IEnqueueContextAccessor.Current
IEnqueueContextAccessor.Current is the data context the enqueue is about to commit on. It is
non-null only while OnQueue runs for a train that does not defer promotion. Entities tracked on it
commit and roll back with the work queue row. The hook must not call SaveChanges or commit on
it. It covers entities in Trax's own model only. Current flows with the async call, so every
accessor instance on that flow sees it, whatever its lifetime or scope. See
IEnqueueContextAccessor.
protected override async Task OnQueue(Metadata metadata, CancellationToken ct)
{
await accessor.Current!.Track(someTraxEntity); // no SaveChanges: the enqueue commits it
}The enqueue holds that context, a pooled connection and an open transaction while the hook runs.
Enqueues started inside OnQueue
An enqueue started from inside an OnQueue hook joins the enqueue it runs inside: its entry is
written in the outer context and transaction, and it returns its entry's id as usual.
| Rule | Detail |
|---|---|
| A nested failure fails the outer enqueue | Even when the hook catches it. The outer enqueue throws InvalidOperationException with the nested failure as its inner exception. |
| Every nested enqueue must be awaited | One still running when the hook returns fails the outer enqueue. Several may run at once (Task.WhenAll); their writes are serialized. |
| A deferring train enqueued from a hook is not staged | Its entry is written confirmed. |
| A deferring train's hook has nothing to join | Its own entry is already committed, so an enqueue it starts commits on its own. The same holds on a provider without transactions. The in-memory provider joins but has no transaction to roll back. |
DeferQueuePromotion
protected override bool DeferQueuePromotion => true;| Step | Effect |
|---|---|
| 1 | The entry is committed unconfirmed and cannot be dispatched. The context it was committed on is released. |
| 2 | OnQueue runs. IEnqueueContextAccessor.Current is null. |
| 3 | A second commit stamps confirmed_at and the entry becomes claimable. This runs even when the caller has cancelled after the hook returned. |
| Case | Result |
|---|---|
| The entry was cancelled while the hook ran (by an operator or the stale-entry sweep) | QueueAsync throws QueuedWorkCancelledException, an InvalidOperationException carrying the entry's WorkQueueId. |
| The sweep promoted the entry while the hook ran | The enqueue succeeds and the entry runs. |
| The hook throws | The staged entry is removed if it is still staged; a promoted or dispatched entry is not deleted. A failure to remove it does not replace the hook's exception. |
| The process dies between the commits | The entry stays unconfirmed. The ManifestManager's sweep cancels entries unconfirmed for longer than StaleStagedEntryTimeout (10 minutes by default), or promotes them when the host calls PromoteStaleStagedEntries(). See ManifestManager. |
QueueSubjectKey
protected override string? QueueSubjectKey(Metadata metadata) =>
$"customer-{metadata.GetInput<PatchCustomerInput>()!.CustomerId}";The dispatcher does not claim an entry while another entry with the same key has a run that has not
reached a terminal state. null means no serialization.
| Rule | Detail |
|---|---|
| Comparison | Exact, case-sensitive, across all trains. Keys are not namespaced by train. |
| Refused keys | Empty or whitespace only; containing an unpaired surrogate; containing a NUL character; longer than 512 Unicode characters (WorkQueue.MaxSubjectKeyLength; a character outside the Basic Multilingual Plane counts once). WorkQueue.Create applies the rules, and the enqueue reports a refusal as an InvalidOperationException naming the train. |
| Exceptions | Propagate and abort the enqueue. |
| Work that carries a key | Entries created through the mediator's queue path, including the dashboard's queue dialog and re-queue button. |
| Work that carries none | Work queued from a manifest (its runs, retries and dead-letter requeues), dormant dependents, synchronous runs (RunAsync, ITrainBus), the dashboard's Run dialog. |
| Run-now of a keyed train | IOperationsService.RunTrainAsync refuses a train that overrides QueueSubjectKey outside a trusted execution scope, such as the dashboard's, and says to queue it. The dashboard's Run dialog warns that it bypasses serialization. |
| Order within a subject | Dispatch order: priority, then age. An entry whose scheduledAt has not arrived is not a candidate. Order holds with one dispatcher; with MaxConcurrentDispatch above 1 or several dispatching hosts, only mutual exclusion holds. |
| Per cycle | Dispatch offers only the first queued entry per subject. The dashboard's work queue detail page shows what an entry waits on under Waiting On. |
| Claim | On Postgres the claim takes a transaction-scoped advisory lock on the subject, held only for the claim. SQLite needs none. See Multi-Server Concurrency. |
A subject is released when its run reaches any terminal state, including Failed written by a
stale-run reaper (StalePendingTimeout, 20 minutes by default; StaleInProgressTimeout, 60 minutes
by default) or by the scheduler's startup recovery (RecoverStuckJobsOnStartup, on by default).
How queued work stays durable and serialized
explains what that means for the guarantee.
See also
- How queued work stays durable and serialized
- ServiceTrain, ITrainExecutionService, IEnqueueContextAccessor, IWorkQueuePromotion
- Trusted execution scope
SDK Reference
ServiceTrain | ITrainExecutionService | IEnqueueContextAccessor | IWorkQueuePromotion | AddMediator