The Bus is Suprnova's synchronous command dispatcher. You define a
typed Command ({ input, Output type }), register a Handler for it at
boot, and then any code in the process can call Bus::dispatch(cmd).await
and get back a Dispatched<T> carrying the handler's typed result.
Bus pairs with Queue - the asynchronous sibling. They are
two intentionally separate facades, not one routing dispatcher:
| If you want… | Use |
|---|---|
| Run the work now, in this task, get the result back | Bus |
| Push the work to a worker, retry on failure, durable | Queue |
The caller picks explicitly. Suprnova does not ship a ShouldQueue
marker - on Tokio both paths are non-blocking, so the explicit selection
is clearer and faster than implicit routing.
Quick Start
Ten lines from command to dispatch:
use ;
use async_trait;
use ;
use Bus;
use FrameworkError;
;
// At boot (once):
;
// In a request handler:
let charge_id = dispatch
.await?
.unwrap_executed;
Defining Commands
A Command is any serializable struct with an associated Output type
and a unique command_name():
The Output is what the handler returns. It only has to be Send + 'static - the real dispatch path keeps values native via
Box<dyn Any>, no serde round-trip. That means non-serde outputs like
Bytes, opaque handles, or an Arc<Mutex<…>> round-trip back to the
caller as live values. The Serialize + DeserializeOwned bound on
Command itself is for the fake-capture path: Bus::fake() records
each dispatched command as a serde_json::Value so predicate-based
assertions (assert_dispatched, assert_dispatched_times) can decode
and inspect them.
command_name() should be a stable string unique per concrete Command
impl. It shows up in assert_dispatched/assert_dispatched_times failure
messages and in error returns when no handler is registered.
Registering Handlers
A Handler<C> is a typed async function that takes the command and
returns Result<C::Output, FrameworkError>:
Call Bus::register::<C, H>(handler) once per command type at boot. The
registry is global; re-registering the same C overwrites the previous
handler (tests rely on this to swap implementations) and emits a
tracing::warn! so a duplicate binding from two boot-time service
registrations is visible in the log.
;
;
Dispatching
Bus::dispatch::<C>(cmd) runs the registered handler in-process and
returns a Dispatched<C::Output> enum:
Dispatched<T> has four helpers:
.unwrap_executed()- return the value, panic onCaptured.executed() -> Option<T>- convert toOption.is_executed()- bool predicate.is_captured()- bool predicate
For real-mode call sites, .unwrap_executed() is the idiomatic form.
Bus::chain - sequential
Bus::chain(Vec<C>) runs commands one at a time, stopping on (and
including) the first error. All commands must be the same type. Returns
Vec<Result<Dispatched<C::Output>, FrameworkError>> - one entry per
command attempted.
let results = chain.await;
// Collect successful charge ids until the first failure:
let charge_ids: = results
.into_iter
.filter_map
.collect;
Bus::chain is homogeneous-only by design - the dispatcher returns
Dispatched<C::Output>, which is only well-typed when every input shares
one Output. For Laravel-style heterogeneous chains (mixed job types,
each step kicking off the next), use Queue::chain - the
queue boxes each job into a typed envelope and so doesn't have the
same constraint.
Bus::batch - concurrent
Bus::batch(Vec<C>) runs commands concurrently via futures::join_all
and collects results in input order. Same homogeneous-type constraint as
chain.
let results = batch.await;
Bus::batch is homogeneous-only for the same reason as chain. For
heterogeneous, persisted batches with progress callbacks, lifecycle
events, and a BatchRepository, use Queue::batch.
Testing
Install the fake at the top of the test. install_fake() acquires a
process-wide FAKE_SERIAL mutex for the guard's lifetime, so two
parallel Bus::fake() tests can't clobber each other's captured-store -
the second blocks until the first guard drops. You still mark the
test #[serial] if a sibling test in the same binary calls real
Bus::dispatch: a real-dispatch caller doesn't acquire FAKE_SERIAL,
so without #[serial] it can race a parallel fake test and observe
is_active() == true. FAKE_SERIAL removes the fake-vs-fake hazard,
#[serial] removes the real-vs-fake one.
use serial;
use Bus;
use ;
async
The fake captures dispatched commands without running their handlers. A
Bus::dispatch call returns Ok(Dispatched::Captured) (no handler
output) instead of Executed. Real errors - encode/decode failures, a
missing registered handler before the fake was installed - still surface
as Err(_).
install_fake() returns a BusFakeGuard. Drop it (it's RAII) and the
fake is cleared and the FAKE_SERIAL mutex is released. The typical
idiom is let _guard = install_fake(); at the top of the test.
Assertion surface
| Assertion | Asserts… |
|---|---|
assert_dispatched::<C>(pred) |
at least one command of type C matching pred |
assert_not_dispatched::<C>(pred) |
zero commands of type C matching pred |
assert_dispatched_times::<C>(pred, count) |
exactly count commands of type C matching pred |
assert_nothing_dispatched() |
zero commands of any type dispatched under the active fake |
All four panic with Bus::fake() must be active if no fake is installed.
The type-scoped ones panic with expected … dispatched <command_name> …
when the count doesn't match. assert_nothing_dispatched panics with
expected no dispatched commands but found <n>.
When to use Queue instead
Reach for Queue when you want any of:
- Durability across restarts. A queued job survives a process crash
if the driver is
databaseorredis. - Retries with backoff. The queue worker applies
Job::max_tries+Job::backoff(exponential / fixed / sequence) on each failure. - Per-job timeout.
Job::timeout+Job::fail_on_timeoutare honored by the worker loop. - Delayed execution.
Queue::later(duration, job)orQueue::push_later(job, at). - Dedupe / idempotency.
Job::unique_id+Queue::push_uniquegates re-submissions for a configurable TTL. - Decoupling the caller from the worker. Run jobs on a separate
fleet of
cargo run --bin app -- queue:workworkers.
Reach for Bus when you want any of:
- In-process, run-now. No serialization across processes.
- Typed result back to the caller.
Dispatched<C::Output>carries the handler's typed return value to the call site. - Synchronous composition. A request handler that decomposes work
into smaller
Commandcalls and reads each result in sequence.
A typical app uses both: synchronous request paths dispatch
result-returning operations through Bus, and "fire and forget" /
durable work pushes through Queue.
Next
- Queues - async sibling, drivers, worker, retry policy, heterogeneous chains and batches
- Events - pub/sub dispatcher (one event → many listeners)
- Workflows - long-running stateful work that survives restarts, when a chain isn't enough
- Testing -
#[suprnova_test], container fakes, and the process-wide serializer pattern used byBus::fake()
