Skip to main content

Deferred Choice

View as Markdown

Use 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.

Pseudocode
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.

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.

offer_response_flow.pyPython
# 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.