How-to guide

Try a decider in shadow

Source Questions.cs, Tracks.cs

A shadow is asked every question the live decider is asked, alongside it. Its answers are recorded with whether each agreed, and never acted on: a shadow that fails, disagrees or is slow changes nothing. Use one to move from a rule table to a model, or from one model version to the next. Decisions explains the design.

1. Give the candidate its own type

A shadow is named by type, so the candidate needs a type the live decider does not have. An interface that extends IDecider works both from AddServices and from the container:

using Trax.Core.Decisions;
 
public interface ICandidateDecider : IDecider;

A shadow handed to AddServices is passed as an interface, as every service there is. From the container, register it under the type the step names; a concrete class works there too.

2. Declare the shadow on the step that asks

On a Decide, add Shadow<T>() to the questions:

using Trax.Core.Decisions;
using Trax.Core.Functional;
using Trax.Core.Train;
 
public class ModeratePost(IDecider decider, ICandidateDecider candidate)
    : Train<Post, ModerationOutcome>
{
    protected override Task<Either<Exception, ModerationOutcome>> Junctions() =>
        AddServices(decider, candidate)
            .Decide<Post>(q => q.Choice<Verdict>().Shadow<ICandidateDecider>())
            .Switch<Verdict>(tracks =>
                tracks
                    .When(Verdict.Allow, t => t.Chain<PublishPost>())
                    .When(Verdict.Remove, t => t.Chain<TakeDownPost>())
                    .When(Verdict.Review, t => t.Chain<QueueForModerator>())
            )
            .Resolve();
}

A Switch, Gate or Scale that asks its own question declares shadows on its tracks instead:

using Trax.Core.Decisions;
using Trax.Core.Functional;
using Trax.Core.Train;
 
public class ModeratePostInline(IDecider decider, ICandidateDecider candidate)
    : Train<Post, ModerationOutcome>
{
    protected override Task<Either<Exception, ModerationOutcome>> Junctions() =>
        AddServices(decider, candidate)
            .Switch<Post, Verdict>(tracks =>
                tracks
                    .When(Verdict.Allow, t => t.Chain<PublishPost>())
                    .Otherwise(t => t.Chain<QueueForModerator>())
                    .Shadow<ICandidateDecider>()
                    .WaitForShadows(TimeSpan.FromSeconds(2))
            )
            .Resolve();
}

A routing step that only routes on an earlier Decide asks nothing, so a shadow declared on it is refused at startup; shadow the Decide. A shadow that neither AddServices nor the container supplies is refused at startup too, and a run that reaches it fails Permanent, as it does for a missing live decider.

3. Bound the wait

Once the live answer is in, the run waits for the shadows at most WaitForShadows(TimeSpan), five seconds by default, then cancels them and records them as not having answered, whether or not they honour the cancellation. Every run can wait that long for a slow shadow, so keep it short. Zero waits only for shadows that have already answered. A negative or unbounded wait is refused at startup.

4. Read the results

Each shadow's answer arrives with the live one, in DecisionMade.Shadows, as a ShadowAnswer: the shadow's type, its Answer (null when it gave none), Agrees, and Error. Trax.Effect's decision recording stores them in the shadows column of the run's decisions; see Recording decisions. A host that records some other way implements IDecisionObserver (Decision replay and state hash rules).

What Agrees means depends on the step:

Shadow onAgrees when
A Switch, Gate or Scale that asks its own questionThe shadow's answer would have sent the train down the same track, by the step's own bars and bands.
A DecideThe same option, the same nearest level, or the same side of one half for a yes/no.

A shadow whose answer does not fit the question never agrees, and neither does any shadow when the live answer takes no track (a gate's band with no Unsure track, a choice with no track and no Otherwise). A question whose answer is replayed is not put to the shadows at all.

5. Switch over

When the shadow agrees often enough on the cases that matter, register it as the live IDecider and remove the Shadow<T>() call, or keep the old decider as a shadow for a while to watch the change from the other side.

What a shadow can touch

  • Its own scope. A shadow from the container is built in a DI scope of its own, disposed when the shadow ends, so it never shares the run's scoped services (a DbContext, say) with the live decider or with the junctions after the decision. A shadow that has not finished a second after it was cancelled has its scope disposed under it, on a timer the run never waits for, and whatever it then fails with is recorded as its own failure. One handed to AddServices is the instance the run holds, used as it is.
  • Its own copy of the state. The state is written to JSON once per asking, with the web defaults the System One adapter uses, and read back separately for each shadow, so a shadow that changes its copy changes nothing the run or another shadow sees. A state type JSON cannot write and read back is refused at startup when shadows are declared on it and the type alone says so; otherwise a shadow whose copy cannot be made is recorded as not having answered, and the run goes on.

SDK Reference

Decide | Switch | Gate | Scale | AddServices