Skip to content

design(channel): organize channel families and capacity contracts #167

Description

@tisonkun

Decision summary

The public API is organized by delivery semantics and keeps common paths at the crate root. There is no public asyncband::channel or asyncband::queue namespace.

The source tree may still collect every channel implementation under one private channel family module and re-export selected public modules from lib.rs. This keeps implementation visibility local without adding a public path segment.

The taxonomy has three independent axes:

  1. the delivery family: oneshot, competing queue, broadcast, or watch;
  2. endpoint topology when it changes endpoint capabilities or enables a static specialization;
  3. capacity or retention selected by a constructor.

The taxonomy does not require every node to be implemented in 0.7. Only implemented modules are exported. The reserved sibling modules can be added later without changing existing paths or endpoint contracts.

Public taxonomy

asyncband
├── oneshot
│   └── channel<T>() -> (Sender<T>, Receiver<T>)
├── watch
│   └── channel<T>(initial: T) -> (Sender<T>, Receiver<T>)
├── mpsc                         # competing queue; implemented family
│   ├── bounded<T>(capacity: usize)
│   └── unbounded<T>()
├── spsc                         # planned competing-queue family
│   ├── bounded<T>(capacity: usize)
│   └── unbounded<T>()
├── spmc                         # planned competing-queue family
│   ├── bounded<T>(capacity: usize)
│   └── unbounded<T>()
├── mpmc                         # planned competing-queue family
│   ├── bounded<T>(capacity: usize)
│   └── unbounded<T>()
└── broadcast
    ├── spmc                     # single producer, multiple subscribers; planned
    │   ├── bounded<T>(capacity: usize)
    │   ├── sliding<T>(capacity: usize)
    │   └── unbounded<T>()
    └── mpmc                     # multiple producers, multiple subscribers
        ├── bounded<T>(capacity: usize)
        ├── sliding<T>(capacity: usize)
        └── unbounded<T>()

Every bounded constructor returns BoundedSender<T> and BoundedReceiver<T> from its leaf module. Every unbounded constructor returns UnboundedSender<T> and UnboundedReceiver<T>. Broadcast sliding constructors return SlidingSender<T> and SlidingReceiver<T>.

The common queue path remains short:

use asyncband::mpsc;

let (tx, mut rx) = mpsc::bounded(128);
tx.send(event).await?;
let event = rx.recv().await?;

Broadcast makes its producer topology explicit because it determines whether the sender has a static single-writer guarantee:

use asyncband::broadcast::mpmc;

let (tx, mut primary) = mpmc::bounded(128);
let mut replica = tx.subscribe();

tx.send(event).await?;

Delivery families

The distinction between a competing queue and a broadcast is ownership of receive progress, not the number of receiver handles that happen to exist at one moment.

Family Receive progress Delivery contract
oneshot one transfer state transfer at most one value
spsc / mpsc one receiver owns one cursor every accepted value is delivered once
spmc / mpmc receivers compete through one shared cursor every accepted value is delivered to exactly one receiver
broadcast::* every subscription owns an independent cursor every active subscription observes every retained publication
watch every receiver tracks an observed version retain one current value and coalesce intermediate updates

At the crate root, spmc means a competing queue. Single-producer multicast is spelled broadcast::spmc. The shared topology acronym does not erase the delivery distinction.

Broadcast only exposes multiple-consumer topologies. broadcast::spsc and broadcast::mpsc are omitted because, with one receiver and no possibility of another independent subscription, multicast is not observable. SPSC remains a planned root queue family because its non-cloneable endpoints may enable a measured single-writer and single-reader specialization.

Notification primitives carry no value or ordered history and remain outside this channel taxonomy.

Queue endpoint contracts

Path Sender capability Receiver capability Delivery
spsc non-cloneable; sending requires &mut self non-cloneable; receiving requires &mut self one producer to one receiver
mpsc cloneable; sending uses &self non-cloneable; receiving requires &mut self producers share one receiver
spmc non-cloneable; sending requires &mut self cloneable; receiving uses &self receivers compete for each value
mpmc cloneable; sending uses &self cloneable; receiving uses &self producers publish to competing receivers

Only MPSC is an immediate implementation commitment. SPSC, SPMC, and MPMC reserve additive public directions. A future MPMC implementation may use Flume as one reference, while retaining Asyncband's own cancellation, runtime-independence, error, and feature contracts. SPMC is not planned for the initial implementation.

Bounded queue endpoints use capacity-constrained storage. send may wait, and try_send may return Full.

Unbounded queues use distinct endpoint types because send is synchronous and Full is impossible. They remain subject to process memory limits.

Sending fails and returns ownership of the unsent value once no receiver remains. Accepted buffered values drain before receive reports Disconnected.

Common queue errors are re-exported from each implemented leaf module: SendError<T>, TrySendError<T>, RecvError, and TryRecvError.

Broadcast topology contracts

The outer broadcast module fixes multicast delivery. Its child module fixes producer cardinality; subscribers are always multiple and advance independently.

Path Sender capability Subscription capability Publication order
broadcast::spmc one non-cloneable sender; publication requires &mut self subscribe creates independent receivers single-producer order observed by every subscription
broadcast::mpmc cloneable concurrent senders; publication uses &self subscribe creates independent receivers one committed multi-producer order observed by every subscription

Broadcast receivers are non-cloneable. Additional subscriptions are created explicitly so that their start at the committed tail is a visible operation. recv requires &mut self because each receiver exclusively advances its own cursor.

