Skip to content

Network Carriers

datum-net is the network satellite crate for Datum. It holds network sources, sinks, and connection utilities built on the core Source / Flow / Sink model from datum-core.

The byte carriers (TLS-over-TCP, UDP, and QUIC) are fully Tokio-native: each network connection runs as a single-owner async task with no blocking I/O seam inside the carrier. Remote StreamRefs add TCP and QUIC carriers on top (the TCP carrier follows the same single-owner async model — see Remote StreamRefs transport). OS threads stay flat (~9 instead of ~1 per connection), and CPU scales down materially at concurrency. A physical-core-sharded Tokio carrier engine is available for extreme concurrency and can be tuned with environment variables. On Linux, an optional io-uring-file feature exposes a tokio-uring file source/sink alongside the default TokioFileIO.

TLS Client And Server

rust
use datum::{Keep, Sink, Source};
use datum_net::{
    TokioTls,
    tls::rustls::{
        ClientConfig, ServerConfig,
        pki_types::ServerName,
    },
};
use std::sync::Arc;

fn run_tls_echo(
    server_config: Arc<ServerConfig>,
    client_config: Arc<ClientConfig>,
) -> datum::StreamResult<()> {
    let (binding, incoming) = TokioTls::bind("127.0.0.1:0", server_config, 8192)
        .to_mat(Sink::head(), Keep::both)
        .run()?;
    let binding = binding.wait()?;

    let server_name = ServerName::try_from("localhost")
        .expect("valid DNS name")
        .to_owned();
    let client = Source::single(b"ping".to_vec())
        .via(TokioTls::outgoing_connection(
            binding.local_addr(),
            server_name,
            client_config,
            8192,
        ))
        .run_with(Sink::head())?;

    let incoming = incoming.wait()?;
    let (source, sink) = incoming.into_parts();
    let request = source.run_with(Sink::head())?.wait()?;
    Source::single(request).run_with(sink)?.wait()?;

    assert_eq!(client.wait()?, b"ping".to_vec());
    Ok(())
}

TokioTls::outgoing_connection_default and TokioTls::bind_default use the default 8 KiB chunk size. TlsIncomingConnection::into_flow() converts an accepted TLS connection into a coupled Flow<Vec<u8>, Vec<u8>, NotUsed> when that is more convenient than handling the byte source and sink separately.

Connection Lifecycle

rust
use datum::{Keep, Sink, Source};
use datum_net::{
    ConnectionLifecycleExt, ConnectionSettings, RetryPolicy, TokioTls,
    tls::rustls::{ClientConfig, pki_types::ServerName},
};
use std::{sync::Arc, time::Duration};

fn run_lifecycle_client(
    addr: std::net::SocketAddr,
    client_config: Arc<ClientConfig>,
) -> datum::StreamResult<Vec<u8>> {
    let settings = ConnectionSettings::default()
        .connect_timeout(Duration::from_millis(250))
        .handshake_timeout(Duration::from_secs(2))
        .retry_policy(
            RetryPolicy::default()
                .max_attempts(4)
                .initial_backoff(Duration::from_millis(50))
                .backoff_multiplier(2.0)
                .max_backoff(Duration::from_millis(250)),
        );

    let server_name = ServerName::try_from("localhost")
        .expect("valid DNS name")
        .to_owned();
    let flow = TokioTls::outgoing_connection_with_lifecycle(
        addr,
        server_name,
        client_config,
        settings,
    )
    .half_close_on_upstream_finish();

    let (connection, response) = Source::single(b"request".to_vec())
        .via_mat(flow, Keep::right)
        .to_mat(Sink::collect(), Keep::both)
        .run()?;

    let _ = connection.wait()?;
    Ok(response.wait()?.concat())
}

ConnectionSettings::default() bounds TCP connect and TLS handshake to 30 seconds and performs one attempt. RetryPolicy::max_attempts includes the first attempt. Upstream completion on TCP/TLS connection byte flows shuts down the write direction (FIN / TLS close) while the read direction continues until the peer finishes, matching Akka Tcp half-close behavior where the transport supports it. ConnectionLifecycleExt::half_close_on_upstream_finish() is an explicit marker for that behavior.

UDP Datagrams

rust
use datum::{Keep, Sink, Source};
use datum_net::{Datagram, TokioUdp};

fn run_udp_once() -> datum::StreamResult<()> {
    let (binding, received) = TokioUdp::bind("127.0.0.1:0", 2048, 16)
        .take(1)
        .to_mat(Sink::head(), Keep::both)
        .run()?;
    let binding = binding.wait()?;

    Source::single(Datagram::new(b"ping".to_vec(), binding.local_addr()))
        .run_with(TokioUdp::send_sink("127.0.0.1:0"))?
        .wait()?;

    let datagram = received.wait()?;
    assert_eq!(datagram.payload(), b"ping");
    assert!(datagram.remote().ip().is_loopback());
    Ok(())
}

