Allows streaming blobs without interference from the actor runtime. Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
33 KiB
Swactor Stream Primitive -- Architectural Design
Context
Swactor has a distributed actor runtime with SWIM membership, Kademlia routing, and a content-addressed datastore. The current datastore transfers blobs one chunk at a time via actor message round-trips -- extremely slow for large objects. Beyond the datastore, the system needs a general-purpose bulk data transfer primitive for ML workloads (training data, weight checkpoints, gradient exchange), real-time media (video/voice), and future game state replication.
The stream primitive is a high-performance data channel between nodes that actors negotiate and manage but do not sit on the data path of. It should achieve top-class throughput by leveraging QUIC's multiplexed streams directly, bypassing the actor mailbox system for data transfer.
Decisions made:
- Data path: StreamHandle with try_read/try_write; actors receive lightweight notification messages but data bypasses mailboxes
- Reliability: Reliable-only MVP; abstraction designed so unreliable (QUIC datagrams) can be added later
- Locality: Cross-node only; same-node actors use regular messages
- Crate: New crates/streams/ crate
- Core Concept: Control Plane vs Data Plane
The fundamental architecture separates stream management (control plane) from data transfer (data plane).
Control plane -- actor messages through normal mailboxes:
- Stream negotiation (open, accept, reject)
- Parameter configuration (buffer sizes, chunk sizes, parallelism)
- Lifecycle events (established, closed, error)
- Progress/health notifications
Data plane -- bypasses actors entirely:
- Raw bytes flow through QUIC streams on the iroh transport
- Managed by async tasks on the IrohDriver's tokio runtime
- Actors interact via StreamHandle objects (try_read/try_write), not mailbox messages
- QUIC's built-in flow control handles backpressure
CONTROL PLANE (actor messages, mailboxes, worker ticks) +--------+ StreamOpen +-----------+ StreamAccept +--------+ | Actor | -----------> | Stream | <------------- | Actor | | (nodeA)| | Manager | |(nodeB) | +--------+ +-----------+ +--------+ | | | | StreamReady(handle) | | StreamReady(handle) v v v DATA PLANE (tokio tasks, QUIC streams, pre-allocated buffers) +----------+ bytes +----------+ bytes +----------+ | SendHalf | =========> | QUIC | =========> | RecvHalf | | (writer) | N parallel| streams | N parallel | (reader) | +----------+ stripes +----------+ stripes +----------+
- Stream Identity and Addressing
StreamId: A 16-byte random identifier, generated by the initiator during negotiation. Deliberately not an ActorAddress -- streams are not actors, are not placed on workers, and are not discoverable via Kademlia. Keeping them out of the AddressMap avoids polluting the actor routing hot path.
Full stream address: The tuple (NodeId, StreamId) is globally unique. A node can host many concurrent streams to many peers.
ALPN separation: Streams use a new protocol identifier swactor/stream/1, separate from the existing swactor/swim/1 used for membership. This means:
- The iroh accept loop can distinguish stream connections from protocol messages immediately
- Stream data never blocks or interferes with cluster heartbeats
- Stream connections can have different tuning in the future
- QUIC Stream Utilization
Parallel Stripes for Blob Transfers
For a single large transfer, multiple QUIC streams are opened in parallel on the same QUIC connection. Each stream carries a disjoint range of the data. This is the stripe count, negotiated during handshake (default: 4).
Why multiple streams? A single QUIC stream can be limited by per-stream receive-window backpressure. Multiple streams allow the sender to push data into QUIC's send buffer more aggressively, keeping the congestion window filled. Measurements from quinn/s2n-quic show 2-8 parallel streams can improve throughput 2-4x on high-bandwidth-delay-product links.
Stream layout per transfer:
- Stream 0 (control stream): Bidirectional QUIC stream. Carries the handshake header and out-of-band signals (completion, cancel, errors, health). Stays open for the transfer's lifetime.
- Streams 1..N (data stripes): Unidirectional QUIC streams, each carrying sequential chunks. Stripe assignment is round-robin by chunk index.
Connection Reuse
Multiple concurrent streams between the same two nodes share one QUIC connection (on the stream ALPN). QUIC multiplexing handles this natively. The streams crate maintains a connection cache separate from the SWIM connection cache.
- Wire Format
Two layers of wire format: the stream-level protocol (negotiation + data framing) and the blob transfer application protocol that rides on top of it.
Control Stream Header (stream-level)
[2B magic: 0x53 0x57] -- "SW" [1B version: 0x01] [16B StreamId] [1B mode] -- 0x01=BlobTransfer, 0x02=ContinuousStream (future) [1B stripe_count] -- parallel data stripes (1-255) [4B frame_size (BE u32)] -- maximum frame payload size in bytes [4B metadata_len (BE u32)] [N bytes metadata] -- negotiation payload (e.g., ContentHash for blob transfer)
Data Stripe Frame Format (stream-level)
[4B frame_len (BE u32)] -- 0 = end-of-stripe [N bytes payload] -- raw data bytes
Deliberately minimal. No per-frame type tags (QUIC provides ordered reliable delivery), no per-frame checksums on the wire (QUIC provides TLS integrity for transport), no per-frame metadata. Every byte of overhead on the hot path costs throughput.
BlobTransfer Application Protocol
For blob transfers, the StreamConfig.metadata carries the 32-byte ContentHash of the requested blob (so the serve side knows what to
send). The actual blob data flows over the StreamHandle with this application-level framing:
[4B manifest_json_length (u32 BE)] [N bytes manifest JSON] -- serialized ObjectManifest [chunk_0 raw bytes] -- size from manifest.chunks[0].size [chunk_1 raw bytes] -- size from manifest.chunks[1].size ...
The receiver knows each chunk's expected size and blake3 hash from the manifest. Each chunk is verified individually on arrival:
blake3(chunk_data) == chunk_ref.hash. Corrupted chunks cause immediate transfer failure. This is implemented by the send_blob and
recv_blob async functions in crates/datastore/src/blob_transfer.rs.
Note: the blob transfer protocol sends chunks sequentially through the StreamHandle, which distributes data frames across stripes via the data-plane layer's round-robin. Individual chunks are not split across stripes -- the stripe layer is transparent to the application protocol.
- Buffering Strategy
Pre-allocated Sliding Window (Zero Allocation on Hot Path)
The buffer pool is a sliding window, not a store. It never holds the entire blob in memory -- data flows through it like water through a pipe. A 1TB transfer uses the same ~4MB of buffer memory as a 1MB transfer; only the duration changes.
All buffers are allocated during stream setup, not per-frame.
Sender pipeline (per stripe, double-buffered): Source (disk/memory/computation) → [Buffer A: being filled from source] → [Buffer B: being written to QUIC] → Buffer B recycled → becomes the next Buffer A → repeat until source exhausted One buffer is being filled while the other is being sent. When QUIC accepts Buffer B's bytes, it's recycled and refilled from the source. The source can be disk I/O, a computation producing data, or anything that yields bytes.
Receiver pipeline (per stripe, double-buffered): QUIC recv stream → [Buffer A: being filled from QUIC] → [Buffer B: being written to disk/consumed] → Buffer B recycled → becomes the next Buffer A → repeat until stream ends The receiver reads from QUIC into one buffer while the previous buffer is being written to disk (for blob transfer) or consumed by the application. Buffers are recycled, never allocated mid-transfer.
Backpressure chain (end-to-end): Source read speed → fills sender buffer pool (2 per stripe) → QUIC congestion window → network bandwidth → QUIC receive window → fills receiver buffer pool (2 per stripe) → sink write speed (disk I/O, consumer processing)
If ANY link is slow, pressure propagates backward automatically. No custom flow control needed -- QUIC handles it.
Sizing:
- Pool: stripe_count * 2 buffers per side = 8 buffers (at 4 stripes)
- Frame size: 256KB per frame (separate from the datastore's 1MB storage chunk size)
- Total memory per stream direction: 8 x 256KB = 2MB
- Total for a bidirectional transfer: ~4MB, regardless of blob size
- At ~1200 bytes per QUIC packet, 256KB = ~213 packets. Smaller blast radius on packet loss than 1MB, better interleaving across stripes, aligns with OS page sizes.
TB-Scale Considerations
For very large transfers (100GB+ ML weights, TB-scale training data), additional design considerations apply:
Manifest streaming: At 1MB datastore chunks, a 1TB blob has ~1M chunks. Each ChunkRef is ~40 bytes, so the manifest is ~40MB. This is too large for a single negotiation payload. The current implementation sends the manifest as a JSON preamble on the data stream itself (not in the negotiation metadata). For TB-scale, the manifest could be streamed progressively instead of loaded all at once.
Per-chunk verification on arrival: The receiver verifies each chunk individually as it arrives: blake3(chunk_data) == chunk_ref.hash.
This is implemented in recv_blob. A corrupted chunk causes immediate transfer failure. This catches problems early rather than waiting
for full reassembly.
Progressive resume tokens: Resume tokens are emitted periodically (e.g., every 1000 chunks or every 256MB, whichever comes first), not just on failure. The sender acknowledges receipt of resume tokens. On connection loss, the receiver persists the latest resume token, and a new stream can resume from that point. For a 1TB transfer, a resume token with a 1M-bit BitVec is ~125KB -- cheap to exchange. (Not yet implemented -- the ResumeToken type exists but nothing emits or consumes it.)
Disk I/O as the bottleneck: For TB-scale over fast networks (10Gbps+), disk I/O often becomes the bottleneck rather than the network. The buffering strategy handles this naturally: when disk writes slow down, the receiver's buffer pool fills, QUIC backpressure kicks in, and the sender slows to match. No special handling needed -- the pipeline self-regulates. For maximum disk throughput, the receiver can use O_DIRECT or memory-mapped writes, but this is an implementation optimization, not an architectural decision.
Stripe count scaling: For very high bandwidth links, the default 4 stripes may not be enough to saturate the connection. The stripe count should be configurable up to 16, negotiated during handshake based on the expected transfer size and link characteristics. A heuristic: min(16, max(4, total_chunks / 1000)) -- more stripes for larger transfers.
- StreamHandle -- The Actor-Facing API
The StreamHandle is a lightweight, Send (but not Clone) object that actors store in their state. It communicates with the data-plane tokio tasks via channels internally.
Writer interface:
- try_write(data: &[u8]) -> Result<usize, StreamError> -- Non-blocking. Returns bytes accepted.
- flush() -- Signal that buffered data should be sent.
- close() -- Graceful close.
Reader interface:
- try_read(buf: &mut [u8]) -> Result<usize, StreamError> -- Non-blocking. Returns bytes read, 0 if none available.
- has_data() -> bool -- Check if data is available without consuming it.
BlobTransfer Async Functions
Rather than a wrapper object, blob transfer uses standalone async functions that run inside tokio tasks (spawned after StreamReady). These functions loop over try_write/try_read with tokio::task::yield_now() for cooperative scheduling:
- send_blob(send, manifest, read_chunk) -- Writes the manifest preamble, then calls read_chunk(hash) for each chunk on-demand and writes it. At most one chunk is in memory at a time on the sender side. The read_chunk callback allows any data source (BlobStore via Inbox, in-memory, etc).
- recv_blob(recv) -- Reads the manifest, then reads and blake3-verifies each chunk. Returns ReceivedBlob { manifest, chunks }.
- poll_inbox(inbox, timeout) -- Async version of the bridge.rs poll_response pattern. Yields instead of thread::sleep.
These live in crates/datastore/src/blob_transfer.rs. The key insight: since actors can't await futures, the pattern is for the actor to receive StreamReady, extract the StreamHandle via OneShot::take(), spawn a tokio task for the I/O loop, then stop itself. The tokio task sends results back to other actors via runtime.send_to().
Why non-blocking? Actor handlers are synchronous (fn handle(&mut self, ctx: &Ctx, msg)). They cannot await futures. The try_read/try_write pattern fits naturally. The tokio task bridge is the mechanism for async I/O.
- Actor Integration: Negotiation Protocol
Opening a Stream (Initiator)
- Actor sends a StreamOpen control message (through normal actor mailbox routing) to a StreamManager system actor. Contains: target_node: NodeId, mode, metadata (e.g., ContentHash + manifest for blob transfer), reply_to: ActorAddress.
- StreamManager validates the request, allocates a StreamId, and posts an async task to the tokio runtime that:
- Opens a QUIC connection to the target (stream ALPN)
- Opens the control bidirectional stream
- Sends the stream header
- Waits for accept/reject
- On accept: StreamManager sends StreamReady { stream_id, handle: StreamHandle } back to the requesting actor.
Accepting a Stream (Receiver)
- IrohDriver's accept loop receives connection on stream ALPN.
- Reads control stream header, extracts StreamId + mode + metadata.
- Sends StreamIncoming actor message to local StreamManager.
- StreamManager routes to registered stream acceptors (actors that called StreamListen).
- Matching actor receives StreamOffer { stream_id, mode, metadata } in its mailbox.
- Actor sends StreamAccept or StreamReject back to StreamManager.
- On accept: StreamManager allocates buffers, spawns data-plane tasks, sends StreamReady { handle } to the accepting actor.
Notification Model (Hybrid)
Stream data bypasses mailboxes, but actors need to know when data is available:
- The data-plane tasks inject lightweight StreamEvent sentinel messages into the owning actor's mailbox when state changes: DataReady, WriteReady, Closed, Error.
- Coalescing: An atomic flag prevents duplicate notifications. Set when notification posted, cleared when actor handles it. A high-throughput stream generates at most one DataReady per actor tick, not one per frame.
- The actor's handle_any dispatches StreamEvent via downcast (same mechanism as Down and ActorExited today -- no core trait changes needed).
- Actors can also proactively call handle.try_read() from any handler, not just in response to DataReady.
- The StreamManager Actor
A system actor spawned alongside the IrohDriver, registered under a well-known name. It is the bridge between the actor world and the stream data plane.
Responsibilities:
- Registry of active streams: StreamId -> StreamState
- Handle StreamOpen / StreamAccept / StreamReject / StreamListen / StreamClose messages
- Spawn and supervise data-plane tokio tasks
- Monitor stream-holding actors; clean up streams when actors die
- Expose stream metrics (active streams, throughput, errors) for the dashboard
Communication with tokio runtime: Uses tokio::sync::mpsc and tokio::sync::oneshot channels. Posts commands to async tasks, receives results as actor messages (via the Inbox pattern already used by DatastoreBridge).
- Flow Control and Backpressure
Three layers, all leveraging what QUIC already provides:
- QUIC-level: Per-stream and per-connection flow control (receive window, congestion window). This is the primary mechanism. Not duplicated.
- Buffer pool saturation: When receiver's pre-allocated buffer pool is full, the recv-side tokio task stops reading from QUIC. QUIC's receive window closes, sender stops transmitting. Natural backpressure without custom protocol.
- StreamHandle backpressure: try_write() returns 0 bytes accepted when the send buffer is full. The actor knows to back off or buffer internally.
No custom flow control protocol. QUIC's congestion control (Cubic/BBR) is battle-tested. Adding application-level flow control would add complexity and latency without benefit.
Cancellation
- Cooperative: StreamCancel signal on the control stream
- Abrupt: reset() on the QUIC streams
- Nuclear: close the QUIC connection (node shutdown only)
- Error Handling and Recovery
Failure Modes
┌──────────────────┬──────────────────────┬────────────────────────────────────────────────┐ │ Failure │ Detection │ Behavior │ ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ │ Frame corruption │ QUIC TLS + checksums │ Automatic retransmit │ ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ │ Stream reset │ QUIC RST_STREAM │ StreamEvent::Error to owning actor │ ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ │ Connection loss │ QUIC timeout │ StreamEvent::Error on all streams to that node │ ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ │ Node death │ SWIM declares Dead │ StreamEvent::Error on all streams to that node │ ├──────────────────┼──────────────────────┼────────────────────────────────────────────────┤ │ Owner actor dies │ Worker cleanup phase │ Stream closed, remote side notified │ └──────────────────┴──────────────────────┴────────────────────────────────────────────────┘
Resume Tokens for Blob Transfers (Not Yet Implemented)
For large transfers, the receiver periodically emits a ResumeToken on the control channel:
ResumeToken { stream_id: StreamId, manifest_hash: ContentHash, chunks_received: BitVec, -- which chunks confirmed stored }
On failure, the initiator can open a new stream with the ResumeToken. The sender skips confirmed chunks. This avoids retransmitting terabytes when a checkpoint transfer fails near completion. Leverages the existing ObjectManifest/ChunkRef model.
The ResumeToken type is defined in crates/streams/src/types.rs but emission/consumption logic is deferred to a future stage.
- Integration with Existing Datastore
The stream primitive adds a parallel transfer path to the datastore. The existing chunk-at-a-time TransferActor is preserved for compatibility; the new stream path is used when stream support is configured.
Architecture:
DOWNLOAD SIDE: SERVE SIDE:
DatastoreNode StreamListener │ DownloadViaStream (Incoming = StreamNotification) │ ctx.spawn(StreamDownloader) │ on StreamOffer → HandleStreamOffer ▼ ▼ StreamDownloader DatastoreNode (Incoming = StreamNotification) │ HandleStreamOffer │ on_start: Open → StreamManager │ ctx.spawn(StreamServer) │ StreamReady → tokio task: ▼ │ recv_blob → verify → write chunks StreamServer │ send completion to DatastoreNode (Incoming = StreamNotification) ▼ │ on_start: Accept → StreamManager DatastoreNode │ StreamReady → tokio task: │ StreamDownloadComplete │ read manifest from BlobStore (Inbox) │ persist metadata, reply to caller │ for each chunk: read from BlobStore, │ write to stream (one at a time) │ close stream
Design principles:
- No bridge/shim actors. Stream-facing actors use Incoming = StreamNotification directly.
- No preloading all chunks into memory. Chunks flow on-demand: storage → network.
- DatastoreNode stays simple (fire-and-forget coordination). The stream actors own the full I/O lifecycle.
- After receiving StreamReady, actors spawn tokio tasks for I/O. Tokio tasks communicate results back via runtime.send_to().
- StreamServer reads chunks on-demand — at most one chunk in memory at a time.
Flow:
- DatastoreNode receives DownloadViaStream { content_hash, source_node, reply_to }.
- Spawns a StreamDownloader actor, which sends Open to StreamManager with metadata = content_hash.0 (32 bytes).
- Remote StreamListener receives StreamOffer, extracts ContentHash from metadata, sends HandleStreamOffer to DatastoreNode.
- Remote DatastoreNode spawns a StreamServer actor, which sends Accept to StreamManager.
- StreamServer receives StreamReady, spawns tokio task: reads manifest from BlobStore, then streams each chunk on-demand via send_blob.
- StreamDownloader receives StreamReady, spawns tokio task: calls recv_blob, writes chunks to BlobStore (fire-and-forget), notifies DatastoreNode of completion.
- DatastoreNode creates ObjectEntry and persists via MetadataActor, which sends PutOk to the original caller.
This eliminates the round-trip-per-chunk bottleneck. A 1GB object with 1MB chunks currently requires 1,024 sequential round-trips. With streams and 4 parallel stripes, the entire blob flows in a single burst limited only by network bandwidth.
- Growth Path
Phase 1 (MVP): Reliable Ordered Blob Transfer — IMPLEMENTED
- StreamConfig with BlobTransfer mode only
- New ALPN swactor/stream/1 handler
- StreamOpen/StreamAccept handshake
- Parallel striped data transfer
- StreamHandle with try_read/try_write
- StreamManager actor
- Datastore integration (StreamListener, StreamDownloader, StreamServer actors)
- BlobTransfer application protocol (send_blob/recv_blob with per-chunk blake3 verification)
Remaining MVP work:
- Two-node integration test (real QUIC, full download flow)
- Resume tokens (ResumeToken type exists, emission/consumption not yet wired)
Phase 2: Continuous Streams
- ContinuousStream mode (no total size known)
- Single bidirectional QUIC stream (no striping)
- Variable-sized message frames
- Bounded ring buffer backpressure
- Enables: federated learning gradient streams, data pipelines
Phase 3: Unreliable Datagrams
- UnreliableSequenced reliability mode using QUIC datagrams
- Sequence-based frame dropping (latest-wins)
- Receiver-side jitter buffer
- Advisory StreamThrottle on control channel
- Enables: voice/video, game entity state replication
Phase 4: Priority and QoS
- priority: u8 in StreamConfig
- Priority-aware write scheduler across concurrent streams
- QUIC stream priority hints
- Per-stream health reporting and dashboard integration
- Enables: simultaneous video + checkpoint without starvation
Phase 5: Parallel Unordered Transfer
- ReliableUnordered mode: parallel QUIC streams per chunk, independent delivery
- Configurable parallelism
- Enables: gradient exchange for distributed ML (any chunk consumable independently)
- Key Design Decisions Summary
┌───────────────────┬───────────────────────────────────────────────┬────────────────────────────────────────────────────────────┐ │ Decision │ Choice │ Rationale │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Data path │ StreamHandle bypass, tokio task bridge │ Max throughput; actors manage, don't bottleneck │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Stream identity │ 16-byte StreamId, not ActorAddress │ Streams are not actors; avoid polluting address space │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ ALPN │ Separate swactor/stream/1 │ Isolate from SWIM; no interference with heartbeats │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Parallel stripes │ 4 QUIC streams per blob transfer │ Saturate congestion window on high-BDP links │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Flow control │ QUIC built-in only │ Don't duplicate what the transport does well │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Wire format │ 4-byte length prefix, no type tags │ Minimal per-frame overhead │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Stream chunk size │ 256KB │ Better packet-loss resilience, page-aligned │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Buffering │ Pre-allocated slab per stream │ Zero allocation on hot path │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Blob protocol │ Async functions, not wrapper object │ Simpler; tokio tasks own the I/O loop after StreamReady │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Actor pattern │ Spawn actor → StreamReady → tokio task → stop │ Clean separation; actor negotiates, task does I/O │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Chunk I/O │ On-demand via Inbox polling (poll_inbox) │ At most 1 chunk in memory; no preloading │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Crate │ New crates/streams/ │ Optional, clean dependency graph │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ MVP scope │ Reliable ordered only │ Covers ML + datastore; unreliable added later │ ├───────────────────┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤ │ Locality │ Cross-node only │ Focused scope; same-node uses regular messages │ └───────────────────┴───────────────────────────────────────────────┴────────────────────────────────────────────────────────────┘