SPMC is a distinct public topology rather than MPMC usage with one live sender. The non-cloneable sender provides the static single-writer guarantee needed by a single-producer sequencer. MPMC remains the general broadcast topology.

Capacity and retention

Queue capacity and broadcast retention use similar constructor names but have different contracts.

Queue capacity

Constructor Sender API Contract
bounded(capacity) async send; try_send may return Full retain at most capacity pending values and apply backpressure when full
unbounded() synchronous send accept while receivers exist and grow subject to process memory limits

Broadcast retention

Constructor Sender API Retention and slow-receiver behavior
bounded(capacity) async send; try_send may return Full lossless bounded log; the slowest active subscription gates producers
sliding(capacity) synchronous send retain the latest capacity publications; a slow subscription gets exact Lagged(count) and resumes at the oldest retained publication
unbounded() synchronous send lossless growth; reclaim a prefix after every active subscription advances or drops

capacity is a strict logical limit, not an implementation hint. The implementation may round physical ring allocation up, but it must still enforce the requested logical capacity. A bounded sender becomes full at the requested limit, and a sliding receiver must not observe publications older than the requested latest-N window merely because more physical slots were allocated.

All bounded and sliding constructors require capacity > 0; passing zero panics.

Only SlidingReceiver has lag in its error space. BoundedReceiver and UnboundedReceiver do not require callers to handle an impossible Lagged variant.

New broadcast subscriptions start at the committed tail and receive future publications. Multi-producer publication exposes only one contiguous committed order; a later reservation must not become visible before an earlier reservation is complete.

Sliding outside broadcast

A sliding competing queue is semantically possible, but it is not the same contract as sliding broadcast retention. A queue has one shared unread set, so evicting its oldest value is a producer-side overflow policy rather than per-subscription lag.

The initial queue API therefore exposes only bounded backpressure and unbounded growth. A future lossy queue API should explicitly decide whether sending returns the displaced value, whether a receiver observes a global drop count, and how the policy composes with multiple competing receivers. It can be added later as a new constructor or explicit send operation without changing the initial queue endpoints.

Rendezvous channels are intentionally not included: independently cancellable async send and receive operations do not provide an unambiguous handoff contract consistent with this queue API.

Source ownership and visibility

The public path and physical source ownership are deliberately different. Every channel may live under one private channel family module, following the pattern of collecting a related implementation family privately and re-exporting selected public modules at the crate root.

asyncband/src/
├── lib.rs
└── channel/                       # private family module
    ├── mod.rs
    ├── error.rs
    ├── oneshot/
    ├── watch/
    ├── mpsc/
    │   ├── bounded.rs
    │   └── unbounded.rs
    ├── spsc/                     # introduced only when implemented
    ├── spmc/                     # introduced only when implemented
    ├── mpmc/                     # introduced only when implemented
    ├── broadcast/
    │   ├── mod.rs
    │   ├── spmc.rs           # introduced only when implemented
    │   ├── mpmc.rs
    │   └── internal/         # subscription and retention machinery
    └── internal/
        ├── mod.rs
        └── ring/                  # shared slots and producer sequencing

At the crate root, implemented public families are flattened by re-export:

mod channel;

pub use self::channel::broadcast;
pub use self::channel::mpsc;
pub use self::channel::oneshot;
pub use self::channel::watch;

Future queue topologies are added to the same re-export list when they are implemented. The exact declarations remain feature-gated. An additive channel umbrella Cargo feature may enable all leaf channel features without creating a public module of that name.

Shared private channel machinery uses pub(super) or narrower visibility. Public endpoint types remain nominal structs with private backend fields; ring modes, sequencer modes, cursor modes, and storage generic parameters do not appear in the public API.

Disruptor and backend direction

There is no public Disruptor channel family. Disruptor-style sequencing is private implementation machinery.

The broadcast implementation is its most direct consumer: bounded broadcast combines a fixed ring with subscriber gating, while sliding broadcast combines slot generations with overwrite and exact lag detection. SPMC may use a single-producer sequencer; MPMC requires multi-producer claim and contiguous publication tracking.

Bounded MPSC may reuse the lower-level ring and multi-producer sequencer with one consumer cursor. An async send must wait for logical capacity before claiming a sequence, then write and publish without another suspension point so cancellation cannot leave a permanent publication hole.

Unbounded MPSC and unbounded broadcast are not fixed-ring Disruptor structures. They may use segmented queues or growable logs while sharing only the relevant waiting, disconnection, and publication helpers.

The source layout does not freeze speculative backend files. Shared ring mechanics belong under private channel::internal; broadcast-only subscriber gating and retention belong under private channel::broadcast::internal. Implementations should be split further only when the chosen algorithm requires it.

Direction

Public delivery, topology, capacity, retention, error, and cancellation contracts are fixed independently of storage and synchronization. Implementations should proceed in reviewable steps with topology-, capacity-, cancellation-, contention-, and fanout-specific benchmarks.

The initial implementation need not complete the topology matrix. Reserving the taxonomy now lets SPSC, competing SPMC or MPMC, and SPMC broadcast arrive as additive modules after 0.7 rather than forcing another public-path migration.

watch remains part of the taxonomy because latest-state coalescing has a clear protocol, but its implementation may be deferred until there is concrete demand.

Supersedes #57 and #95. Related prior work: #146.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions