Enqueue and Outcome Changes

The release that made Junctions() the only way to declare a chain also changed how work is enqueued and how a run's outcome is recorded. None of it needs a version bump beyond the usual 1.x update, and most of it compiles unchanged, which is why it is listed here: several changes only show up at runtime.

The chain declaration change has its own guide, Removal of RunInternal and Activate. Read it first if any train overrides RunInternal or calls Activate.

Projects scaffolded by an older Trax.Cli

trax generate from earlier Trax.Cli versions wrote trains that override RunInternal and a trains project that references Trax.Effect, Trax.Effect.Data.InMemory, Trax.Mediator and Trax.Scheduler with Version="1.*". A floating version restores the newest 1.x, so such a project picks up this release on its next restore and stops compiling, with nothing changed in the project itself.

Move each train to Junctions() as the RunInternal guide shows, or pin the Trax packages to an exact version until you do. Current Trax.Cli versions scaffold Junctions().

ITrainExecutionService.QueueAsync

A new parameter before ct. The signature is now:

Task<QueueTrainResult> QueueAsync(
    string trainName,
    string? inputJson,
    int priority = 0,
    DateTime? scheduledAt = null,
    CancellationToken ct = default
);

A call that passes the token positionally after priority, such as QueueAsync(name, json, 5, ct), no longer compiles, because ct now lands on scheduledAt. Name it: QueueAsync(name, json, priority: 5, ct: ct). An assembly compiled against the old signature fails with MissingMethodException until it is rebuilt, and a class that implements ITrainExecutionService has to add the parameter.

A JSON null input throws JsonException. It used to throw InvalidOperationException. The same applies to RunAsync. Code that caught InvalidOperationException to detect a bad input should catch JsonException, which a malformed input already threw.

A null or blank input is read as {}. The parameter is now string?. Through the GraphQL queueTrain mutation, a null input used to be stored as null and failed at dispatch; it is now read as {}, and an input type that needs values is refused at enqueue with JsonException: a constructor parameter with no default, as in a positional record, or a required member. RunAsync reads a blank input the same way.

A deferred entry cancelled under its hook throws QueuedWorkCancelledException. It used to throw a plain InvalidOperationException, the same type as an empty subject key. The new type derives from InvalidOperationException, so existing catches still work, and carries WorkQueueId and TrainName for a caller that has to compensate for the hook's side-effect.

See TrainExecution.

Every caller-built enqueue goes through the mediator

The GraphQL operations mutations and the dashboard used to write the work queue row themselves. They now call QueueAsync, which changes what runs:

Path[TraxAuthorize]OnQueueQueueSubjectKeyInput size cap
GraphQL queueTrain, requeueExecutionNow enforcedNow firesNow stampedNow applied
Dashboard queue dialog, execution Re-queue buttonNot enforced (trusted scope; the dashboard is gated by its host)Now firesNow stampedNow applied

What to check:

  • An OnQueue hook now runs from these paths. A hook written on the assumption that only your own code enqueues (one that is not idempotent, or that expects a request context) now also runs when someone queues or re-queues the train from the API or the dashboard.
  • A caller of queueTrain or requeueExecution may now be refused with a TRAX_AUTHORIZATION error for a train whose requirements it does not meet.
  • An API-only host needs AddMediator. OperationsService takes ITrainExecutionService as a required constructor dependency, so a host that registers OperationsService itself without AddMediator cannot resolve it. Where AddTraxGraphQL exposes the operations surface, its startup validator refuses to start such a host rather than letting queueTrain fail at request time.
  • IOperationsService.QueueTrainAsync can throw UnauthorizedAccessException (TrainAuthorizationException when the API's authorization is registered). Code calling it directly, such as a custom admin page, should handle it; it is not returned as a failed OperationResult.
  • requeueExecution refuses a run with no saved input, or one whose input was stored as the truncation placeholder, rather than re-running it with an empty input.

Trax.Docs/adr/0017 records why, and mutations lists the failure messages.

A cancelled caller can get a completed run

A train's terminal state is now saved on a token the caller cannot cancel. A train whose work finishes after the caller cancelled (because a downstream call ignored the token) is recorded and returned as Completed, where the caller used to get OperationCanceledException and the row stayed InProgress. Code that treated a cancelled request as proof the work did not happen should check the result instead. See Cancellation Tokens; effect/0005 in Trax.Effect records the decision.

Work queue entries are built only by WorkQueue.Create

WorkQueue's parameterless constructor is now protected, so new WorkQueue { ... } no longer compiles. It used to compile and then fail silently: an entry built that way has a null ConfirmedAt, which makes it a staged entry the dispatcher never claims and the stale-staged sweep eventually cancels. Build entries with WorkQueue.Create(new CreateWorkQueue { ... }), which stamps ConfirmedAt unless you set DeferPromotion.

WorkQueue.Create also checks CreateWorkQueue.SubjectKey now, the same way an enqueue through the mediator does. An empty key, or one longer than WorkQueue.MaxSubjectKeyLength (512 characters), throws ArgumentException. Leave the key null when the entry should not be serialized.

A later Trax.Effect makes WorkQueue.SubjectKey read-only, so Create is the only place a key is set and every key passes its checks. Assigning entry.SubjectKey = ... no longer compiles; pass the key in CreateWorkQueue.SubjectKey instead. That Trax.Effect needs Trax.Mediator 1.23.0 or later: Trax.Mediator 1.22.x sets the key after Create, so it fails every enqueue with MissingMethodException against it. Update the two packages together.

Records that gained parameters

These positional records gained optional trailing parameters:

RecordPackageNew parameters
ExecutionSummaryTrax.ApiFailureClass
ExecutionDetailTrax.ApiFailureClass
WorkQueueSummaryTrax.ApiConfirmedAt, SubjectKey
RemoteRunResponseTrax.SchedulerFailureClass

Constructing one by position still compiles, because the new parameters have defaults. Deconstructing one positionally (var (id, name, ...) = summary) does not, since the generated Deconstruct gained the same parameters, and an assembly compiled against the old constructor fails with MissingMethodException until rebuilt.

Rolling deploys

Two new train overrides change what a dispatcher must understand, so upgrade every host that runs the JobDispatcher before any train uses them:

  • DeferQueuePromotion: an older dispatcher does not check confirmed_at and would dispatch a staged entry before its hook returns. See Making the side-effect durable.
  • QueueSubjectKey: an older dispatcher claims without the subject check, so two entries for one subject can run at once. See QueueSubjectKey.

Postgres migration 041 adds work_queue.confirmed_at and backfills it from created_at on every row that can still be dispatched: queued and cancelled entries, and anything created in the last day. Older dispatched entries are left null rather than rewritten, because work_queue keeps every dispatched entry until metadata cleanup removes it, and rewriting them all locks and rewrites the whole table. Nothing reads confirmed_at on a dispatched entry, but a report or query of your own that does will see null there.

SDK Reference

ITrainExecutionService | Mutations | Junctions