Deferred Choice
View as MarkdownUse Deferred Choice when a Flow waits for one of several events and the first event decides what happens next. The events come from outside the Flow. The Flow cannot pick a branch in advance.
An example is an offer to a customer. The customer can accept it or reject it. If the customer does neither within 3 days, the offer expires. Whichever happens first wins. The other two no longer change the chosen branch.
How it works
One Step waits for every event at once. Its WaitFor returns AnyOf with one Condition per event. Each customer response is a Channel Condition. The deadline is a Timer.
When the first Condition is satisfied, the Step's Execute runs. It checks which Condition won and moves to the matching branch.
rpc acceptCustomerOffer(note) -> OfferAccepted.publish(note)
rpc rejectCustomerOffer(note) -> OfferRejected.publish(note)
AwaitOfferResponse.waitFor -> AnyOf(OfferAccepted.ForOne(), OfferRejected.ForOne(), Timer(3 days))
AwaitOfferResponse.execute
if the Timer fired: goTo(ExpireOffer)
if OfferAccepted has a result: goTo(FulfillOffer)
otherwise: goTo(CloseRejectedOffer)
Only one Condition wins, so only one branch ever starts.
Rules
- AnyOf consumes a message only from the Condition that wins. A response that lost stays pending in its Channel.
- When several Channel Conditions are ready at the same time, the first one declared wins. Declare them in business priority order. In the example, an acceptance and a rejection that are both pending select the acceptance.
- In Execute, check HasTimerFired for the Timer. Do not treat a missing response as expiry. Read the Channel results for the responses. A Channel that lost returns no results.
- The Timer starts when the Step starts waiting. It survives Worker and Server restarts. While the Step waits, it holds no Worker.
- A response that arrives after the choice cannot change the chosen branch. While the Flow still runs, the RPC succeeds and publishes a message that no Step consumes. After the Flow closes, Dex rejects the RPC. So a successful RPC call does not mean the response won. A caller that needs to know the outcome reads the Flow result.
- To test the expiry branch, skip the Timer with the Client SkipTimer. Do not shorten the business deadline for tests.
Related shapes
- Reminder, Inactivity tracker, and Inbound Event Loop also wait on AnyOf of a Timer and a Channel. They loop back to the same Step. Deferred Choice decides once and moves on.
- First-Win Parallel Steps races work that the Flow started itself. Deferred Choice races events from outside.
Example
OfferResponseFlow starts with the offer ID. AwaitOfferResponse waits for the acceptCustomerOffer RPC, the rejectCustomerOffer RPC, or the 3-day Timer. It moves to FulfillOffer, CloseRejectedOffer, or ExpireOffer. Each of those completes the Flow with its outcome.
# How long the customer has to respond.
OFFER_EXPIRY = timedelta(days=3)
OFFER_ACCEPTED = Channel("offerAccepted", str)
OFFER_REJECTED = Channel("offerRejected", str)
class AwaitOfferResponse(Step[str]):
"""Waits for whichever event happens first and routes on it."""
def wait_for(self, context: Context, offer_id: str) -> Wait:
return Wait.any_of(
OFFER_ACCEPTED.for_one(),
OFFER_REJECTED.for_one(),
Timer.by_duration(OFFER_EXPIRY),
)
def execute(self, context: Context, offer_id: str) -> StepDecision:
if context.has_timer_fired():
return go_to(ExpireOffer, offer_id)
if OFFER_ACCEPTED.results(context):
return go_to(FulfillOffer, offer_id)
return go_to(CloseRejectedOffer, offer_id)
Example: examples/python/dex_examples/patterns/deferred-choice/offer_response_flow.py
Anti-patterns
- One parallel Step per event, where the first to finish cancels the others. One AnyOf wait makes the choice with no race between branches.
- A Timer loop that checks an Attribute to see whether the customer answered.
- A separate Flow, cron job, or database job that enforces the deadline.