Reading Along: DeliveryDispatch¶
Guide Home | Previous: Reading Along: SupportChat | Next: Reading Along: ShoppingMall
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
What you get from this chapter
You can open the DeliveryDispatch sample in an editor and follow the code from a delivery request accepted over HTTP, through the offer to a courier and the reassignment on no answer, to the status pushed to the customer. The code in this chapter comes from the DeliveryDispatch sample in the per-language example repositories.
Picking a Sample introduced what this sample demonstrates. This chapter is what you read after that introduction — the roles and where their code lives, the message flow of the main scenarios, and, for each flow, the framework feature it uses and the chapter that explains it, in the order the source is laid out. This chapter explains the DeliveryDispatch sample's roles and code locations, its main message flows, and its run verification in source order. See the DeliveryDispatch scenario for requirements, message contracts, and verification criteria.
1. What This Sample Demonstrates¶
The customer creates a delivery over HTTP and receives its status over STREAM. The courier receives offers over STREAM and sends decisions. The servers in between keep no session map and no socket registry — the courier and the customer are each an Actor with an id, and offers and statuses are pushed to the session bound to that Actor. No handler holds an execution turn while waiting for the courier's answer; the offer deadline is a record that Dispatch manages.
The flow this chapter follows is the courier binding → HTTP intake and the first offer → the offer push and the decision → reassignment on timeout → status recording and the customer push.
2. Roles and Where the Code Lives¶
| Role | Processes | Owns | Code |
|---|---|---|---|
| Dispatch | 1 | HTTP intake, candidate selection, offer records and deadlines, reassignment | Server/Dispatch |
| CourierSession | 1 | Courier STREAM, courier Actor binding and relay | Server/CourierSession |
| CourierActorNode | 2 | Courier Entry Spot and courier Actors | Server/CourierActorNode |
| Tracking | 1 | Status event recording, forwarding to the customer Actor | Server/Tracking |
| CustomerGateway | 1 | Customer STREAM, customer Actor binding and pushes | Server/CustomerGateway |
| Client | 1 | The customer and courier scenarios with self-checks | Client |
Candidate order, deadlines and attempt validation belong to Dispatch's Application, and the status
transitions to Tracking's Domain; neither references a framework type. Messages are set by the JSON
contract in shared.
3. Server Configuration¶
Dispatch is an object client of the courier mesh, the server of the dispatch channel, and a client of the tracking channel. In the implementations whose HTTP handler sends the request over the dispatch channel, the same process is that channel's client as well.
Server/DispatchCenter/dispatch-center-module.ts
const mesh = builder
.addRouteMesh(SampleNames.courierMeshName)
.listen(config.dispatchSpotEndpoint)
.routingId(DeliveryDispatchNodeIds.dispatch);
mesh.objects().client();
const dispatchChannel = builder.addClientServerChannel(SampleNames.dispatchChannel);
dispatchChannel.client();
dispatchChannel.server().listen().addHandlerGroup('dispatch');
builder.addClientServerChannel(SampleNames.trackingChannel).client();
CourierActorNode registers the courier Entry Spot and the courier Actor factory as an object server. Both nodes provide the same type, so the framework picks the node an Actor is created on.
Server/Courier/courier-module.ts
const mesh = builder
.addRouteMesh(SampleNames.courierMeshName)
.listen(spotEndpoint)
.routingId(nodeId);
const objectServer = mesh.objects().server();
objectServer.addEntrySpot(CourierEntrySpot);
objectServer.addActorFactory(
SampleNames.courierActorType,
CourierActorFactory,
(factory) => factory.disableRelocation()
);
builder.addClientServerChannel(SampleNames.dispatchChannel).client();
The client and server roles of a channel are covered by Channel Messaging, and the difference between an object server and an object client by Spot.
4. Courier Binding¶
When the courier client connects over STREAM and asks to bind, CourierSession creates or finds the Actor by the courier id and binds it to the current session.
Server/CourierSession/courier-session.ts
async handle(
context: ZLinkSessionContext,
_dispatch: ZLinkSessionDispatchContext,
payload: ZLinkMessage
): Promise<void> {
const request = payload.decode(BindCourierSessionReq);
const actorRef = await this.findOrEnsureActor(request.courierId);
const actor = await context.actors.bindOrGet(actorRef);
console.log(`deliverydispatch-courier bound courier=${request.courierId}`);
await actor.relay(payload);
}
The call that obtains the Actor does not decide which CourierActorNode creates it. Binding is covered by Session and Actor.
5. HTTP Intake and the First Offer¶
The customer's HTTP request arrives at Dispatch's endpoint. The handler hands the request to the worker and replies at once — some implementations do so with a one-way send on the dispatch channel, others through an in-process queue.
Server/DispatchApi/dispatch-api-server.ts
if (request.method === 'POST' && request.url === '/deliveries') {
const body = await readJson<CreateDeliveryReq>(request);
submitDispatch(channels, body);
console.error(`deliverydispatch api: created delivery=${body.deliveryId}`);
sendJson(response, 200, { deliveryId: body.deliveryId } satisfies CreateDeliveryRes);
return;
}
The worker picks the first candidate, reports the Assigned status to Tracking, records the offer with its attempt and deadline, and sends the offer to the courier Actor. The turn that sends the offer ends there.
Server/DispatchCenter/dispatch-worker.ts
const courierId = courierCandidates[attempt - 1];
const actor = await this.findOrEnsureActor(courierId);
await this.publishStatus(
deliveryStatusChanged(
request.deliveryId,
request.customerId,
attempt === 1 ? 'Assigned' : 'Reassigned',
courierId
)
);
const offer: DeliveryOffer = {
deliveryId: request.deliveryId,
customerId: request.customerId,
pickupAddress: request.pickupAddress,
dropoffAddress: request.dropoffAddress,
courierId,
attempt,
deadline: Date.now() + SampleTimings.offerDecisionTimeout,
status: 'Offered'
};
this.offers.save(offer);
await this.actors
.sendToActor(
actor.actorId,
offerDelivery(
courierId,
request.deliveryId,
attempt,
request.pickupAddress,
request.dropoffAddress
)
)
.submit();
The offer is an Actor direct send addressed by the courier id. It is not a request, so nothing waits for a reply.
Server/DispatchCenter/dispatch-worker.ts
await this.actors
.sendToActor(
actor.actorId,
offerDelivery(
courierId,
request.deliveryId,
attempt,
request.pickupAddress,
request.dropoffAddress
)
)
.submit();
Calling an Actor by id is covered by Actor, and the difference between send and request by Channel Messaging.
6. The Offer Push and the Decision¶
The offer arrives at the courier Actor's Entry Spot handler. The Actor remembers the attempt and pushes an offer notify to its bound session.
Server/Courier/courier-actor.ts
async offer(request: OfferDeliveryMsg): Promise<void> {
this.offers.set(request.deliveryId, request.attempt);
await this.context.boundSession
.send(
new OfferDeliveryNotify(
request.courierId,
request.deliveryId,
request.pickupAddress,
request.dropoffAddress
)
)
.submit();
}
The courier's decision comes in over the same connection and is relayed to the Actor. The handler attaches the remembered attempt to the decision and sends it one-way to the dispatch channel. The node neither judges the decision nor times it.
Server/Courier/courier-actor.ts
async decide(decision: CourierDecisionMsg): Promise<void> {
const attempt = this.offers.get(decision.deliveryId);
if (attempt === undefined) {
// Contract README §7.2: a decision that no longer matches a live offer is a
// stale/unrecognized decision and is discarded without status effect, not an
// error. Mirrors the .NET reference (CourierDecisionActorHandler): log and return.
console.log(
`deliverydispatch-courier decision-unknown-offer delivery=${decision.deliveryId} courier=${this.actorId}`
);
return;
}
await this.channels
.sendToChannel(
SampleNames.dispatchChannel,
new OfferDeliveryResultMsg(
decision.deliveryId,
decision.courierId,
attempt,
decision.accepted,
decision.reason
)
)
.submit();
}
Dispatch's handler applies a decision only when its attempt equals the current attempt on record. A decision from the previous courier that arrives after a reassignment is ignored as stale.
Server/DispatchCenter/dispatch-worker.ts
private async applyResult(result: OfferDeliveryResultMsg): Promise<void> {
const current = this.offers.get(result.deliveryId);
if (
current === undefined ||
current.status !== 'Offered' ||
current.attempt !== result.attempt ||
current.courierId !== result.courierId
) {
console.log(
`deliverydispatch-dispatch stale-decision-ignored delivery=${result.deliveryId} ` +
`courier=${result.courierId} attempt=${result.attempt}`
);
return;
}
if (result.accepted) {
this.saveStatus(current, 'Accepted');
await this.continueAcceptedDelivery(current);
return;
}
this.saveStatus(current, 'Rejected');
await this.startOffer(current, current.attempt + 1);
}
The handler kinds are covered by Handlers and Message Processing.
7. Reassignment on Timeout¶
The deadline is checked by Dispatch's sweeper. Each period it takes the expired offers and calls reassignment; no handler waits for a courier.
Server/DispatchCenter/dispatch-worker.ts
private async sweepExpiredOffers(): Promise<void> {
const now = Date.now();
for (const offer of this.offers.offered()) {
if (offer.deadline > now) continue;
this.saveStatus(offer, 'Expired');
await this.startOffer(offer, offer.attempt + 1);
}
}
Reassignment repeats the same procedure with the next candidate under a new attempt. When no candidate is left, the delivery ends in the Failed status.
Server/DispatchCenter/dispatch-worker.ts
private async startOffer(request: AssignDeliveryMsg, attempt: number): Promise<void> {
if (attempt < 1 || attempt > courierCandidates.length) {
const previous = this.offers.get(request.deliveryId);
if (previous !== undefined) this.saveStatus(previous, 'Failed');
await this.publishStatus(
deliveryStatusChanged(request.deliveryId, request.customerId, 'Failed')
);
console.log(
`deliverydispatch-dispatch failed delivery=${request.deliveryId} reason=candidates-exhausted`
);
return;
}
const courierId = courierCandidates[attempt - 1];
const actor = await this.findOrEnsureActor(courierId);
await this.publishStatus(
deliveryStatusChanged(
request.deliveryId,
request.customerId,
attempt === 1 ? 'Assigned' : 'Reassigned',
courierId
)
);
const offer: DeliveryOffer = {
deliveryId: request.deliveryId,
customerId: request.customerId,
pickupAddress: request.pickupAddress,
dropoffAddress: request.dropoffAddress,
courierId,
attempt,
deadline: Date.now() + SampleTimings.offerDecisionTimeout,
status: 'Offered'
};
this.offers.save(offer);
await this.actors
.sendToActor(
actor.actorId,
offerDelivery(
courierId,
request.deliveryId,
attempt,
request.pickupAddress,
request.dropoffAddress
)
)
.submit();
}
The sweeper is a periodic job of the host, not a framework timer — the deadline is not the state of any Spot. The form to use when periodic execution inside a Spot is needed is covered by Timers and Workers.
8. Status Recording and the Customer Push¶
A status change reaches Tracking as a request on the tracking channel. Tracking records the evidence and forwards it to CustomerGateway with an Actor direct send addressed by the customer id.
Server/Tracking/Handlers/tracking-handlers.ts
this.evidence.append(request);
await this.actors
.sendToActor(
request.customerId,
new DeliveryStatusUpdatedMsg(
request.deliveryId,
request.customerId,
request.status,
request.occurredAtUnixMs,
request.courierId
)
)
.submit();
The customer Actor's Entry Spot handler receives it and pushes a status notify to the bound session. When the customer reconnects, a new session is bound to the same Actor, so the push goes to the new connection.
Server/Session/customer-status-handler.ts
async handle(
_spot: CustomerEntrySpot,
actor: CustomerActor,
_context: ZLinkMessageContext,
message: DeliveryStatusUpdatedMsg
): Promise<void> {
if (!actor.accepts(message.deliveryId)) return;
await actor.context.boundSession
.send(
new DeliveryStatusNotify(
message.deliveryId,
message.status,
message.occurredAtUnixMs,
message.courierId
)
)
.submit();
if (message.status === 'Delivered') {
console.log(
`deliverydispatch-customer pushed status=Delivered delivery=${message.deliveryId}`
);
}
}
The path to a bound session and the refresh after reconnecting are covered by How Session Binding Works.
9. Running and Verifying¶
One runner starts the Redis container, the server processes and the client scenario together.
The client asserts the order of the status notifies and their DeliveryId in both the normal
dispatch flow and the timeout reassignment flow. The checks and the exact log strings are set by the
DeliveryDispatch scenario.
10. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: DeliveryDispatch scenario
- A layout that binds several Actors to one session: Reading Along: SupportChat