Streaming roadmap: resilient transport for Kubernetes¶
Status: draft / for discussion Scope: doppler's network streaming of I/Q (and metadata) — where it goes beyond today's ZeroMQ transport layer.
1. Context¶
doppler already ships three ZMQ messaging patterns via libdoppler_stream
(opt-in, vendored static libzmq — see archive/EXTENSION_WITH_STATIC_ZMQ.md):
| Pattern | Sender | Receiver | Use case |
|---|---|---|---|
| PUB/SUB | dp_pub_* |
dp_sub_* |
Fan-out broadcast |
| PUSH/PULL | dp_push_* |
dp_pull_* |
Pipeline / load-balance |
| REQ/REP | dp_req_* |
dp_rep_* |
Control / metadata |
All three are exposed as Python handles (Publisher/Subscriber,
Push/Pull, Requester/Replier) and as C senders/receivers in
native/inc/stream/stream.h. The existing surface covers six wire types
(CF32, CF64, CF128, CI8, CI16, CI32) with a shared dp_header_t
envelope.
PUB/SUB is the current ZmqSink backing (wfmgen's --output zmq://…). It is
the right tool for a co-located, low-latency I/Q firehose — a live
spectrum display or an in-rack consumer that tolerates loss. It is the wrong
tool for an elastic, resilient Kubernetes deployment.
2. What actually breaks per pattern in k8s¶
| Pattern | k8s gap |
|---|---|
| PUB/SUB | Discovery (hard-coded tcp://host:port); reconnect after pod restart; backpressure (PUB silently drops to slow subscribers); no durability/replay; no HA |
| PUSH/PULL | Discovery only — backpressure and round-robin load-balance already work; a full PUSH queue blocks the sender |
| REQ/REP | Discovery + reconnect; otherwise fine for low-rate control |
PUSH/PULL already closes the "horizontal consumer scaling / work-sharing" gap
that brokerless PUB/SUB can't. The remaining gap for PUSH/PULL in k8s is
purely service discovery — pods are ephemeral, IPs churn, so a hard-coded
bind tcp://host:port breaks when a pod restarts.
3. Proposal: NATS JetStream as the resilient backend¶
NATS JetStream is purpose-built for the k8s axis:
- Subjects decouple producers/consumers; discovery is connect to the NATS
cluster, not to a peer IP. A producer publishes
iq.<stream>.<segment>; consumers subscribe by subject — pods come and go freely. - JetStream streams are append logs → durability + replay: a consumer that restarts resumes from its last ack (or replays from the start).
- Consumers (durable, pull-based, work-queue) give horizontal scaling — N replicas share a subject's load with at-least-once delivery.
- Acks + flow control give real backpressure instead of silent drops.
- RAFT-replicated streams give HA.
- k8s-native: official Helm chart, runs as a
StatefulSetwith PVCs for JetStream storage; clients connect via aService(nats://nats.ns.svc:4222); leaf nodes for edge/ingest.
Architectural bonus — it drops the C++. The NATS C client
(nats.c) is pure C (TLS via OpenSSL,
optional libsodium). A NATS backend therefore avoids the stdc++ that the
vendored libzmq forces. That directly serves the C-first / C++-free-core goal.
NATS equivalents per ZMQ pattern¶
| ZMQ pattern | NATS equivalent |
|---|---|
| PUB/SUB | Core NATS pub/sub (ephemeral) or JetStream push consumer (durable) |
| PUSH/PULL | Core NATS queue group (ephemeral) or JetStream pull consumer (durable) |
| REQ/REP | Core NATS request/reply (built-in, low-rate, no persistence needed) |
4. Honest tradeoffs¶
NATS JetStream is not a drop-in replacement for the ZMQ firehose:
- Latency: a broker hop + (for JetStream) a persistence write per message — higher than raw brokerless ZMQ. Fine for control/metadata and moderate I/Q rates; measure before trusting it for tight closed loops.
- Throughput ceiling: sustained multi-GB/s raw-I/Q firehose is not what a broker is for. High sample rates need chunking + careful subject/stream design, or stay on a raw transport.
- Message size: JetStream messages are bounded (default 1 MB, configurable); continuous high-rate I/Q must be framed into bounded blocks (doppler already blocks the stream — align block size to a sane message limit).
- Persistence cost: disk + RAFT replication isn't free; use ephemeral (core NATS) where replay isn't needed.
Conclusion: add NATS as a backend, don't swap. Route by use case.
5. Recommended shape: a pluggable transport seam¶
doppler already has the seam (stream/) and the opt-in-component pattern
(libdoppler_stream, the weak-symbol stub). Generalize it:
- A transport-backend interface behind
stream/(open / send-block / recv-block / close + an*_available()probe), with each backend a thin C wrapper. Each is an opt-in component so a deployment links only what it uses, and the core stays-lm-only. - Each backend is just another generated
kind="handle"—NatsSink/NatsSourcemirrorZmqSink/ZmqSourceexactly (sameopen/send/closebinding shape). Adding a transport is ~a C wrapper + a manifest block, zero new jm work. (This is the payoff of the handle-generator adoption already underway.) - One wire envelope. Reuse the
dp_header_tframing (magic,sample_type,fs,fc,num_samples, timestamp) as the message body across all transports, so a ZMQ producer and a NATS consumer agree byte-for-byte. JetStream message headers carry the metadata; the subject (iq.<stream>.<segment>) carries the routing.
Backend matrix¶
| ZMQ pattern | ZMQ component (today) | NATS component (new) |
|---|---|---|
| PUB/SUB | libdoppler_stream_zmq |
wfm_nats_pub.c / wfm_nats_sub.c |
| PUSH/PULL | libdoppler_stream_zmq |
wfm_nats_push.c / wfm_nats_pull.c |
| REQ/REP | libdoppler_stream_zmq |
lowest priority — stay ZMQ or migrate last |
Composition with in-flight work¶
- SampleClock + the realtime-paced stream compose with JetStream flow
control: pace to
fs, publish per block, let consumer acks throttle. - Durable capture / replay = a JetStream stream + a SigMF sidecar; replay is a re-read of the stream — no separate capture path.
6. Phased plan¶
| Phase | Deliverable |
|---|---|
| P0 — envelope + benchmark | Pin the wire envelope (dp_header_t framing + subject scheme); build a throughput/latency harness for both PUB/SUB and PUSH/PULL vs ZMQ at representative I/Q rates (CF32 at 1 MS/s, 10 MS/s, ~100 MS/s). Gate the whole effort on these numbers. |
| P1 — NATS core | wfm_nats_pub.c/sub.c + wfm_nats_push.c/pull.c over nats.c (core pub/sub + queue groups, no persistence) → generated NatsSink/NatsSource/NatsPush/NatsPull handles; parity with ZMQ for both patterns; libdoppler_stream_nats opt-in component (pure C). |
| P2 — JetStream + k8s | Durable streams, pull consumers, acks, flow control; Helm deploy (StatefulSet + PVC); document durable-consumer scaling + reconnection patterns. |
| P3 — durability/replay/HA | Durable capture + SigMF; replay; exactly-once (dedupe + double-ack); RAFT-replicated streams. |
| P4 — routing matrix | Decision doc: which transport for which use case (ZMQ firehose vs NATS resilient vs NATS pipeline), backed by the P0/P4 benchmark data. |
7. Open questions to validate¶
- Throughput/latency at target sample rates — the make-or-break for P1; decides the firehose-vs-resilient boundary for both PUB/SUB and PUSH/PULL.
- PUSH/PULL queue depth: does a NATS pull consumer saturate a DSP-rate PUSH sender, or does the ack round-trip introduce unacceptable head-of-line blocking?
- Chunking strategy for continuous high-rate I/Q within JetStream's message bound.
- Persistence (disk/RAFT) vs ephemeral per use case.
- AuthN/Z + TLS — NATS accounts/JWT vs the cluster's mesh; CURVE-equivalent.
- nats.c deps (OpenSSL/libsodium) — vendor (like libzmq) or system?
8. Recommendation¶
- NATS JetStream is the right instinct for the resilient / k8s axis — built for it, and the pure-C client is a genuine architectural win (sheds the libzmq C++ dependency the core has been isolating).
- PUSH/PULL already closes the load-balance / backpressure gap locally; the only real k8s fix it needs is service discovery — which NATS subjects give for free.
- Keep a low-latency brokerless path (ZMQ, or raw UDP) for the co-located firehose. The choice is per-use-case, behind the pluggable seam — which the handle-generator adoption makes cheap.
- Biggest risk is throughput/latency at DSP rates for the PUSH/PULL path; commit only after the P0 benchmark. PUB/SUB resilience (durability, scaling, discovery) NATS clearly wins regardless.
Recommendation: proceed to P0 (envelope + benchmark, both patterns) now; greenlight P1+ on the numbers.
9. P0 benchmark results¶
doppler runs two planes with different figures of merit, and P0 measures each on its own terms:
- the I/Q firehose (PUSH/PULL) — high-rate sample blocks; the metric is throughput (§9.1);
- the status / control / telemetry plane (PUB/SUB + REQ/REP) — small messages at a low rate; the metric is unloaded latency + delivery semantics, not throughput (§9.2).
Harness: native/benchmarks/bench_stream.c — two pthreads (producer +
consumer) with a semaphore-pair start barrier (portable; macOS lacks
pthread_barrier), 50-frame warmup, 32k-bin 1 µs latency histogram. Run on a
single host (dev box, Release build). One-way / RTT latency uses
dp_header_t.timestamp_ns (CLOCK_REALTIME, valid because both threads share
the clock).
Transport under test: ZMQ PUSH/PULL over tcp://127.0.0.1:5679 and ZMQ
REQ/REP over tcp://127.0.0.1:5680; NATS core pub/sub over
nats://127.0.0.1:4222 (nats-server v2.14.2 in-process, no JetStream,
nats bench pub + sub CLI).
Message payload: firehose = CF32 I/Q (8 bytes/sample); status = a 64-byte control message. No framing overhead subtracted.
Loopback overstates the brokerless edge for the distributed case. At ≥ 4 k-sample frames both transports exceed 10 GbE line rate (~1,250 MB/s), so on a real NIC the firehose is link-bound for either transport and the throughput gap below collapses. The ZMQ advantage is a co-located (ipc / loopback) phenomenon — which is exactly the axis it's recommended for.
9.1 Firehose throughput (PUSH/PULL)¶
PUSH/PULL has backpressure: the producer blocks if the consumer falls
behind, so every frame is delivered exactly once and throughput is the true
end-to-end rate. One-way latency uses dp_header_t.timestamp_ns
(CLOCK_REALTIME sender → receiver; valid because both threads share the same
clock).
| block_sz (samples) | frame bytes | tput MS/s | tput MB/s | lat_mean µs | lat_p99 µs |
|---|---|---|---|---|---|
| 256 | 2,048 | 110 | 840 | 964 | 1,069 |
| 1,024 | 8,192 | 199 | 1,520 | 1,072 | 1,362 |
| 4,096 | 32,768 | 461 | 3,516 | 832 | 1,135 |
| 16,384 | 131,072 | 563 | 4,299 | 2,725 | 5,088 |
| 65,536 | 524,288 | 721 | 5,501 | 5,671 | 11,974 |
Caveats:
- TCP loopback bypasses the NIC but still traverses the kernel TCP stack. Real network throughput will be lower; a 10 GbE link caps at ~1,250 MB/s.
- Latency is measured from
dp_push_send_cf32→ first byte pastdp_pull_recvon the same machine. Cross-host latency needs PTP/NTP-synchronised clocks. - No pinning, no
SO_BUSY_POLL, no huge pages. Numbers are a pessimistic baseline; tuned production configs will be better. - The
lat_*columns are saturation latency, not at-rate latency. The producer runs flat-out into the default ZMQ send queue (SNDHWM≈ 1000 frames), so the ~1 ms means are dominated by queue depth, not transport cost — which is why they are non-monotonic in frame size (256 → 964 µs but 4096 → 832 µs). They bound the firehose's worst case under saturation; the unloaded small-message latency that matters for control lives in §9.2.
Firehose: NATS as a candidate (throughput upper bound)¶
This is still the firehose question — can a NATS broker carry the I/Q
stream? — measured against NATS's fastest path (core pub/sub, no persistence),
so it's an upper bound. It is not the status plane (that's §9.2). nats bench pub + nats bench sub, single publisher + subscriber, no JetStream.
Throughput is the subscriber msgs/sec (end-to-end) × actual frame size; the
CLI's MB/s assumes a fixed 128 B default and is ignored. nats bench latency is
publish-to-broker only — not end-to-end — and is not comparable.
| block_sz (samples) | frame bytes | NATS sub msgs/s | NATS MB/s | ZMQ MB/s | ZMQ / NATS |
|---|---|---|---|---|---|
| 256 | 2,048 | 418,666 | 820 | 840 | 1.0× |
| 1,024 | 8,192 | 139,061 | 1,084 | 1,520 | 1.4× |
| 4,096 | 32,768 | 60,921 | 1,901 | 3,516 | 1.85× |
| 16,384 | 131,072 | 16,424 | 2,049 | 4,299 | 2.1× |
| 65,536 | 524,288 | 4,819 | 2,398 | 5,501 | 2.3× |
Caveats:
- Core NATS routes every message through the broker (an extra copy + server CPU); ZMQ PUSH/PULL is socket-to-socket. The gap grows with frame size because the broker copy cost is proportional to bytes.
- At 256-sample frames the two are essentially tied (~820 vs 840 MB/s). The break-even is somewhere in the 256–1024 sample range.
- These are core NATS numbers — not JetStream. JetStream adds a persistence write per message and will be measurably slower; measure P2 before committing to it for high-rate paths.
- The NATS C client (
nats.c) is not yet integrated; these numbers come from the Go-basednats benchCLI which has its own serialisation overhead. A C-native integration (P1) may close some of the gap at small frames.
9.2 Status / control plane (PUB/SUB + REQ/REP)¶
The status plane carries small control / telemetry messages at a low rate (Hz–kHz), so throughput is irrelevant — its figures of merit are unloaded latency and delivery semantics. Latency is a non-issue either way:
| pattern | msg | RTT min | RTT mean | RTT p99 | RTT max | one-way (≈) |
|---|---|---|---|---|---|---|
| ZMQ REQ/REP (idle) | 64 B | 14 µs | 30 µs | 51 µs | 282 µs | ~15 µs |
| ZMQ REQ/REP (loaded) | 64 B | 224 µs | 375 µs | 998 µs | — | ~110–190 µs |
(Both rows are loopback, 20k pings. The idle row is the real figure; the loaded row was taken with concurrent builds running — the firehose sweep was ~4–5× slower than §9.1 at the time — and stands as a worst-case ceiling.) The takeaway is the order of magnitude: even loaded, a sub-millisecond round trip is negligible against status update periods, so latency does not drive the choice. Semantics do — and this is the axis NATS exists for:
| property | ZMQ PUB/SUB | NATS |
|---|---|---|
| late joiner / "current state" | lost — slow-joiner drops msgs sent before the SUB connects; no last value | JetStream KV / last-value — a new subscriber reads current state immediately |
| delivery | fire-and-forget (lossy on a slow consumer) | at-least-once + acks + redelivery (JetStream) |
| fan-out / filtering | N subscribers; topic conventions by hand | subjects + wildcards; broker-side filtering |
| discovery / reconnect | endpoints hard-wired; no topology reconnect | built-in reconnect, service discovery, clustering |
| request / reply | strict lock-step; head-of-line blocking | request-reply with timeouts + queue groups, no HOL |
The late-joiner / last-value gap is usually decisive for a control plane: ZMQ PUB/SUB cannot answer "what is the current state?" for a subscriber that joined after the last update, while NATS does so natively. So the status plane is a NATS win on semantics, with latency a non-factor.
9.3 Interpretation for the phased plan¶
- Firehose → ZMQ, co-located. At DSP block sizes (4k–64k samples) ZMQ delivers 1.85–2.3× a NATS broker's throughput on loopback/ipc — but that edge is a co-located phenomenon (on a real NIC both are link-bound; see the headline caveat). Brokerless socket-to-socket is the right firehose.
- Status/control → NATS, distributed. Throughput is moot; NATS wins on last-value/late-joiner, durable+acked delivery, reconnect, and discovery — exactly the resilient/k8s axis.
- The routing boundary is the plane, not just the frame size. High-rate co-located I/Q → ZMQ; resilient/distributed status + control → NATS. (For a firehose that must cross hosts resiliently, NATS is viable above ~1k-sample frames where its throughput keeps up and the NIC is the real limit anyway.)
- P1 greenlit: the numbers confirm the pluggable-transport seam is the right architecture — ZMQ for the co-located firehose, NATS for the distributed status/control plane, behind one interface.