Skip to content

StreamRefs

StreamRefs are one-shot streaming handles. A local stream can materialize a SourceRef<T> or SinkRef<T>, pass that handle across an actor boundary, and let the other side attach exactly one stream endpoint while preserving completion, failure, cancellation, and backpressure.

SourceRef

StreamRefs::source_ref::<T>() is a Sink<T, SourceRef<T>>: materializing it consumes a local source and returns a SourceRef<T>. The receiving side calls SourceRef::source() to turn that handle back into a Source<T, NotUsed>.

Use StreamRefs::source_ref_with_settings(settings) to override the default settings.

rust
use datum::{Source, StreamRefSettings, StreamRefs};
use std::time::Duration;

let settings = StreamRefSettings::default()
    .with_buffer_capacity(4)
    .with_subscription_timeout(Duration::from_secs(5))
    .with_demand_redelivery_interval(Duration::from_millis(250));

let source_ref = Source::from_iter([1_u64, 2, 3])
    .run_with(StreamRefs::source_ref_with_settings(settings))
    .unwrap();

let values = source_ref.source().run_collect().unwrap();

The ref only feeds elements as the source_ref.source() side demands them. The configured buffer allows a bounded amount of run-ahead.

SinkRef

StreamRefs::sink_ref::<T>() is the dual: it is a Source<T, SinkRef<T>>. Materializing it returns a SinkRef<T>, and the other side uses SinkRef::sink() to feed the local source.

rust
use datum::{Keep, Sink, Source, StreamRefs};

let (sink_ref, completion) = StreamRefs::sink_ref::<u64>()
    .to_mat(Sink::collect(), Keep::both)
    .run()
    .unwrap();

Source::from_iter([4_u64, 5, 6])
    .run_with(sink_ref.sink())
    .unwrap()
    .wait()
    .unwrap();

let received = completion.wait().unwrap();

Use StreamRefs::sink_ref_with_settings(settings) when the local source endpoint needs non-default subscription or buffering behavior.

Settings

StreamRefSettings::default() mirrors Akka StreamRefs defaults:

SettingDefaultMethod
Buffer capacity32 elementswith_buffer_capacity(capacity)
Subscription timeout30 secondswith_subscription_timeout(timeout)
Demand redelivery interval1 secondwith_demand_redelivery_interval(interval)

Demand is cumulative, so redelivery is idempotent. buffer_capacity must be greater than zero.

One-shot pairings

Each SourceRef or SinkRef can be attached once. A second SourceRef::source() materialization, or a second SinkRef::sink() materialization, fails with StreamError::Failed.

Failures propagate across the ref instead of panicking:

rust
use datum::{Source, StreamError, StreamRefs};

let source_ref = Source::<u64>::failed(StreamError::Failed("boom".to_owned()))
    .run_with(StreamRefs::source_ref())
    .unwrap();

assert_eq!(
    source_ref.source().run_collect(),
    Err(StreamError::Failed("boom".to_owned()))
);
assert!(matches!(
    source_ref.source().run_collect(),
    Err(StreamError::Failed(message)) if message.contains("already")
));

Cancellation

Cancellation also crosses the ref. If the local endpoint stops early, the producer attached through the ref observes cancellation.

rust
use datum::{Keep, Sink, Source, StreamError, StreamRefs};

let (sink_ref, completion) = StreamRefs::sink_ref::<u64>()
    .take(1)
    .to_mat(Sink::collect(), Keep::both)
    .run()
    .unwrap();

let producer = Source::repeat(1_u64)
    .run_with(sink_ref.sink())
    .unwrap()
    .wait();

assert_eq!(completion.wait().unwrap(), vec![1]);
assert_eq!(producer, Err(StreamError::Cancelled));

Backpressure across the ref

The receiving side sends cumulative demand. The producer can run ahead only by the configured buffer capacity.

rust
use datum::testkit::TestSink;
use datum::{Source, StreamRefSettings, StreamRefs};
use std::sync::{
    Arc,
    atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant};

