Recover a deciding train without asking the model again
Source Decisions/, DecisionRecordingStartupCheck.cs
A run replays the decisions of another run only when it was queued to: by a manifest's automatic
retry, a requeue of the manifest's dead letter, or a requeue of an execution (requeueExecution,
the dashboard's Re-queue). A train run directly (Run, a [TraxQuery] or [TraxMutation] in
Run mode) or queued with queueTrain / QueueTrainAsync never replays, and neither does a
manifest's scheduled run that is not a retry. So a train whose crash should be recovered without
asking the model again runs from a manifest. The rules a replay follows are on
Decision records.
1. Record decisions on every host that runs the train
using Trax.Effect.Data.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
services.AddTrax(trax => trax.AddEffects(effects => effects
.UsePostgres(connectionString)
.AddDecisionRecording(o => o.HashStatesWith(Convert.FromBase64String(configuration["StateHashKey"]!)))));- Call
AddDecisionRecording()after the data provider. Before one, it does not compile. - Use Postgres or Sqlite, not InMemory. A retry is linked to the failed run by comparing the work queue entry's input, which the InMemory scheduler does not keep; on InMemory nothing fails, the retry asks afresh.
- Give the host a state hash key when a decision's state can reach a
[TraxSensitive]member. Without one, such a decision is stored with no hash and never replays, and a warning names the state type once. Every process that may repeat a run uses the same key. - Register any
IDecisionObserverof your own beforeAddTrax. One registered after it replaces the composite recording depends on, and the host refuses to start, naming the observer.
A host that runs a deciding train without AddDecisionRecording() refuses to start and names the
trains (A host that does not record).
The decider itself comes from the container or Memory as usual; without one, the startup check refuses the chain. Answer decisions with Nimble sets one up.
2. Run the train from a manifest with retries
using Trax.Scheduler.Services.TraxScheduler;
var manifest = await scheduler.ScheduleOnceAsync<IUnderwriteLoanTrain, LoanApplication, LoanDecision>(
new LoanApplication("a3", 700),
TimeSpan.Zero,
options => options.MaxRetries(3),
ct);MaxRetries must be 1 or more (the default is 3), and ReplayDecisionsOnRetry must stay on, which
is the default. Trax.Api has no GraphQL operation that creates a manifest, so a host whose callers
start such runs over GraphQL exposes its own: a [TraxMutation] train whose junction makes the
call above and returns the manifest's Id, which operations.executions(manifestId:) takes to
list each attempt. The Recovery sample does this.
3. Keep the input and the state stable between attempts
- The manifest's input must be byte-identical between attempts. The scheduler links a retry only to a failed run queued with exactly the same input. Keep anything that varies between attempts, a fault-injection flag for instance, out of it.
- The state each decision is asked about must hash the same on the retry: no timestamp, random
id, counter or cache in it. An answer whose state hashes differently is asked afresh, with the
reason in
replay_refused.
Trax has no per-junction retry. The retry runs the chain from its first junction, and only the answers are replayed, so there is something to save only when the crash happens after the decisions.
4. Check what the retry did
| Between the failure and the retry | The retry |
|---|---|
| Nothing changed | Is linked to the failed run (replay_decisions_of); every answer replays, replayed set, no decider asked |
| Data the state is built from changed | Is still linked; the replay refuses each answer whose state hashes differently, stores the reason in replay_refused and asks afresh. Not replay_abandoned |
| The manifest's input was edited | Is not linked: asks afresh |
triggerManifest(askAfresh: true) before the dispatcher claimed the retry | Is not linked: asks afresh |
The failed run was deleted, or the retry ran on a host without AddDecisionRecording() | Was linked, could not honour it: asks afresh and is marked replay_abandoned |
| The failed run had itself replayed another run | Is not linked: a retry replays at most once in a row |
To see why an answer was asked afresh, read the run's rows of trax.decision: Replayed, and in
the Answer JSON a replay_refused property with the reason. The run's
Metadata.ReplayDecisionsOf and Metadata.ReplayAbandoned say whether it was linked and whether
the link was abandoned. Neither GraphQL nor junction events carry these: a step says replayed,
not why it was not.
using System.Text.Json.Nodes;
using Microsoft.EntityFrameworkCore;
await using var context = await dataContextFactory.CreateDbContextAsync(ct);
var rows = await context.RecordedDecisions.AsNoTracking()
.Where(d => d.MetadataId == metadataId).OrderBy(d => d.Id).ToListAsync(ct);
var refused = rows.Select(d => d.Answer is null
? null
: JsonNode.Parse(d.Answer)?["replay_refused"]?.GetValue<string>());dataContextFactory is an IDataContextProviderFactory from the container.
IOperationsService.GetRecordedDecisionsAsync
reads a run's rows a page at a time, leaving out an answer to a [TraxSensitive] question and
every decision on a track taken on one.
SDK Reference
AddDecisionRecording | ScheduleOnceAsync | IOperationsService