Appearance
Dynamic Streams
Dynamic stream controls let you manage stream lifecycles at runtime and build fan-in / fan-out topologies where producers or consumers attach independently of each other. Datum provides two families of primitives: kill switches for lifecycle control, and hubs for dynamic fan-in and fan-out.
Kill Switches
A kill switch is a handle that lets external code complete or abort a running stream on demand. The signal is observed on the next pull issued by downstream — because Datum's runtime is pull-based, the terminal signal propagates only when downstream requests an element.
UniqueKillSwitch
KillSwitches::single() returns a Flow<T, T, UniqueKillSwitch> whose materialized value is an exclusive handle to that one stream:
rust
use datum::{
Keep, KillSwitches, Materializer,
testkit::{TestSink, TestSource},
};
// KillSwitches::single() injects a UniqueKillSwitch as the materialized value
// of a Flow. The switch is owned by this stream only.
let materializer = Materializer::new();
let ((source, switch), sink) = TestSource::probe::<i32>()
.via_mat(KillSwitches::single(), Keep::both)
.to_mat(TestSink::probe(), Keep::both)
.run_with_materializer(&materializer)
.expect("graph materializes");
// Normal element delivery before the switch fires.
sink.request(1);
assert_eq!(source.expect_request(), 1);
source.send_next(42);
sink.assert_next(42);
// shutdown() completes downstream and cancels upstream on the next pull.
switch.shutdown();
sink.request(1);
sink.expect_complete();
source.expect_cancellation();switch.shutdown()completes the downstream and cancels the upstream on the next pull.switch.abort(error)fails the stream with the givenStreamErroron the next pull.- Both are idempotent: calling
shutdown()twice, orabort()aftershutdown(), is a no-op.
SharedKillSwitch
KillSwitches::shared(name) creates a SharedKillSwitch that can be wired into any number of independent streams. Each call to switch.flow::<T>() returns a Flow<T, T, SharedKillSwitch> that shares the same on/off state:
rust
use datum::{
Keep, KillSwitches, Materializer,
testkit::{TestSink, TestSource},
};
// KillSwitches::shared(name) returns a SharedKillSwitch that can be wired
// into any number of independent streams via switch.flow().
let switch = KillSwitches::shared("my-switch");
let materializer = Materializer::new();
let ((source_a, _), sink_a) = TestSource::probe::<i32>()
.via_mat(switch.flow(), Keep::both)
.to_mat(TestSink::probe(), Keep::both)
.run_with_materializer(&materializer)
.expect("first stream");
let ((source_b, _), sink_b) = TestSource::probe::<i32>()
.via_mat(switch.flow(), Keep::both)
.to_mat(TestSink::probe(), Keep::both)
.run_with_materializer(&materializer)
.expect("second stream");
// A single shutdown() call terminates both streams.
switch.shutdown();
sink_a.request(1);
sink_b.request(1);
sink_a.expect_complete();
sink_b.expect_complete();
source_a.expect_cancellation();
source_b.expect_cancellation();switch.name()returns the name given at construction.switch.shutdown()andswitch.abort(error)propagate to every attached flow.- The
SharedKillSwitchitself isClone— you can share it across threads safely.
Pull-based propagation
The terminal signal from shutdown() or abort() is observed only when downstream issues demand. If no consumer is pulling, the stream stays suspended until the next request. This matches Datum's pull-based execution model and means that tests must call sink.request(1) before sink.expect_complete() or sink.expect_error().
MergeHub — fan-in (N producers → 1 downstream)
MergeHub materializes a reusable Sink<T, NotUsed> that many producers can connect to independently. The downstream consumer runs as a single stream that interleaves elements from all attached producers.
Creating a hub
MergeHub::source(per_producer_buffer_size) materializes the hub source and returns Source<T, Sink<T, NotUsed>>:
rust
use datum::{Keep, MergeHub, Sink};
let (hub_sink, completion) = MergeHub::source::<u64>(4)
.to_mat(Sink::collect(), Keep::both)
.run()
.unwrap();The hub source blocks waiting for items. It completes when the hub enters a draining state and all currently-attached producers finish. Use MergeHub::source_with_draining to obtain a MergeHubDrainingControl:
Attaching producers and draining
rust
use datum::{Keep, MergeHub, Sink, Source};
// MergeHub::source_with_draining materializes a cloneable Sink<T, NotUsed>
// that producers use to attach, plus a MergeHubDrainingControl.
let ((hub_sink, control), completion) = MergeHub::source_with_draining::<u64>(4)
.to_mat(Sink::collect(), Keep::both)
.run()
.unwrap();
// Each run_with call starts the producer in its own background thread.
// Clone the sink for each producer; the original clone is dropped below.
hub_sink
.clone()
.run_with(Source::from_iter(0_u64..3))
.unwrap();
hub_sink
.clone()
.run_with(Source::from_iter(10_u64..13))
.unwrap();
// drain_and_complete() signals that no new producers will attach.
// The hub completes once the currently-running producers finish.
control.drain_and_complete();
let mut collected: Vec<u64> = completion.wait().unwrap();
collected.sort_unstable();Key points:
hub_sinkis aSink<T, NotUsed>that isClone. Each clone is an independent connection that can be attached to a different source.- Calling
hub_sink.clone().run_with(source)starts the producer in a background thread managed by Datum's internalMaterializer. The call returns immediately. control.drain_and_complete()signals that no new producers will attach. Once all running producers finish, the hub source completes and the downstreamSink::collectreturns.- Order of elements across producers is non-deterministic; sort before asserting.
Variant: source (without draining control)
MergeHub::source(buf) is shorthand for source_with_draining(buf) with the control discarded. It is useful when you know all producers will finish naturally and you control termination by some other means (e.g. by dropping all hub_sink clones when using a source that owns the upstream lifecycle).
Performance
MergeHub is competitive at medium and high parallelism after the v0.4.0 optimization passes and the later MergeHub terminal-drain fast path. At p16, Datum is 15.9× faster than warmed Akka; at p4 2.19×. At a single producer (p1), Datum is at 0.46× — below parity. The typed/direct terminal-drain hook — recognized fold/collect/ignore sinks pull producers directly, skipping the boxed downstream source traversal — has shipped and moved p1 from 0.35× to 0.46×. The residual p1 gap is now understood as Datum's core terminal/source materialization cost, not hub coordination: even a plain Source::from_iter → Sink::fold already sits near Akka's p1 number on this host, so closing it requires a core materialization-cost reduction (a larger effort than the hub). See roadmap/benchmarks/dynamic-streams.md for the full same-host table including the diagnosis and remaining levers.
BroadcastHub — fan-out (1 upstream → N consumers)
BroadcastHub materializes a reusable BroadcastHubConsumerSource<T> that lets many consumers attach to a single upstream source. Every consumer receives every element. The upstream adapts to the slowest active consumer.
Creating a hub
BroadcastHub::sink(buffer_size) starts upstream immediately (once the sink is materialized). BroadcastHub::sink_starting_after(n, buffer_size) defers upstream until at least n consumers have attached, which is useful when you need all consumers ready before the first element flows:
rust
use datum::{BroadcastHub, Sink, Source};
// BroadcastHub::sink_starting_after(n, buf) defers upstream until n
// consumers attach. Each consumer.source() call creates an independent
// Source<T, NotUsed> that receives every upstream element.
let consumer_source = Source::from_iter(1_u64..=3)
.run_with(BroadcastHub::sink_starting_after(1, 8))
.unwrap();
// Materialize the consumer source; this attaches the consumer and starts
// upstream (start_after = 1 means one consumer is sufficient).
let items: Vec<u64> = consumer_source
.source()
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Attaching consumers
Each call to consumer_source.source() returns a fresh Source<T, NotUsed> blueprint. When that blueprint is materialized (run), the consumer attaches to the hub and begins receiving elements. Consumers that attach after upstream has started miss earlier elements — they see only elements produced after their attachment.
BroadcastHubConsumerSource<T> is Clone, so you can distribute it to multiple threads that each materialize their own consumer source.
Backpressure behavior
Unlike Akka, Datum's BroadcastHub blocks upstream immediately when there are zero consumers instead of pre-buffering up to buffer_size elements. When consumers are present, upstream waits until every consumer's per-consumer queue has room before emitting the next element, matching Akka's slowest-consumer backpressure contract.
Performance
BroadcastHub is competitive after the v0.4.0 optimization passes. At one consumer, Datum is 2.05× faster than warmed Akka; at c4 1.58×; at c16 1.25×. The previous 0.07× single-consumer number was a pre-optimization baseline. See roadmap/benchmarks/dynamic-streams.md for the full same-host table.
PartitionHub — fan-out (1 upstream → 1 selected consumer per element)
PartitionHub is like BroadcastHub but routes each element to exactly one consumer — the one selected by a partitioner function. Elements can also be dropped by returning -1.
Signature
rust
PartitionHub::sink(
partitioner: impl Fn(&PartitionConsumerInfo, &T) -> isize + Send + Sync + 'static,
start_after_nr_of_consumers: usize,
buffer_size: usize,
) -> Sink<T, PartitionHubConsumerSource<T>>The partitioner receives:
- A
&PartitionConsumerInfodescribing all currently-attached consumers. - A
&Treference to the element being routed.
It must return a consumer_id (an isize cast from the u64 consumer ID) or -1 to drop the element. The partitioner runs while the hub's internal lock is held, so it must be fast and non-blocking.
PartitionConsumerInfo accessors
| Method | Returns |
|---|---|
size() | Number of currently-attached consumers |
consumer_ids() | Slice of active consumer IDs (&[u64]) |
consumer_id_by_idx(idx) | Consumer ID at position idx |
queue_size(consumer_id) | Current queue depth for a consumer |
Example
rust
use datum::{Materializer, PartitionHub, Sink, Source};
// PartitionHub::sink(partitioner, start_after, buffer_size) routes each
// element to one consumer selected by the partitioner function.
// partitioner(&info, &item) -> isize: return a consumer_id or -1 to drop.
let materializer = Materializer::new();
let hub = Source::from_iter(0_u64..6)
.run_with_materializer(
PartitionHub::sink(
|info, item| {
// Distribute elements round-robin across all consumers.
let idx = (*item as usize) % info.size();
info.consumer_id_by_idx(idx) as isize
},
2, // start_after_nr_of_consumers
8, // buffer_size
),
&materializer,
)
.unwrap();
// Attach two consumers sequentially so their IDs are assigned in order.
// Upstream starts once both are registered (start_after_nr_of_consumers = 2).
let completion_a = hub.source().run_with(Sink::collect()).unwrap();
let completion_b = hub.source().run_with(Sink::collect()).unwrap();
let mut items_a: Vec<u64> = completion_a.wait().unwrap();
let mut items_b: Vec<u64> = completion_b.wait().unwrap();
items_a.sort_unstable();
items_b.sort_unstable();Consumers are attached sequentially before any elements flow, ensuring deterministic consumer_id_by_idx ordering. completion_a.wait() and completion_b.wait() block until the hub finishes processing all elements.
The partitioner returns a consumer id, not a positional index, so map any index through info.consumer_id_by_idx(...) as the snippet does: consumer_id_by_idx(item % info.size()) distributes round-robin and stays correct even after consumers detach and new ones attach with higher ids (consumer ids are never reused). Returning a bare item % info.size() only works while ids happen to equal their positions and fails the stream once they diverge. For sticky routing (all elements for a given key to one consumer), hash the key into a stable slot index and resolve it the same way.
Routing to the least-loaded consumer
rust
PartitionHub::sink(
|info, _item| {
// Select the consumer with the smallest pending queue.
info.consumer_ids()
.iter()
.min_by_key(|&&id| info.queue_size(id))
.map(|&id| id as isize)
.unwrap_or(-1)
},
1,
64,
)Dropping elements
Return -1 from the partitioner to discard an element without routing it:
rust
PartitionHub::sink(
|info, item| {
if *item % 2 == 0 {
info.consumer_id_by_idx(0) as isize
} else {
-1 // drop odd elements
}
},
1,
8,
)Performance
PartitionHub is competitive after the v0.4.0 optimization passes. At one consumer, Datum is 2.70× faster than warmed Akka; at c4 2.04×; at c16 1.30×. The previous 0.12× single-consumer number was a pre-optimization baseline. See roadmap/benchmarks/dynamic-streams.md for the full same-host table including remaining levers (zero-allocation PartitionConsumerInfo for c16).
Performance summary
Numbers from roadmap/benchmarks/dynamic-streams.md (same-host, 2026-06-11, after opt-hubs and opt-terminal-consumer passes; the merge_hub_* wall/CPU rows are refreshed to the 2026-06-15 terminal-drain pass — Akka and allocation retained from the 2026-06-11 run, which had java/sbt available):
| Scenario | Datum µs/op | Akka µs/op | Speedup | Datum alloc B/op | Alloc ratio |
|---|---|---|---|---|---|
kill_switch_shared_shutdown_fanout_1 | 17.3 | 38.9 | 2.24× | 1,320 | 6.5× less |
kill_switch_shared_shutdown_fanout_100 | 365 | 371 | 1.02× | 119,328 | 6.1× less |
kill_switch_shared_shutdown_fanout_10000 | 34,766 | 39,970 | 1.15× | 11,920,189 | 6.0× less |
merge_hub_throughput_p1 | 101.3 | 47.0 | 0.46× | 83,921 | 0.33× (Datum ~3× more) |
merge_hub_throughput_p4 | 346.5 | 758 | 2.19× | 325,815 | 2.4× less |
merge_hub_throughput_p16 | 1,025.0 | 16,277 | 15.9× | 1,295,671 | 12.9× less |
broadcast_hub_throughput_c1 | 452 | 927 | 2.05× | 163,670 | 3.1× less |
broadcast_hub_throughput_c4 | 1,235 | 1,952 | 1.58× | 412,197 | 3.3× less |
broadcast_hub_throughput_c16 | 3,255 | 4,070 | 1.25× | 1,393,755 | 3.4× less |
partition_hub_throughput_c1 | 711 | 1,917 | 2.70× | 165,408 | 20.8× less |
partition_hub_throughput_c4 | 821 | 1,675 | 2.04× | 183,790 | 11.4× less |
partition_hub_throughput_c16 | 1,705 | 2,219 | 1.30× | 222,927 | 9.6× less |
SharedKillSwitch is competitive across all fan-out sizes with ~6× lower allocation. The hubs are now competitive-or-faster than warmed Akka at medium and high parallelism/consumer-count after the v0.4.0 optimization passes. MergeHub p1 remains below parity (0.46×) — the typed/direct terminal-drain lever has shipped, and the residual gap is now understood as a core materialization-cost floor rather than hub coordination; see the benchmark file for details.
Next steps
- Buffers & Rate — decouple producer and consumer speed
- Working with Graphs —
GraphDSL, junctions, and fan-in/fan-out topologies