fn wait_until(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if condition() {
            return true;
        }
        std::thread::park_timeout(Duration::from_millis(1));
    }
    condition()
}

fn assert_condition_holds(timeout: Duration, mut condition: impl FnMut() -> bool) {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        assert!(condition());
        std::thread::park_timeout(Duration::from_millis(1));
    }
    assert!(condition());
}

let pulled = Arc::new(AtomicUsize::new(0));
let pulled_for_source = Arc::clone(&pulled);
let source = Source::unfold(0_u64, move |next| {
    pulled_for_source.fetch_add(1, Ordering::SeqCst);
    Some((next + 1, next))
});
let settings = StreamRefSettings::default()
    .with_buffer_capacity(1)
    .with_subscription_timeout(Duration::from_secs(5))
    .with_demand_redelivery_interval(Duration::from_millis(20));
let source_ref = source
    .run_with(StreamRefs::source_ref_with_settings(settings))
    .unwrap();
let mut probe = source_ref.source().run_with(TestSink::probe()).unwrap();

probe.request(1);
probe.assert_next(0);
assert!(wait_until(Duration::from_secs(1), || {
    pulled.load(Ordering::SeqCst) >= 2
}));
assert_condition_holds(Duration::from_millis(50), || {
    pulled.load(Ordering::SeqCst) <= 2
});

probe.request(1);
probe.assert_next(1);
assert!(wait_until(Duration::from_secs(1), || {
    pulled.load(Ordering::SeqCst) >= 3
}));
assert_condition_holds(Duration::from_millis(50), || {
    pulled.load(Ordering::SeqCst) <= 3
});

probe.cancel();

Remote transport: TCP & QUIC

datum-core owns a first-class StreamRefs protobuf protocol and a transport-agnostic producer/consumer seam (the StreamRefProtoEndpoint, StreamRefProtoProducer, and StreamRefProtoConsumer in stream_ref_proto.rs). datum-net carries the frames over two transports:

CarrierAPI entry pointsEncryptedUse case
Plaintext TCPserve_source_ref_over_tcp / source_ref_over_tcp / serve_sink_ref_over_tcp / sink_ref_over_tcpNoTrusted/loopback; Akka Artery-TCP-equivalent baseline.
Encrypted QUICserve_source_ref_over_quic / source_ref_over_quic / serve_sink_ref_over_quic / sink_ref_over_quicYes (TLS 1.3)Untrusted/WAN.

The same-process direct splice remains the fast local path (~6.5× faster than Akka) and is untouched by the remote transport layer. When both sides run in the same process, Datum keeps the direct actor-boundary handoff and never serializes.

Protocol

The protocol mirrors Akka StreamRefs: OnSubscribeHandshake, cumulative demand, sequenced OnNext, completion, failure, cancel, and terminal Ack. Sequence numbers start at zero, the default receive window is 32 elements, and demand refills when remaining credit reaches the low-watermark of 16. Akka redelivers cumulative demand for lossy remoting; Datum does not redeliver on TCP or QUIC because both carriers are reliable and ordered.

Steady-state data uses a versioned compact carrier frame (high-bit length prefix, batched SequencedOnNext payloads with an element count and shared validation). Control messages (handshake, completion, ack) stay protobuf. The batch path shares the existing sequence/window/completion validation; there is no second state machine.

Element payloads are encoded through StreamRefPayload. Datum includes built-in payload codecs for primitive numbers, bool, String, and Vec<u8>; custom element types can implement the trait.

SourceRef Examples

Run the two-process SourceRef example over TCP from the workspace root:

sh
cargo run -p datum-net --example streamref_quic_node -- --serve 127.0.0.1:5005 --count 1000
sh
cargo run -p datum-net --example streamref_quic_node -- --connect 127.0.0.1:5005 --count 1000

The client prints deterministic output:

text
RECEIVED 1000 CHECKSUM 499500

