Our order can now wait for a shipping inquiry without pretending that shipping has already succeeded.
The request receives an identifier. The order enters CHECKING_SHIPPING. A matching response may move it to READY_TO_SHIP, while a timeout or unsuccessful result returns it to PAID.
But Part V ended with another requirement:
Before proceeding, the system must obtain both a carrier response and an inventory result.
Those operations may finish in either order.
The carrier might answer first. The inventory system might answer first. One might fail while the other is still working.
How do we combine their results without depending on a particular sequence?
Start with the smallest solution
We could simply perform the checks one after another:
carrier_available = await check_carrier(request) if carrier_available: inventory_available = await check_inventory(request) This is a reasonable design.
It has an obvious execution order. If the carrier rejects the destination, we avoid the inventory lookup entirely. There is little coordination logic to maintain.
We should preserve that option when the work is cheap, the second operation depends on the first, or avoiding unnecessary requests matters more than overlapping their execution.
But suppose both checks can begin from information we already possess. Neither needs the other’s result.
The serial implementation imposes a dependency that the business rule does not require:
Carrier finishes ↓ Inventory may begin What we actually require is different:
Carrier succeeds + Inventory succeeds ↓ The combined check succeeds Both must finish successfully. Neither must finish first.
Separate checking inventory from reserving it
Before introducing concurrent execution, we need to settle what the inventory operation does.
Checking whether stock is available is not the same as reserving it.
| Operation | What it establishes |
|---|---|
| Check inventory | The inventory service reports whether the order’s items are currently available. |
| Reserve inventory | The inventory service commits stock to this order under its reservation policy. |
| Release inventory | The service removes an earlier reservation. |
The end of Part V raised inventory reservation as a future requirement. We will reach its failure consequences later in this part.
First, we will implement two read-only availability checks. Neither books a shipment nor reserves stock.
This lets us establish the coordination rule without quietly introducing external changes that would need to be undone.
Our inventory adapter will look up the order’s item requirements using its identifier. We are not adding an item model in this lesson. As with the destination in Part V, those requirements remain fixed during one check.
READY_TO_SHIP therefore remains a state in our local simulation. It does not promise that stock is reserved or that carrier capacity will remain available indefinitely.
Fork the work, then join the results
Starting independent branches from one request is commonly described as a fork. Combining their results before continuing is a join.
Our intended flow is:
Shipping check │ fork ┌────┴────┐ ▼ ▼ Carrier Inventory check check │ │ └────┬────┘ join │ ▼ Combined result The join is not simply the point where two lines meet in a diagram.
It must answer a precise question:
Have all the required branches for this particular request succeeded?
These are two valid execution sequences:
| First result | Second result | Combined outcome |
|---|---|---|
| Carrier available | Inventory available | Available |
| Inventory available | Carrier available | Available |
After the first successful result, the combined answer is still incomplete.
Starting both operations is not enough. Finishing either operation is not enough.
Readiness requires a successful result from every required branch of the current check.
Why a completion counter is insufficient
We might begin with:
completed_checks = 0 Then increment it whenever a successful result arrives:
completed_checks += 1 if completed_checks == 2: report_available() Now deliver these messages:
Carrier available Carrier available again The counter reaches two.
The inventory system has not answered.
We counted messages when the rule required distinct prerequisites.
A duplicate result must not satisfy another branch’s obligation. We need to remember which branch answered, not merely how many messages arrived.
We also need to retain the request identifier from Part V. A carrier answer from yesterday must not combine with an inventory answer for today’s request.
The group of results belongs to:
order identifier + check request identifier Within that group, each result belongs to a named branch.
This is the coordination problem described by the Aggregator pattern in Enterprise Integration Patterns: collect related messages, determine when enough information has arrived, and produce one combined result. The pattern separates correlation, completeness, and the rule used to combine results.[2]
For our case, those decisions are:
Keep the order’s lifecycle intact
We do not need to expose every partial combination as another OrderState.
The order can remain in:
CHECKING_SHIPPING while a smaller coordination component tracks the branch results.
This preserves the lifecycle from Part V:
PAID ↓ CHECK_SHIPPING CHECKING_SHIPPING ↓ combined positive result READY_TO_SHIP ↓ SHIP_ORDER SHIPPED The meaning of the positive result is now broader.
Previously, SHIPPING_AVAILABLE meant that the carrier inquiry succeeded. In this version, it means that both required availability checks succeeded.
That is an intentional change in the operation’s contract. A raw carrier response must no longer go directly to the order as SHIPPING_AVAILABLE. It must first pass through the component that combines the results.
The outer ShippingCheckResult envelope can remain unchanged:
@dataclass(frozen=True) class ShippingCheckResult: order_id: str request_id: str event: OrderEvent The order receives one combined answer through the same interface.
Partial progress belongs inside the check, not in the order’s public lifecycle vocabulary.
Give branch results their own vocabulary
Let’s distinguish where a reply came from and what it reports:
from dataclasses import dataclass, replace from enum import Enum class CheckBranch(Enum): CARRIER = "carrier" INVENTORY = "inventory" class CheckOutcome(Enum): AVAILABLE = "available" UNAVAILABLE = "unavailable" FAILED = "failed" @dataclass(frozen=True) class BranchResult: order_id: str request_id: str branch: CheckBranch outcome: CheckOutcome UNAVAILABLE means that the lookup completed and returned a negative answer.
FAILED means that an expected service failure prevented us from obtaining an answer.
Those outcomes both prevent readiness under our current policy, but they do not say the same thing.
We can now retain partial progress:
@dataclass(frozen=True) class CheckProgress: request: ShippingCheck carrier: CheckOutcome | None = None inventory: CheckOutcome | None = None conclusion: OrderEvent | None = None The existing ShippingCheck contains the order identifier, request identifier, and copied delivery address.
The two branch fields initially contain None, meaning no result has been accepted for that branch.
conclusion is also initially absent. Once set, this check is closed.
For example:
request = check-A carrier = AVAILABLE inventory = None conclusion = None means that one prerequisite has succeeded and the check is still waiting.
This is not “half an order transition.” It is a complete, valid configuration of the coordination process.
Decide what failure means before writing the handler
There are several reasonable completion policies.
We could wait for every result, even after one branch fails. That would collect more diagnostic information.
We could stop once success becomes impossible. That would avoid waiting for an answer that can no longer change the overall decision.
For these read-only checks, we will choose the second policy:
| Accepted branch result | Coordinator action |
|---|---|
| First positive result | Record it and continue waiting |
| Second distinct positive result | Conclude SHIPPING_AVAILABLE |
| Any negative result | Conclude SHIPPING_UNAVAILABLE |
| Any expected lookup failure | Conclude SHIPPING_CHECK_FAILED |
| Duplicate of an accepted result | Do not count it again |
| Result for another request | Do not apply it |
Once a conclusion exists, later replies cannot reopen the check.
If different failures compete, the first decisive result processed by the coordinator determines the reported outcome. This is an ordering policy, not a claim that the same failure detail will win under every possible schedule.
For successful checks, however, the completion order must not matter.
Apply one branch result at a time
The following function receives the current progress and one reply. It returns updated progress and, only when the check first concludes, a combined result.
def accept_branch_result( progress: CheckProgress, reply: BranchResult, ) -> tuple[CheckProgress, ShippingCheckResult | None]: request = progress.request if reply.order_id != request.order_id: raise ValueError( "Result belongs to a different order." ) if not isinstance(reply.branch, CheckBranch): raise TypeError("Unknown check branch.") if not isinstance(reply.outcome, CheckOutcome): raise TypeError("Unknown check outcome.") if reply.request_id != request.request_id: return progress, None if progress.conclusion is not None: return progress, None previous = ( progress.carrier if reply.branch is CheckBranch.CARRIER else progress.inventory ) if previous is not None: if previous is not reply.outcome: raise ValueError( "Conflicting result for one branch." ) return progress, None if reply.branch is CheckBranch.CARRIER: updated = replace( progress, carrier=reply.outcome, ) else: updated = replace( progress, inventory=reply.outcome, ) if reply.outcome is CheckOutcome.FAILED: event = OrderEvent.SHIPPING_CHECK_FAILED elif reply.outcome is CheckOutcome.UNAVAILABLE: event = OrderEvent.SHIPPING_UNAVAILABLE elif ( updated.carrier is CheckOutcome.AVAILABLE and updated.inventory is CheckOutcome.AVAILABLE ): event = OrderEvent.SHIPPING_AVAILABLE else: return updated, None updated = replace(updated, conclusion=event) return updated, ShippingCheckResult( order_id=request.order_id, request_id=request.request_id, event=event, ) The function does not call either service. It does not change the order. It calculates the consequence of receiving one result.
An identical duplicate leaves progress unchanged.
A contradictory second answer for an already recorded branch raises an error while the group is still open. Our protocol expects one final answer per branch. It does not support silently revising an earlier answer.
After the group closes, subsequent replies are irrelevant to its decision. A production diagnostic path could still record them, but they cannot authorize another transition.
We now have a small stateful process inside the larger workflow.
Its control condition is whether it has concluded. Its supporting data identifies the request and the accepted branch outcomes.
Test the join without relying on task scheduling
Let’s construct a request and its two successful replies:
request = ShippingCheck( order_id="order-66", request_id="check-A", delivery_address="Istanbul", ) carrier_available = BranchResult( order_id=request.order_id, request_id=request.request_id, branch=CheckBranch.CARRIER, outcome=CheckOutcome.AVAILABLE, ) inventory_available = replace( carrier_available, branch=CheckBranch.INVENTORY, ) Now test both possible success orders:
for first, second in ( (carrier_available, inventory_available), (inventory_available, carrier_available), ): progress = CheckProgress(request) progress, result = accept_branch_result( progress, first, ) assert result is None assert progress.conclusion is None progress, result = accept_branch_result( progress, second, ) assert result is not None assert result.event is OrderEvent.SHIPPING_AVAILABLE closed = progress progress, repeated = accept_branch_result( progress, second, ) assert progress == closed assert repeated is None Neither branch can complete the join alone. Either can complete it when its counterpart has already succeeded.
The last assertions verify another property: a closed check does not produce a second combined result when a reply repeats.
That is a local property of this coordinator instance. It is not an exactly-once delivery guarantee across crashes or restarts.
Now test the counter mistake directly:
progress = CheckProgress(request) progress, _ = accept_branch_result( progress, carrier_available, ) after_first = progress progress, result = accept_branch_result( progress, carrier_available, ) assert progress == after_first assert progress.inventory is None assert result is None Two carrier messages still satisfy only the carrier prerequisite.
An inventory result from an older request must also remain irrelevant:
stale_inventory = replace( inventory_available, request_id="older-check", ) progress, result = accept_branch_result( progress, stale_inventory, ) assert progress == after_first assert progress.inventory is None assert result is None Finally, a negative answer closes the current check:
inventory_unavailable = replace( inventory_available, outcome=CheckOutcome.UNAVAILABLE, ) progress, result = accept_branch_result( progress, inventory_unavailable, ) assert result is not None assert result.event is OrderEvent.SHIPPING_UNAVAILABLE If a later positive reply arrives, it does not reverse this conclusion. Starting another inquiry requires a new request identifier and a new CheckProgress.
An older model for independent conditions
Carl Adam Petri’s 1962 dissertation, Kommunikation mit Automaten, investigated the foundations of communication between computational mechanisms.[3]
Petri nets provide a way to model concurrent processes using places, transitions, and tokens. Places describe conditions, tokens mark which conditions currently hold, and transitions consume and produce tokens. The token distribution is called a marking.[4]
The successful path of our check can be sketched as:
(check requested) │ [fork] ┌────┴────┐ ▼ ▼ (carrier pending) (inventory pending) │ │ [carrier succeeds] [inventory succeeds] │ │ ▼ ▼ (carrier passed) (inventory passed) └────┬────┘ [join] │ ▼ (checks passed) Parentheses represent places; brackets represent transitions.
The join requires a token from both successful branches. Firing it consumes those prerequisites and produces the combined completion condition.[4]
This drawing shows only the successful path. Negative results and abandonment need additional paths.
Our Python coordinator is not a general Petri-net engine. But it applies the same useful distinction: independent conditions can become true in either order, while a later action requires their conjunction.
The diagram does not make network calls atomic. It describes when the modeled continuation becomes eligible.
Did we escape state machines?
No.
A bounded set of branch outcomes can still be represented by a finite machine with a state for every combination.
For two branches, that may even be manageable.
The problem is not that a flat FSM is incapable of describing the behavior. The problem is that manually naming every combination makes the independent structure harder to see.
Our representation keeps that structure visible:
Carrier progress + Inventory progress + Completion rule The full configuration still has combinations. We have organized them rather than eliminated them.
This is also why the join is more precise than another large lifecycle label. It states which conditions must coexist before the process continues.
Run the branches concurrently without letting them mutate the order
We can now connect the decision logic to asynchronous work.
Python’s TaskGroup starts and owns related tasks, waits for their completion, and propagates unexpected failures. Its task management does not decide whether our business prerequisites passed; that remains the coordinator’s responsibility.[1]
The following executor replaces the single-carrier executor from Part V.
Both adapters receive the immutable ShippingCheck. The carrier adapter uses its destination. The inventory adapter resolves the fixed item requirements using its order identifier.
Expected service failures are translated into AvailabilityLookupError:
import asyncio from typing import Awaitable, Callable class AvailabilityLookupError(Exception): pass AvailabilityLookup = Callable[ [ShippingCheck], Awaitable[bool], ] Now execute and combine the two branches:
async def execute_check( request: ShippingCheck, carrier: AvailabilityLookup, inventory: AvailabilityLookup, inbox: asyncio.Queue[ShippingCheckResult], ) -> None: progress = CheckProgress(request) replies: asyncio.Queue[BranchResult] = ( asyncio.Queue(maxsize=2) ) async def run_branch( branch: CheckBranch, lookup: AvailabilityLookup, ) -> None: try: available = await lookup(request) except AvailabilityLookupError: outcome = CheckOutcome.FAILED else: if type(available) is not bool: raise TypeError( "Availability lookup must return bool." ) outcome = ( CheckOutcome.AVAILABLE if available else CheckOutcome.UNAVAILABLE ) await replies.put( BranchResult( order_id=request.order_id, request_id=request.request_id, branch=branch, outcome=outcome, ) ) result: ShippingCheckResult | None = None async with asyncio.TaskGroup() as group: workers = ( group.create_task( run_branch(CheckBranch.CARRIER, carrier) ), group.create_task( run_branch(CheckBranch.INVENTORY, inventory) ), ) while result is None: reply = await replies.get() try: progress, result = accept_branch_result( progress, reply, ) finally: replies.task_done() for worker in workers: if not worker.done(): worker.cancel() await inbox.put(result) The scheduling callback from Part V now supplies both adapters when it starts this executor.
Neither branch receives the mutable order. Each produces a BranchResult.
One coordinator processes those results sequentially and updates its own local CheckProgress. Only the combined result enters the order’s inbox.
The private queue has room for the two messages these adapters can produce. Its FIFO behavior orders arrivals to this coordinator; it does not impose an order on events in remote services.[5]
We have separated two forms of activity:
External work overlaps. Local decisions about the collected results remain sequential.
A finished task is not necessarily a successful prerequisite
An inventory adapter returning False has completed normally as a Python task.
It has not satisfied the inventory prerequisite.
That distinction is why we translate the return value into an explicit outcome rather than treating task completion as business success.
The executor catches only the expected AvailabilityLookupError. An invalid return type or another programming error is not silently converted into “unavailable.”
On a decisive negative result or expected lookup failure, the coordinator requests cancellation of unfinished sibling tasks. The task group waits for them to finish their cleanup before the executor publishes the combined result.[1]
The adapters must use bounded I/O waits and cooperate with cancellation. A task that blocks the event loop or suppresses cancellation can still delay cleanup.
The task group gives the asynchronous work a lifetime boundary. It does not make arbitrary adapter code reliable.
For a simple case with no need to inspect partial results, a task group followed by an all() check could be enough. Our explicit coordinator earns its place when branch identity, duplicates, stale messages, and early completion rules need to be examined and tested separately.
What happens to the timeout from Part V?
The order’s existing timeout still applies to the entire ShippingCheck.
It is associated with the same request identifier, not separately with “the first branch” or “the second branch.”
Consider:
| Step | What happens | Order behavior |
|---|---|---|
| 1 | Check A begins | Enter CHECKING_SHIPPING |
| 2 | Carrier A succeeds | No aggregate result yet |
| 3 | The overall timeout for A is handled | Return to PAID |
| 4 | Inventory A succeeds | Coordinator may produce a result for A |
| 5 | That result reaches the order | Ignore it because A is no longer outstanding |
The outer handler from Part ...

