Background
After M14 (Design 029), Producer::produce is synchronous and infallible:
impl<T> Producer<T> {
pub fn produce(&self, value: T);
}
That's correct for today's buffers (SpmcRing, SingleLatest, Mailbox)
because all three are non-blocking and overwrite on overflow — WriteHandle::push
genuinely cannot fail or block. The M14 design explicitly defers
backpressure-aware variants until a buffer that needs them actually exists.
Problem
Buffer types we'll want next don't fit the "infallible push" model:
- Persistent / disk-backed — push performs an I/O write that can block on
fsync or fail mid-write.
- Network-backed (e.g. a record that publishes directly to a remote AimX
shard or a distributed broker) — push is genuinely async, may need
reconnect, may fail.
- Bounded with await-on-full semantics — block the producer until a slot
frees up rather than overwriting. Maps to tokio::sync::mpsc::Sender::send.
For all of these, the producer needs an async entry point that returns a
Result so it can await space / I/O completion and surface failure.
Proposed API
Add an async variant on Producer<T> alongside today's produce:
impl<T> Producer<T> {
/// Sync, infallible. Today's API. Best for overwriting buffers.
pub fn produce(&self, value: T);
/// Backpressure-aware. Awaits space (bounded buffer) or I/O completion
/// (persistent / network buffers). For overwriting buffers this is
/// equivalent to `produce()` + `Ok(())` and completes immediately.
pub async fn produce_async(&self, value: T) -> DbResult<()>;
}
A symmetric consumer-side subscribe_async is only needed if a buffer
genuinely requires async attachment (e.g. registering with a remote broker
before it can deliver). Defer until that case exists — BufferReader<T>
already exposes recv().await for the actual data path.
WriteHandle trait extension
Extend the crate-private trait in aimdb-core/src/buffer/traits.rs with a
default-implemented async method so existing buffer impls compile
unchanged:
pub(crate) trait WriteHandle<T: Clone + Send + 'static>: Send + Sync {
fn push(&self, value: T);
/// Default: delegate to `push` and return `Ok(())` immediately.
/// Backpressure-aware buffers (persistent, network, bounded-await) override.
fn push_async<'a>(
&'a self,
value: T,
) -> Pin<Box<dyn Future<Output = DbResult<()>> + Send + 'a>> {
self.push(value);
Box::pin(core::future::ready(Ok(())))
}
}
This means SpmcRing / SingleLatest / Mailbox need no changes —
they get produce_async for free with semantically correct
"complete-immediately" behaviour.
Open questions
-
ProducerTrait::produce_any symmetry — should the type-erased trait
(used by inbound connector routing) gain produce_any_async? Connector
routes today bind to a single produce-fn flavour at link-startup; the
simplest approach is to keep produce_any as-is (calls sync push) and
add produce_any_async only when a connector needs it.
-
Naming — produce_async (mirrors how we'd add subscribe_async) vs.
send_async (closer Tokio parity). Settle before merge.
-
aimdb-sync interaction — SyncProducer::send already bridges into a
bounded tokio channel. Does it internally call produce_async instead of
produce, so backpressure surfaces back to the sync caller? Probably yes,
but only matters once a backpressure-aware buffer ships.
-
Producer<T>: Send + 'static for produce_async — the returned future
must be Send so it can cross await points in user services. Verify the
Pin<Box<dyn Future + Send + '_>> shape composes with WriteHandle: Send + Sync
without lifetime gymnastics.
Out of scope
- Implementing an actual backpressure-aware buffer (separate milestone,
driven by a concrete need: persistence / remote AimX / bounded fan-in).
Consumer<T>::subscribe_async — not yet needed.
- AimX remote-access path — has its own backpressure story.
try_produce — covered by the sibling issue (non-blocking variant).
Acceptance criteria
Depends on / blocks
- Depends on: Design 029 — Remove
R from Producer<T> / Consumer<T>
(M14, ✅ Implemented). The WriteHandle<T> indirection is what makes this
cheap to add.
- Blocks: any new buffer impl with persistent / network-backed / bounded-
await semantics.
References
docs/design/029-M14-remove-r-from-typed-handles.md — Decisions item 2
(kept async fn produce -> DbResult<()> "for headroom" — that headroom is
what this issue redeems).
aimdb-core/src/buffer/traits.rs — WriteHandle<T> trait.
aimdb-core/src/typed_api.rs — Producer<T>::produce to extend.
Background
After M14 (Design 029),
Producer::produceis synchronous and infallible:That's correct for today's buffers (
SpmcRing,SingleLatest,Mailbox)because all three are non-blocking and overwrite on overflow —
WriteHandle::pushgenuinely cannot fail or block. The M14 design explicitly defers
backpressure-aware variants until a buffer that needs them actually exists.
Problem
Buffer types we'll want next don't fit the "infallible push" model:
fsync or fail mid-write.
shard or a distributed broker) — push is genuinely async, may need
reconnect, may fail.
frees up rather than overwriting. Maps to
tokio::sync::mpsc::Sender::send.For all of these, the producer needs an
asyncentry point that returns aResultso it can await space / I/O completion and surface failure.Proposed API
Add an
asyncvariant onProducer<T>alongside today'sproduce:A symmetric consumer-side
subscribe_asyncis only needed if a buffergenuinely requires async attachment (e.g. registering with a remote broker
before it can deliver). Defer until that case exists —
BufferReader<T>already exposes
recv().awaitfor the actual data path.WriteHandletrait extensionExtend the crate-private trait in
aimdb-core/src/buffer/traits.rswith adefault-implemented async method so existing buffer impls compile
unchanged:
This means
SpmcRing/SingleLatest/Mailboxneed no changes —they get
produce_asyncfor free with semantically correct"complete-immediately" behaviour.
Open questions
ProducerTrait::produce_anysymmetry — should the type-erased trait(used by inbound connector routing) gain
produce_any_async? Connectorroutes today bind to a single produce-fn flavour at link-startup; the
simplest approach is to keep
produce_anyas-is (calls syncpush) andadd
produce_any_asynconly when a connector needs it.Naming —
produce_async(mirrors how we'd addsubscribe_async) vs.send_async(closer Tokio parity). Settle before merge.aimdb-syncinteraction —SyncProducer::sendalready bridges into abounded tokio channel. Does it internally call
produce_asyncinstead ofproduce, so backpressure surfaces back to the sync caller? Probably yes,but only matters once a backpressure-aware buffer ships.
Producer<T>: Send + 'staticforproduce_async— the returned futuremust be
Sendso it can cross await points in user services. Verify thePin<Box<dyn Future + Send + '_>>shape composes withWriteHandle: Send + Syncwithout lifetime gymnastics.
Out of scope
driven by a concrete need: persistence / remote AimX / bounded fan-in).
Consumer<T>::subscribe_async— not yet needed.try_produce— covered by the sibling issue (non-blocking variant).Acceptance criteria
Producer::produceunchanged (no call-site breakage).Producer::produce_asyncexists, returnsDbResult<()>.WriteHandle::push_asynchas a default impl that delegates topush.SpmcRing,SingleLatest,Mailbox) compile andtest unchanged.
push_asyncto exercise the asyncpath (e.g. a fake "await for slot" buffer) — otherwise we're adding API
surface without validation.
produceandproduce_asyncexplains when to pick which.make checkclean.Depends on / blocks
RfromProducer<T>/Consumer<T>(M14, ✅ Implemented). The
WriteHandle<T>indirection is what makes thischeap to add.
await semantics.
References
docs/design/029-M14-remove-r-from-typed-handles.md— Decisions item 2(kept
async fn produce -> DbResult<()>"for headroom" — that headroom iswhat this issue redeems).
aimdb-core/src/buffer/traits.rs—WriteHandle<T>trait.aimdb-core/src/typed_api.rs—Producer<T>::produceto extend.