Skip to content

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

Description

@tisonkun

Proposed public API

The public shape is organized first by delivery semantics. Capacity and retention choices are constructors inside a family, while endpoint cardinality is exposed only when it changes the caller contract.

asyncband::channel
├── oneshot
│   ├── channel<T>() -> (Sender<T>, Receiver<T>)
│   ├── Sender<T>
│   └── Receiver<T>
├── queue
│   ├── mpsc
│   │   ├── bounded<T>(capacity: usize) -> (BoundedSender<T>, BoundedReceiver<T>)
│   │   ├── unbounded<T>() -> (UnboundedSender<T>, UnboundedReceiver<T>)
│   │   ├── BoundedSender<T> / BoundedReceiver<T>
│   │   └── UnboundedSender<T> / UnboundedReceiver<T>
│   └── mpmc
│       ├── bounded<T>(capacity: usize) -> (BoundedSender<T>, BoundedReceiver<T>)
│       ├── unbounded<T>() -> (UnboundedSender<T>, UnboundedReceiver<T>)
│       ├── BoundedSender<T> / BoundedReceiver<T>
│       └── UnboundedSender<T> / UnboundedReceiver<T>
├── broadcast
│   ├── bounded<T>(capacity: usize)
│   │   -> (BoundedSender<T>, BoundedReceiver<T>)
│   ├── sliding<T>(capacity: usize)
│   │   -> (SlidingSender<T>, SlidingReceiver<T>)
│   ├── unbounded<T>()
│   │   -> (UnboundedSender<T>, UnboundedReceiver<T>)
│   ├── BoundedSender<T> / BoundedReceiver<T>
│   ├── SlidingSender<T> / SlidingReceiver<T>
│   └── UnboundedSender<T> / UnboundedReceiver<T>
└── watch
    ├── channel<T>(initial: T) -> (Sender<T>, Receiver<T>)
    ├── Sender<T>
    └── Receiver<T>

SPSC is ordinary MPSC usage with one sender. SPMC is ordinary MPMC usage with one sender. Dedicated SPSC or SPMC types require a measured specialization benefit rather than completing a topology matrix for its own sake.

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

Endpoint contracts

Path Endpoint capability Core operations Delivery contract
channel::oneshot neither endpoint is cloneable synchronous send(self, T); one receive future transfer at most one value
channel::queue::mpsc sender is cloneable; receiver is not waiting send, try_send, recv(&mut self), try_recv each accepted value goes to the single receiver
channel::queue::mpmc sender and receiver are cloneable waiting send, try_send, recv(&self), try_recv cloned receivers compete; each accepted value goes to exactly one
channel::broadcast sender is cloneable; subscriptions are explicit policy-specific send plus subscribe, recv(&mut self), and try_recv every active subscription has an independent cursor
channel::watch sender and receiver are cloneable synchronous state update, subscribe, borrow, and changed retain one current version and coalesce intermediate updates

Bounded queues use capacity-constrained endpoints whose send may wait and whose try_send may report Full. Unbounded queues use distinct endpoint types because send is synchronous and Full is impossible.

Common queue errors are re-exported from the leaf modules: SendError<T>, TrySendError<T>, RecvError, and TryRecvError. Sending fails and returns ownership of the value once no receiver remains. Accepted buffered values drain before receive reports Disconnected.

Example calls

use asyncband::channel::queue::mpsc;

let (tx, mut rx) = mpsc::bounded(128);
tx.send(event).await?;
let event = rx.recv().await?;
use asyncband::channel::queue::mpmc;

let (tx, rx1) = mpmc::bounded(128);
let rx2 = rx1.clone();

// rx1 and rx2 compete; one of them receives each value.
tx.send(job).await?;

Broadcast contracts

Broadcast capacity is retention for an ordered multicast log, not ordinary competing-queue capacity.

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

sliding names the persistent latest-N retention contract; overflow is only the condition that triggers an eviction. backpressure does not need its own public module because it follows from bounded lossless retention.

Only SlidingReceiver has lag in its error space. BoundedReceiver and UnboundedReceiver must not require callers to handle an impossible Lagged variant. New subscriptions start at the committed tail and receive future publications. Multi-producer publication establishes one committed order observed by every subscription.

use asyncband::channel::broadcast;

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

tx.send(event).await?; // waits when either subscription holds the retention window
use asyncband::channel::broadcast;
use asyncband::channel::broadcast::SlidingRecvError;

let (tx, mut rx) = broadcast::sliding(128);
tx.send(event)?;

match rx.recv().await {
    Ok(event) => consume(event),
    Err(SlidingRecvError::Lagged(skipped)) => recover(skipped),
    Err(SlidingRecvError::Disconnected) => return,
}

Capacity contracts

  • bounded backpressure requires capacity > 0; it accepts into available capacity, while a waiting send parks and try_send reports Full; passing zero panics;
  • unbounded accepts while receivers exist and remains subject to process memory limits;
  • sliding retention is explicit multicast loss with exact per-receiver lag reporting;
  • latest-state coalescing belongs to watch rather than queue or broadcast overflow.

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

Source ownership

The source tree mirrors the public families but deliberately does not freeze speculative backend files:

asyncband/src/channel/
├── mod.rs
├── error.rs
├── oneshot.rs
├── queue/
│   ├── mod.rs
│   ├── mpsc.rs
│   └── mpmc.rs
├── broadcast.rs
├── watch.rs
└── internal/
    ├── mod.rs
    └── disruptor/
        └── mod.rs

channel::internal::disruptor is private implementation machinery for channel backends, initially a candidate for bounded multicast sequencing and subscriber gating. It creates no public Disruptor family or claim/publish API. Callers continue to receive the nominal broadcast sender and receiver types above.

Other private files such as queue storage, cursors, waiter registration, or ring slots should be introduced only when the chosen implementation requires them. They are not part of this API design.

Direction

Public contracts are fixed before selecting or specializing storage and synchronization. Implementations should proceed in reviewable steps with topology-, capacity-, cancellation-, and fanout-specific benchmarks.

watch remains part of the design 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