Skip to content

Reading Along: DeliveryDispatch

Guide Home | Previous: Reading Along: SupportChat | Next: Reading Along: ShoppingMall

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.

↗ View larger

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/Dispatch/DispatchServerHostFactory.cs

var mesh = options
    .AddRouteMesh(SampleNames.CourierMeshName)
    .Listen(topology.MeshEndpoint)
    .SetRoutingIdPrefix("delivery-dispatch");
mesh.Objects().Client();
var dispatchChannel = options.AddClientServerChannel(SampleNames.DispatchChannel);
dispatchChannel.Client();
dispatchChannel.Server().Listen().AddHandlerGroup(SampleNames.DispatchChannel);
options.AddClientServerChannel(SampleNames.TrackingRouteChannel).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/CourierActorNode/NodeHostFactory.cs

var mesh = options
    .AddRouteMesh(SampleNames.CourierMeshName)
    .Listen(topology.MeshEndpoint)
    .SetRoutingIdPrefix("courier-actor");
mesh.Objects()
    .Server()
    .AddEntrySpot<CourierEntrySpot>()
    .AddActorFactory<CourierActor, CourierActorFactory>(
        SampleNames.CourierActorType,
        factory => factory.DisableRelocation()
    );
// The courier's decision goes back to dispatch as its own one-way message, so this
// node needs a way to speak to the dispatch channel (common sample spec §7.4).
options.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/CourierSessionBinder.cs

public async ValueTask<BindCourierSessionRes> BindAsync(
    string courierId,
    IZLinkSessionContext context,
    CancellationToken cancellationToken
)
{
    var actor = await FindOrEnsureActorAsync(courierId, cancellationToken);
    logger.LogInformation(
        "deliverydispatch-courier bind-relayed courier={CourierId}",
        courierId
    );
    await context.Actors.BindOrGetAsync(actor, cancellationToken);

    logger.LogInformation("deliverydispatch-courier bound courier={CourierId}", courierId);

    return new BindCourierSessionRes(courierId);
}

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.

↗ View larger

Server/Dispatch/DispatchServerHostFactory.cs

app.MapPost(
    "/deliveries",
    async (
        CreateDeliveryReq request,
        Zlink.Framework.Contracts.Channels.IZLinkRouteClient channels,
        ILoggerFactory loggerFactory,
        CancellationToken cancellationToken
    ) =>
    {
        var assign = new AssignDeliveryMsg(
            request.DeliveryId,
            request.CustomerId,
            request.PickupAddress,
            request.DropoffAddress
        );
        await channels.SendToChannel(SampleNames.DispatchChannel, assign).Async();
        loggerFactory
            .CreateLogger("DeliveryDispatch.Server.Dispatch")
            .LogInformation(
                "deliverydispatch api: created delivery={DeliveryId}",
                request.DeliveryId
            );
        return Results.Ok(new CreateDeliveryRes(request.DeliveryId));
    }
);

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/Dispatch/DispatchWorker.cs

/// <summary>The first offer. Records it, sends it, and returns — nobody is left waiting.</summary>
public async ValueTask StartAsync(
    AssignDeliveryMsg request,
    CancellationToken cancellationToken
)
{
    logger.LogInformation(
        "deliverydispatch dispatch: assign delivery={DeliveryId} customer={CustomerId}",
        request.DeliveryId,
        request.CustomerId
    );

    var courierId = couriers.Candidates[0];
    await statusPublisher.PublishAsync(
        request,
        DeliveryStatus.Assigned,
        courierId,
        cancellationToken
    );
    var attempt = offers.Offer(request, 0, SampleTimings.CourierDecisionTimeout);
    await courierOffers.OfferAsync(request, courierId, attempt, cancellationToken);
}

The offer is an Actor direct send addressed by the courier id. It is not a request, so nothing waits for a reply.

Server/Dispatch/DispatchZLinkAdapters.cs

public async ValueTask OfferAsync(
    AssignDeliveryMsg delivery,
    string courierId,
    int attempt,
    CancellationToken cancellationToken
)
{
    await actors
        .SendToActor(
            courierId,
            new OfferDeliveryMsg(
                courierId,
                delivery.DeliveryId,
                attempt,
                delivery.PickupAddress,
                delivery.DropoffAddress
            )
        )
        .Async();
}

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/CourierActorNode/CourierActor.cs

public async ValueTask OfferAsync(OfferDeliveryMsg offer, CancellationToken cancellationToken)
{
    _offeredAttempts[offer.DeliveryId] = offer.Attempt;
    await Context
        .BoundSession.Send(
            new OfferDeliveryNotify(
                offer.CourierId,
                offer.DeliveryId,
                offer.PickupAddress,
                offer.DropoffAddress
            )
        )
        .Async();
}

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/CourierActorNode/Spots/EntrySpot/Handlers/CourierDecisionActorHandler.cs