Use --slow-ms <d> on the client to throttle downstream pulls and observe backpressure across the remote ref. The TCP variants mirror the same API shape:

rust
use datum_net::stream_ref::serve_source_ref_over_tcp;

Remote SourceRefs are covered by in-process loopback tests for both TCP and QUIC: success/checksum, origin failure propagation, downstream cancellation with Ack, bounded backpressure, and first-demand subscription timeout.

SinkRef over TCP / QUIC

The SinkRef direction is symmetric with the SourceRef carrier, with the producer/consumer roles swapped: the remote/sender side returns a Sink<T, StreamCompletion<NotUsed>> whose incoming elements are framed and sent over the carrier (producer seam, lazy input); the local/receiver side surfaces inbound elements as a Source<T> (consumer seam) which the caller runs into a local Sink.

Because the consumer sends the OnSubscribeHandshake and cumulative demand first, the receiver must open the stream so those frames establish the connection before the sender accepts — the reverse of the SourceRef carrier. The sender accepts the receiver-opened stream and waits for demand before sending elements.

Run the two-process SinkRef example:

sh
cargo run -p datum-net --example streamref_quic_node -- --serve 127.0.0.1:5005 --sink --count 1000
sh
cargo run -p datum-net --example streamref_quic_node -- --connect 127.0.0.1:5005 --sink --count 1000

The server (receiver) collects inbound elements and prints:

text
RECEIVED 1000 CHECKSUM 499500

The client (sender) prints SENT 1000. SinkRef carriers are covered by the same in-process loopback test matrix as the SourceRef carriers: success/checksum, sender-failure propagation, receiver cancel reaching the sender, bounded backpressure, and subscription timeout.

Honest performance (vs forced-remote Akka)

After correcting the Akka comparison harness to force real Artery-TCP remoting (the prior table measured an in-JVM ActorRef/SourceRef handoff — 18.7× faster than actual remote), the honest forced-remote benchmark (1,024 u64/Long elements, fold-to-sum, 5× warmup + 5× measurement, whole-process CPU) yields:

ShapeDatum wallAkka wallDatum CPUAkka CPUDatum allocAkka alloc
TCP plaintext1,951 µs/op18,612 µs/op3,14946,293133 KB/op4.77 MB/op
TCP reuse2,072 µs/op18,895 µs/op3,22047,326135 KB/op4.90 MB/op
QUIC encrypted6,611 µs/op17,530 µs/op10,01344,7883.38 MB/op4.90 MB/op
QUIC reuse5,169 µs/op18,848 µs/op7,54647,634188 KB/op4.91 MB/op

Per-element (N-sweep fit, forced-remote TCP, fold-to-sum):

SideWall per elementCPU per element
Datum TCP1.27 µs/elem1.99 µs/elem
Akka forced-remote17.0 µs/elem36.7 µs/elem

On the fair plaintext TCP baseline, Datum is ~10× faster wall, ~15× lower CPU, and ~35× lower allocation than warmed forced-remote Akka. The QUIC carrier is TLS-encrypted (always; QUIC mandates TLS 1.3) and is not a plaintext-vs-plaintext comparison — the TCP row is the Artery-TCP-equivalent baseline.

The full benchmark record, including the N-sweep, the in-JVM-vs-forced-remote Akka delta, and per-axis verdicts, lives in roadmap/benchmarks/net.md.

Cross-process transport

SourceRef<T> and SinkRef<T> are direct in-memory handles inside one process. For cross-process handoff, use the TCP or QUIC StreamRefs carriers in datum-net; they drive Datum's protobuf StreamRefs protocol with StreamRefPayload codecs and do not depend on Ractor cluster support.

The old datum-core cluster/ractor_cluster feature and its BytesConvertable ref serialization path have been removed. The multi-node membership and placement layer is being built separately as datum-cluster for v0.10; remote StreamRefs over datum-net remain the data-plane path across process boundaries.

Next steps