Repository navigation
design(channel): organize channel families and capacity contracts #167
Description
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 mirrors the public families directly under asyncband/src/. Machinery shared by multiple families belongs under the private crate-level internal module, without adding a public path segment or restoring a private channel wrapper.
For 0.7, the implemented families are oneshot, bounded and unbounded mpsc, broadcast::mpmc::unbounded, and watch. The remaining taxonomy nodes are additive directions rather than release commitments.
The taxonomy has three independent axes:
- the delivery family: oneshot, competing queue, broadcast, or watch;
- endpoint topology when it changes endpoint capabilities or enables a static specialization;
- 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)
│ └── unbounded<T>()
└── mpmc # multiple producers, multiple subscribers
├── bounded<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>.
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 currently implemented among the competing queue topologies. 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 remains lower priority than establishing the competing MPMC design.
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 |
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 a bounded sender becomes full at the requested limit rather than the physical ring size.
All bounded constructors require capacity > 0; passing zero panics.
Both current broadcast retention modes are lossless, so their receive errors do not include Lagged.
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.
Lossy retention deferred
A sliding or drop-oldest mode is semantically possible, but it is not part of the initial API. Its contract must decide whether the requested capacity is strict or may be rounded to an effective physical ring size, when and how lag is reported, whether an exact skipped count is guaranteed, where a lagging subscription resumes, and when an evicted payload is released. These choices do not follow from multicast delivery alone.
Lossy retention also has different meanings for broadcast and competing queues. A broadcast has one cursor per subscription, while a queue has one shared unread set; evicting the oldest queue value is therefore a producer-side overflow policy rather than per-subscription lag.
The initial queue and broadcast APIs expose only bounded backpressure and unbounded growth. A future concrete workload may justify a lossy constructor or explicit send operation with nominal endpoint and error types. That API can be added without changing the bounded or unbounded contracts.
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 physical source tree mirrors the public delivery families that are implemented:
asyncband/src/
├── lib.rs
├── internal/ # machinery genuinely shared across root families
├── oneshot/
├── watch/
├── mpsc/
│ ├── mod.rs
│ ├── error.rs
│ ├── bounded.rs
│ └── unbounded.rs
└── broadcast/
├── mod.rs
└── mpmc/
├── mod.rs
└── unbounded/
lib.rs declares each implemented public family directly under its own Cargo feature. There is no private or public channel wrapper and no re-export layer. Future root queue topologies such as spsc, spmc, or mpmc add sibling source modules only when they are implemented.
The 0.7 Cargo features remain oneshot, mpsc, broadcast, and watch. Producer topology and bounded or unbounded policy stay in module paths and constructors rather than becoming separate features. There is no channel umbrella feature for 0.7.
Family-specific storage, cursor, and retention machinery remains private inside that family. Machinery moves to crate::internal only after more than one root family needs the same contract. Public sender and receiver 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.
Bounded broadcast is its most direct consumer: it combines a fixed ring with subscriber gating. 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 crate::internal only when multiple root families actually use them; broadcast-only subscriber gating and retention belong inside a private child of broadcast. Implementations should be split further only when the chosen algorithm requires it, and shared machinery does not justify restoring a channel source wrapper.
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.
For 0.7, broadcast::mpmc::unbounded provides lossless unbounded retention. Bounded MPMC broadcast and an SPMC specialization remain additive follow-ups. Lossy sliding or overflow retention is deferred until a concrete workload establishes its public contract; it can be added later without changing the existing endpoint types.
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 is implemented for 0.7 as a latest-state channel with independently tracked receivers and coalesced intermediate updates.
Activity
orthur2 commentedon Aug 23, 2026
Is "capacity" a strict limit or merely an implementation hint?
For example, should sliding(capacity) retain exactly capacity messages, or may the implementation round it up to the next power of two, as the current
overflow::channeldoes withcapacity.next_power_of_two()?tisonkun commentedon Aug 23, 2026
@orthur2 Thanks for raising this. The capacity question exposed that sliding retention has a subtler contract than we want to commit to in 0.7. Beyond strict versus effective capacity, we would need to define exact lag counting, where a lagging subscription resumes, when an evicted payload is released, and how much of the physical ring shape may affect observable behavior.
We also rechecked the original downstream motivation. Foyer used Tokio/Asyncband broadcast only in
foyer-benchas a one-shot Ctrl-C fan-out signal. It never observesLaggedor relies on overwrite behavior, and that use is now being replaced with a stickyAtomicBoolin foyer-rs/foyer#1318. We therefore do not currently have a concrete downstream workload that requires sliding semantics.I have updated this issue to defer sliding/drop-oldest retention. For now, we will implement only lossless bounded broadcast with backpressure and lossless unbounded broadcast. Bounded
capacityremains a strict logical limit even if a backend rounds its physical ring allocation up. If a real sliding workload appears later, we can revisit the full contract and add a new constructor and nominal endpoint types without changing the bounded or unbounded APIs.tisonkun commentedon Aug 26, 2026
Implementation is now tracked incrementally in #206. The first subtask is #207, a behavior-preserving source-layout refactor for the existing oneshot and MPSC implementations. The bundled implementation in #183 has been closed; this issue remains the design and contract baseline for the individual follow-up tasks.