Hedronite · Dev Lesson · Polyglot-Dev / Rust · Thu 2026-10-01

Rust trait objects for queue delivery policies — Box and the dispatcher that swaps backends

Put the receive mode behind the trait, not behind a match you will keep extending.

Lesson Class: Dev (Rust trait objects · DeliveryPolicy)
Topic: T1 Systems core
Lag rule: Duha #21 ch18 trait objects shipped this morning; no Patterns #22
Crate: pkg/queue-delivery · std only · cargo test 5
Paired Ops: Azure Service Bus queue DLQ census
Paired Cert: AZ-900 three messaging shapes
Dyn Pointer
Box is the trait object.
Open Collection
Dispatcher holds many concrete policies.
Policy Swap
Add a backend with one struct, not a new match arm.
Keep the set of receive modes open at the call site.

<!-- hal:authoritative:yaml -->

*A Vec of concrete structs stays closed. A Vec<Box<dyn Trait>> stays open. Put the receive mode behind the trait, not behind a match you will keep extending.*

§I. Frame

This morning's Duha session taught the Object Ledger and the Dyn Pointer from TRPL chapter 18: Rust keeps data-plus-methods and encapsulation, refuses classical inheritance, and uses Box<dyn Trait> when the set of types is open. The book's GUI example stores Box<dyn Draw> in a Screen and calls draw on each.

Today's Ops lesson lists Azure Service Bus queues. The ARM surface reports delivery ceilings and dead-letter depth. The receive mode (peek-lock vs receive-and-delete) and session binding live on the client. That split is a natural trait-object drill: one trait, three concrete policies, one dispatcher that does not know the concrete types.

The crate queue-delivery sits under pkg/. It has no dependencies.

§II. The trait and three backends

pub trait DeliveryPolicy {
    fn name(&self) -> &str;
    fn receive(&self, msg: &Message) -> Outcome;
}

pub struct PeekLock { pub max_delivery: u32 }
pub struct ReceiveAndDelete;
pub struct SessionAware { pub session: String, pub max_delivery: u32 }

Each impl DeliveryPolicy returns a different Outcome:

  1. PeekLock returns Locked while delivery_count < max_delivery, else DeadLettered with reason MaxDeliveryCountExceeded.
  2. ReceiveAndDelete always returns Deleted. There is no settle step and no DLQ path in this offline model.
  3. SessionAware returns SessionHeld { session } under the ceiling, else the same dead-letter outcome as peek-lock.

Object safety holds: no Self returned, no generic methods. The trait is a thin Dyn Pointer target, same rules as Draw in ch18-02.

§III. The Open Collection

TRPL's Screen holds components: Vec<Box<dyn Draw>> and run calls draw on each. The dispatcher here is the same shape with a queue name:

pub struct Dispatcher {
    policies: Vec<Box<dyn DeliveryPolicy>>,
}

impl Dispatcher {
    pub fn run(&self, messages: &[Message]) -> Vec<(String, Outcome)> {
        let mut out = Vec::new();
        for policy in &self.policies {
            for msg in messages {
                out.push((policy.name().to_string(), policy.receive(msg)));
            }
        }
        out
    }
}

Construction stays open at the call site:

let d = Dispatcher::new(vec![
    Box::new(PeekLock { max_delivery: 10 }),
    Box::new(ReceiveAndDelete),
    Box::new(SessionAware { session: "order-7".into(), max_delivery: 10 }),
]);

Generics would force one concrete type per Dispatcher. Trait objects let one Vec hold all three. That is the Open Collection move from Duha, applied to receive policies instead of GUI widgets.

Named technique: Policy Swap. Add a fourth backend later by writing one struct and one impl. Do not open the dispatcher and add another match arm.

§IV. What the tests pin

Five tests in pkg/queue-delivery:

TestPins
peek-lock under ceilingLocked
peek-lock at ceilingDeadLettered / MaxDeliveryCountExceeded
receive-and-deleteDeleted even at high delivery count
sessionSessionHeld carries the session string
dispatcherthree Box<dyn DeliveryPolicy> rows for one poison message

cargo test on the box: 5 passed. cargo run --bin demo prints six lines (three policies times two messages), including a DLQ row for the poison message under peek-lock and session policies.

§V. What not to do

  1. Encoding receive mode as an enum you match forever inside Dispatcher::run. That closes the set the trait object was meant to keep open.
  2. Putting max_delivery only on the dispatcher. Peek-lock and session each need their own ceiling.
  3. Claiming receive-and-delete participates in maxDeliveryCount the same way peek-lock does. Settlement never happens; the broker already deleted the message.
  4. Pre-empting TRPL Patterns (#22). This lesson stops at trait objects.

§VI. Close instruction

Clone pkg/queue-delivery. Add a fourth policy struct that always dead-letters with reason FilterFailure, push Box::new(...) into the dispatcher vec, and extend the dispatcher test. If you had to edit run's match arms, you left the Open Collection path.

Related