Build a request metrics aggregator with fibre mpsc
For Rust developers who know the basics of Tokio
You'll build a small Tokio program where several request handlers report every request they serve to one aggregator task, which owns the per-route counters and prints a report at the end. Along the way you'll swap between fibre's unbounded, bounded and rendezvous mpsc channels, see what a bounded send does when the channel is full and feed the same aggregator from a plain OS thread.
Before you start
You need Rust with Cargo. fibre comes from crates.io:
fibre = "0"Set up the project#
Start a new binary crate:
Add fibre and Tokio to Cargo.toml. The program needs Tokio's single-threaded runtime, its macros and its timers.
[]
= "0"
= { = "1", = ["macros", "rt", "time"] }Every step uses the current_thread runtime. All the tasks share one thread, so the output comes out the same way on every run and you can compare it line for line with what's shown here.
Send events to one aggregator#
mpsc is fibre's many-to-one channel. Any number of senders can push into it and exactly one receiver takes everything out. That fits state with a single owner: request counters, an audit log or a connection table. Instead of wrapping the state in a lock that every handler contends on, the handlers send events and one task applies them in order.
Start with one handler and the unbounded async flavour. Replace src/main.rs with this:
use mpsc;
async
mpsc::unbounded_async() returns an UnboundedAsyncSender and an UnboundedAsyncReceiver. Both send and recv take &mut self on the unbounded handles, which is why tx and rx are declared mut. An unbounded send never waits for room. The future completes as soon as it's polled and it fails only if the receiver is gone.
recv waits for the next hit. When every sender has been dropped and nothing is left in the channel, it returns Err(RecvError::Disconnected) instead. Here the only sender lives inside the handler task and is dropped when that task finishes, so the aggregator loop ends on its own after the third hit. Run it:
Clone a sender per handler#
Senders on an mpsc channel are Clone. Each handler gets its own clone and they all feed the same receiver. The receiver is not Clone, so the compiler guarantees there is only ever one aggregator. That's what lets it keep its statistics in a plain BTreeMap with no lock around it.
Replace src/main.rs with a version that spawns three handlers and builds a per-route report:
use mpsc;
use BTreeMap;
async
/health 3 requests, 0 errors, avg 1ms
/login 3 requests, 1 errors, avg 10ms
/search 3 requests, 1 errors, avg 55ms
The
drop(tx)after the loop matters. Each handler's clone is dropped when its task finishes, but the originaltxbelongs tomain. If you leave it alive, the channel always has one sender left andrecvwaits forever for a hit that never comes.
Hits from different handlers can interleave in any order in the channel. Hits from one handler always arrive in the order that handler sent them. The report is sorted by route, so the order hits arrive in doesn't change what gets printed.
Report from a sync thread#
Not every producer lives in the async runtime. A background job running on its own OS thread can report into the same aggregator. Every fibre handle converts between its sync and async forms with to_sync() and to_async(). The two forms work on the same channel at once.
Add a cleanup job on a std::thread right after the handler loop, before drop(tx):
let mut cron_tx = tx.clone.to_sync;
let cron = spawn
;tx.clone().to_sync() turns a fresh clone into an UnboundedSyncSender. Its send is a plain function call that returns a Result straight away. Wait for the thread after the aggregator loop, just before the report:
cron.join.unwrap;/cron 2 requests, 0 errors, avg 280ms
/health 3 requests, 0 errors, avg 1ms
/login 3 requests, 1 errors, avg 10ms
/search 3 requests, 1 errors, avg 55ms
The aggregator loop didn't change. It ends once every async handler and the cleanup thread have dropped their senders. The cleanup hits land at a different point among the others on each run, but the report comes out the same.
Bound the channel#
An unbounded channel accepts every hit immediately, which is fine while the aggregator keeps up. If it falls behind, hits pile up in memory without limit. mpsc::bounded_async(n) caps the channel at n queued items. When it's full, send returns a future that waits until the aggregator takes an item out and frees a slot.
To see that, give the channel room for 2 hits, make the aggregator take 100ms per hit and have a single handler send a burst of five. The handler prints a line each time a hit gets in. The helper ms rounds the elapsed time to the nearest 100ms so the numbers stay stable. Here is the whole file:
use mpsc;
use Duration;
use ;
async
Bounded senders and receivers take &self, so neither tx nor rx needs mut any more. Run it:
0ms handler: sent 200 40ms
0ms handler: sent 200 35ms
0ms handler: sent 500 90ms
100ms aggregator: recorded /search 200 40ms
100ms handler: sent 200 38ms
200ms aggregator: recorded /search 200 35ms
200ms handler: sent 200 41ms
300ms aggregator: recorded /search 500 90ms
400ms aggregator: recorded /search 200 38ms
500ms aggregator: recorded /search 200 41ms
500ms aggregator: every sender is gone
At time zero three hits get in. Two fill the channel and the aggregator has already taken the first one out to work on, which frees a slot for the third. After that the handler's send waits. Each time the aggregator takes the next hit, one slot opens and one more hit gets in, so from then on the handler moves at the aggregator's pace. Nothing is lost and the channel never holds more than 2 hits.
If a handler would rather drop metrics than slow down a request, use try_send. It never waits. When the channel is full it returns TrySendError::Full with the hit inside, so you can count it, log it or throw it away. Add use fibre::error::TrySendError; at the top and swap the body of the handler loop for this:
match tx.try_send
0ms handler: sent 200 40ms
0ms handler: sent 200 35ms
0ms handler: dropped 500 90ms, channel full
0ms handler: dropped 200 38ms, channel full
0ms handler: dropped 200 41ms, channel full
100ms aggregator: recorded /search 200 40ms
200ms aggregator: recorded /search 200 35ms
200ms aggregator: every sender is gone
The handler never yields between its try_send calls, so the aggregator doesn't get a chance to take anything out before the burst is over. The first two hits fill the channel and the other three are dropped. The catch-all arm covers TrySendError::Closed, which means the aggregator is gone.
Hand off audit entries with rendezvous#
Some events shouldn't sit in a buffer at all. An audit entry for a sensitive action is one: the handler should only carry on once the audit writer actually has the entry in hand. The mpsc::rendezvous module builds a channel with no capacity. A send completes only when the receiver takes that exact item, so each send is a direct handoff from one sender to the receiver. Senders are still Clone and there is still one receiver.
This step also shows the other way an mpsc channel ends. The audit writer only has room for three entries. When it's full it drops its receiver while a handler is still sending. Replace src/main.rs with this:
use rendezvous;
use Duration;
use ;
async
0ms ana: login handed to the audit log
100ms audit: wrote ana login
100ms ana: export handed to the audit log
200ms audit: wrote ana export
200ms ben: login handed to the audit log
300ms audit: wrote ben login
300ms audit: log full, closing
300ms ben: audit log closed, delete refused
Ana's first send completes at once, because the audit writer is already waiting in recv. Her second one waits until the writer finishes the first entry and calls recv again at 100ms. Ben's session starts at 150ms and his send waits until the writer is ready at 200ms. Nothing ever sits in the channel between a send and a receive.
After the third entry the writer breaks out of its loop and drops rx. Ben's pending send fails with SendError::Closed and he stops. Closing from the receiving side is immediate: the receiver doesn't drain anything and every later send fails. That's why the handlers in every step here break out of their loops on a send error instead of unwrapping it. For a clean shutdown where nothing is lost, close from the sending side as the earlier steps do and let the aggregator drain what's left.
For sync code, rendezvous::rendezvous() returns the blocking handles. The rendezvous flavour has no batch API.
Where to go next#
The channels guide at https://github.com/excsn/fibre/blob/main/channels/README.GUIDE.md covers the rest of mpsc, including recv_timeout for a sync aggregator that also needs to flush on a timer and the batch API, where recv_batch lets an aggregator take up to max events in one call. The async receivers also implement futures::Stream, so an aggregator can be written with StreamExt combinators. The same guide covers spsc, spmc, mpmc and oneshot for the other shapes of producer and consumer.
The full API, including every error type, is on docs.rs at https://docs.rs/fibre.