var attempt = actor.TakeOfferedAttempt(message.DeliveryId);
if (attempt is null)
{
    logger.LogWarning(
        "deliverydispatch courier-actor: decision for an unknown offer delivery={DeliveryId} courier={CourierId}",
        message.DeliveryId,
        actor.ActorId
    );
    return;
}

await channels
    .SendToChannel(
        SampleNames.DispatchChannel,
        new OfferDeliveryResultMsg(
            message.DeliveryId,
            message.CourierId,
            attempt.Value,
            message.Accepted,
            message.Reason
        )
    )
    .Async();

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/Dispatch/OfferDeliveryResultHandler.cs

var offer = offers.Settle(message.DeliveryId, message.Attempt);
if (offer is null)
{
    logger.LogInformation(
        "deliverydispatch-dispatch stale-decision-ignored delivery={DeliveryId} courier={CourierId} attempt={Attempt}",
        message.DeliveryId,
        message.CourierId,
        message.Attempt
    );
    return;
}

logger.LogInformation(
    "deliverydispatch dispatch: decision delivery={DeliveryId} courier={CourierId} attempt={Attempt} accepted={Accepted}",
    message.DeliveryId,
    message.CourierId,
    message.Attempt,
    message.Accepted
);
await worker.SettleAsync(offer, message.Accepted, message.Reason, cancellationToken);

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.

↗ View larger

Server/Dispatch/DispatchWorker.cs

await Task.Delay(SampleTimings.OfferSweepInterval, stoppingToken);

foreach (var offer in offers.TakeExpired())
{
    logger.LogInformation(
        "deliverydispatch dispatch: offer expired delivery={DeliveryId} attempt={Attempt}",
        offer.Request.DeliveryId,
        offer.Attempt
    );
    try
    {
        await worker.ReassignAsync(offer, stoppingToken);
    }
    catch (Exception error) when (!stoppingToken.IsCancellationRequested)
    {
        // The sweeper comes back next tick; one failed reassign must not stop it.
        logger.LogError(
            error,
            "deliverydispatch dispatch: reassign failed delivery={DeliveryId}",
            offer.Request.DeliveryId
        );
    }
}

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/Dispatch/DispatchWorker.cs

public async ValueTask ReassignAsync(DeliveryOffer offer, CancellationToken cancellationToken)
{
    var nextIndex = offer.CandidateIndex + 1;
    if (nextIndex >= couriers.Candidates.Count)
    {
        await statusPublisher.PublishAsync(
            offer.Request,
            DeliveryStatus.Failed,
            couriers.Candidates[^1],
            cancellationToken
        );
        offers.Close(offer.Request.DeliveryId);
        logger.LogWarning(
            "deliverydispatch-dispatch failed delivery={DeliveryId} reason=candidates-exhausted",
            offer.Request.DeliveryId
        );
        return;
    }

    var courierId = couriers.Candidates[nextIndex];
    await statusPublisher.PublishAsync(
        offer.Request,
        DeliveryStatus.Reassigned,
        courierId,
        cancellationToken
    );
    var attempt = offers.Offer(offer.Request, nextIndex, SampleTimings.CourierDecisionTimeout);
    await courierOffers.OfferAsync(offer.Request, courierId, attempt, cancellationToken);
}

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

evidence.Append(request);
var updated = new DeliveryStatusUpdatedMsg(
    request.DeliveryId,
    request.CustomerId,
    request.Status,
    request.CourierId,
    request.OccurredAtUnixMs
);
await actors.SendToActor(request.CustomerId, updated).Async();

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/CustomerGateway/Spots/EntrySpot/Handlers/DeliveryStatusUpdatedHandler.cs

public async ValueTask HandleAsync(
    CustomerEntrySpot spot,
    CustomerActor actor,
    IZLinkMessageContext context,
    DeliveryStatusUpdatedMsg message,
    CancellationToken cancellationToken
)
{
    logger.LogInformation(
        "deliverydispatch customer-entry: status delivery={DeliveryId} customer={CustomerId} actor={ActorId} status={Status}",
        message.DeliveryId,
        message.CustomerId,
        actor.ActorId,
        message.Status
    );
    await actor.PushStatusAsync(message, cancellationToken);
    logger.LogInformation(
        "deliverydispatch-customer pushed status={Status} delivery={DeliveryId}",
        message.Status,
        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.

framework/languages/dotnet/samples/DeliveryDispatch/run_sample.sh

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.