TokioUdp::bind_default, TokioUdp::bind_flow_default, and TokioUdp::connect_default use a 65,536-byte receive buffer per datagram and a 64-datagram in-process receive buffer. Datagram preserves UDP message boundaries: one socket receive emits one Datagram, and one upstream Datagram is sent with one send_to.

UDP is lossy and has no end-to-end flow control. Datum keeps receive buffering bounded; when the configured in-process buffer is full, additional datagrams are dropped, and the operating system may also drop datagrams before Datum receives them. Use an application-level protocol if ordering, retries, acknowledgements, or reliable delivery are required.

QUIC Bidirectional Streams

rust
use datum::{Keep, Sink, Source};
use datum_net::{TokioQuic, quic::quinn};

fn run_quic_echo(
    server_config: quinn::ServerConfig,
    client_config: quinn::ClientConfig,
) -> datum::StreamResult<()> {
    let (binding, incoming) = TokioQuic::bind("127.0.0.1:0", server_config, 8192)
        .to_mat(Sink::head(), Keep::both)
        .run()?;
    let binding = binding.wait()?;

    let client_connection = TokioQuic::connect(
        binding.local_addr(),
        "localhost",
        client_config,
        8192,
    )
    .run_with(Sink::head())?
    .wait()?;

    let incoming = incoming.wait()?;
    let client_response = Source::single(b"ping".to_vec())
        .via(client_connection.open_bi_default())
        .run_with(Sink::head())?;

    let server_stream = incoming
        .accept_bi_default()
        .run_with(Sink::head())?
        .wait()?;
    let (server_source, server_sink) = server_stream.into_parts();
    let request = server_source.run_with(Sink::head())?.wait()?;
    Source::single(request).run_with(server_sink)?.wait()?;

    assert_eq!(client_response.wait()?, b"ping".to_vec());
    Ok(())
}

Build quinn::ServerConfig / quinn::ClientConfig from rustls configs with quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(server_crypto)?)) and quinn::ClientConfig::new(Arc::new(QuicClientConfig::try_from(client_crypto)?)). The datum_net::quic module re-exports quinn, rustls, and crypto so callers can use the same types without adding duplicate imports.

QUIC streams are reliable, ordered, and flow-controlled. Datum maps a bidirectional QUIC stream to a Flow<Vec<u8>, Vec<u8>, StreamCompletion<QuicStream>>; read-side buffering is bounded and write-side backpressure follows Quinn's stream flow control. Quinn only exposes a newly opened bidirectional stream to the peer after the opener writes data or sends FIN, so call accept_bi_default after the initiating side has started the stream.

Bidirectional streams ship first. Uni-directional QUIC streams and richer connection lifecycle controls for QUIC are deferred to keep this slice small and fully tested.

Plain TCP remains in the core Streaming IO guide.

Async/Tokio-native carriers

As of v0.7.0, every datum-net byte carrier (TLS/UDP/QUIC) is fully Tokio-native. The previous architecture called Tokio blocking_recv on the read side and Handle::block_on on the write side from synchronous Datum stream operators. That seam scaled to ~1 OS thread per active connection — 1,033 threads at 1,024 concurrent streams. The single-owner async-task model eliminates that scaling entirely.

The new architecture, measured for each carrier:

CarrierSingle-stream wall1,024-stream threads1,024-stream CPU
TLS-over-TCP+32% faster9 (was 1,033)−27%
UDP datagramstied (±0.3%)9 (was 1,033)−39%
QUIC bidir+39% faster9 (was 1,033)−30%

The shared async_carrier helper (AsyncCommandSender + DemandBatcher) provides a bounded demand-and-command channel between the synchronous Datum stream core and each single-owner Tokio task. The TLS, UDP, and QUIC byte carriers all use the same helper. The remote-StreamRefs carriers are described separately below — the TCP carrier is a single-owner async task following the same pattern, while the QUIC carrier drives the protocol with a dedicated per-direction loop.

The public TokioTls, TokioUdp, and TokioQuic APIs are unchanged. No test had to be modified. The only user-visible change is that threads no longer scale with connection count.

High-concurrency sharding

A single multi-thread Tokio runtime with one listener hits a wall cliff at extreme concurrency (4,668 ms/op at 1,024 TLS streams versus the bridge's 3,528 ms/op — a +32% regression caused by single-runtime scheduling contention).

The sharded-Tokio carrier (ShardedTokioCarrierExecution) closes that cliff. Each shard is a dedicated OS thread running its own current_thread Tokio runtime owning a disjoint subset of connections. Sharding is internal (public APIs unchanged) and activates automatically when the active connection count crosses the configured minimum.

Physical-core shard-count heuristic

physical_cores = num_cpus::get_physical()
cgroup_cap     = std::thread::available_parallelism()
cores          = clamp(physical_cores, 1, cgroup_cap)
shards         = clamp(cores, 1, min(cores, active_connections))
  • num_cpus::get_physical() returns the physical core count (not SMT threads).
  • The result is capped at available_parallelism() for cgroup/container safety.
  • Shard count is further clamped to min(cores, active_connections) so low-connection workloads don't over-shard.
  • No sharding below ~2 physical cores or below the configurable connection threshold (default 64).

Environment variables

VariableDefaultDescription
DATUM_NET_SHARDED_TOKIO_SHARDSheuristicOverride the shard count.
DATUM_NET_SHARDED_TOKIO_MIN_CONNECTIONS64Minimum active connections before sharding activates.
DATUM_NET_SHARDED_TOKIO_DISABLE(unset)Set to 1 to disable sharding entirely.

Suggested shard counts by physical cores

Physical coresShards (default)Rationale
20 (no sharding)Below the 2-core threshold.
44Worth it at ≥64 connections.
8 (4c/8t SMT)4Physical cores, not SMT siblings.
1616Common server SKU.
3232High-core server.
64 / 96min(cores, active)Active-connection clamp dominates.

TLS sharding results (96-physical-core AMD EPYC 7R13)

Concurrent streamsNon-sharded p50 msSharded p50 msWall speedupCPU improvement
120.422.0 (fallback)
64348.836.49.6×1.32×
2561,163.9117.69.9×1.45×
1,0244,401.3415.410.6×1.49×

At 64+ connections, the sharded path provides ~9–10× wall improvement with no low-concurrency regression. The sharded carrier engine is carrier-agnostic by design, but is currently wired only into the TLS carrier; the UDP and QUIC byte carriers use the shared single-owner Tokio task model without sharding.

Remote StreamRefs transport

datum-core owns the protocol seam (protobuf handshake, cumulative demand, sequenced elements, completion, failure, and terminal ack — mirroring Akka StreamRefs). datum-net carries the frames over two transports:

CarrierTransportEncryptedUse 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.

Both carriers share the same protobuf protocol and StreamRefSettings (32-element buffer, 30s subscription timeout). The QUIC carrier routes StreamRefs frames through a length-delimited frame decoder over a single QUIC bidi stream. The TCP carrier uses the same frame decoder over tokio::net::TcpStream.

The TCP carrier runs as a single-owner async Tokio task (WP-F3), the same pattern as the TLS/UDP/QUIC byte carriers. The QUIC StreamRefs carrier instead drives the protocol with a dedicated per-direction loop. Because remote StreamRefs are long-lived and low-cardinality (one bidi stream per remote), this difference does not affect the connection-count thread scaling that motivated the byte-carrier work.

The same-process direct splice remains the fast local path (~6.5× faster than Akka) and is untouched by the remote transport layer.

For full API details and examples, see the StreamRefs guide.

io-uring-file feature (Linux only)

On Linux kernels ≥5.10, an optional io-uring-file feature enables a UringFileIO source/sink backed by tokio-uring. It sits alongside the default TokioFileIO (unchanged, always available) and is opt-in at compile time:

toml
[dependencies]
datum-net = { version = "0.7", features = ["io-uring-file"] }

The feature is Linux-only; non-Linux targets compile without the feature and fall back to TokioFileIO. All internal unsafe is in the tokio-uring dependency; datum-net itself is forbid(unsafe_code).

Integrated benchmark results (full stream machinery, not raw syscalls):

Operationio-uring-file vs TokioFileIO
256 KiB read1.70× (median)
64 MiB read1.33×
256 KiB writenoisy/slower — not a defaulting signal
64 MiB write1.38×

Performance

Datum-net benchmarks are measured the same way as the core library: 5× warmup, 5× measurement, whole-process CPU via /proc/self/stat, plus peak RSS and thread count. The full benchmark record lives in roadmap/benchmarks/net.md.

Akka comparison (forced-remote, equal honest measurement)

After WP-RT5 corrected the Akka harness to force real Artery-TCP remoting (the prior comparison table measured an in-JVM ActorRef/SourceRef handoff, not a real network boundary), the honest standings are:

AreaDatum wallAkka wallVerdict
TLS echo (64 B roundtrip)553 µs/op1,899 µs/op3.43× faster
UDP send/receive (128 × 64 B)651 µs/op1,069 µs/op1.64× faster
Remote StreamRefs TCP (1,024 elements)1,951 µs/op18,612 µs/op9.54× faster
Remote StreamRefs TCP reuse2,072 µs/op18,895 µs/op9.12× faster
Remote StreamRefs QUIC (1,024 elements)6,611 µs/op17,530 µs/op2.65× faster
Remote StreamRefs QUIC reuse5,169 µs/op18,848 µs/op3.65× faster

On the plaintext TCP StreamRefs path (the fair Artery-TCP-equivalent baseline), Datum is ∼10× faster wall, ∼15× lower CPU, and ∼35× lower allocation than warmed forced-remote Akka. The N-sweep gives a per-element wall slope of 1.27 µs/elem for Datum vs 17.0 µs/elem for Akka.

All performance claims are accompanied by the benchmark record; the comparison rows, methodology, caveats, and per-axis verdicts are in roadmap/benchmarks/net.md.