Migrate ZMQ transport → resilient NATS JetStream I/Q streaming (k8s + HPA)¶
Context¶
doppler's live I/Q transport (native/src/stream/stream_core.c, public API
native/inc/stream/stream.h) is a thin layer over vendored static libzmq,
exposing three ZMQ patterns — PUB/SUB (fan-out), PUSH/PULL (pipeline), REQ/REP
(control) — all framed with the 96-byte dp_header_t wire header (magic
0x53494753 "SIGS", per-sender sequence, timestamp_ns, sample_rate,
center_freq, num_samples). It works, but ZMQ gives us no broker-side
durability, no replay, no decentralized resilience, and no clean k8s autoscaling
story. There is currently no NATS, no Kubernetes, and no Helm anywhere in
the repo (only a demo Dockerfile + docker-compose.yml).
Goal: add a NATS / JetStream transport that is fault-tolerant, decentralized
(no single-broker SPOF), and horizontally autoscalable under Kubernetes + HPA,
without breaking the existing ZMQ users and without compromising doppler's
C-first, -lm-only-core invariants.
Decisions locked in (from user)¶
- Tiered transport — Core NATS for the hot live-I/Q fan-out path (at-most-once, in-memory, line-rate); JetStream only for durable/balanced work-queue, control, and record/replay.
- Coexist (pluggable backend) — keep ZMQ; select backend by endpoint
scheme.
nats://…→ NATS;tcp:///ipc://→ ZMQ. Onestream.h, two backends, chosen at create time. Deprecate ZMQ later, not now. - Topology — user is flexible; we adopt the idiomatic streaming-DSP answer:
a nats-server sidecar per pod/node (clients connect to
127.0.0.1:4222), servers clustered into a JetStream RAFT mesh + optional leaf nodes for edge peers. No central broker SPOF; the C library stays a pure client (it cannot embed the Go nats-server — that's a deployment concern, not C code). - Autoscaling — KEDA NATS-JetStream scaler (scales consumer deployments on pending/unacked message lag, scale-to-zero capable) with CPU/memory HPA as a secondary trigger.
Transport tier mapping¶
| Current ZMQ | New NATS tier |
|---|---|
| PUB/SUB (fan-out) | Core NATS publish/subscribe on subjects (lossy-OK) |
| PUSH/PULL (pipeline) | JetStream work-queue stream + durable pull consumers |
| REQ/REP (control) | NATS native request/reply (natsConnection_Request) |
| (none — new) | JetStream file-storage stream for record/replay |
Phase 0 — Backend-abstraction refactor (no behavior change)¶
Make the existing code dispatchable before adding NATS. Pure refactor; existing
C tests + pytest src/doppler/stream/ must stay green.
In native/src/stream/stream_core.c:
- Convert
struct dp_ctxto a tagged union (NOT a vtable — only two backends, thin-glue ethos): - Convert
struct dp_msgto a tagged union too (zmq_msg_tby value vsnatsMsg *owned pointer), because zero-copy lifetime differs. - Lift the current ZMQ bodies into
zmq_*static helpers. Add a one-line backend branch inside the ~6 shared funnels:ctx_create,send_signal,recv_signal,recv_raw,set_recv_timeout, anddp_msg_data/free. - Add scheme parse:
strncmp(ep, "nats://", 7)→ NATS, else ZMQ. Change the six public*_createfns to pass a role enum (decoupled fromZMQ_PUBetc.);ctx_createresolves role→ZMQ-type only on the ZMQ branch.
Every public dp_pub_send_* / dp_*_create signature stays identical — they
still call the shared funnels. stream.h gets no API change in this phase.
Phase 1 — Vendor nats.c (build integration)¶
Mirror the existing libzmq vendoring exactly (CMakeLists.txt:~295-365):
- Add
vendor/nats.c/. Anadd_custom_commandbuilds it static+PIC intobuild/libnats-vendor/withNATS_BUILD_STATIC_LIB=ON,BUILD_SHARED_LIBS=OFF,NATS_BUILD_WITH_TLS=OFFfor v1 (avoids an OpenSSL vendoring project; TLS is a later opt-in), nkeys/libsodium off. - Expose an
IMPORTEDnats_vendor_statictarget; link it into the stream objects and foldlibnats.aintodoppler_stream_staticvia a secondmerge_static_libs.cmakecall (cmake/merge_static_libs.cmake— extend to accept a list, or invoke twice). - Verify at the pinned tag whether protobuf-c (JetStream) is in-tree
(
deps//src/, expected) or external — if in-tree, JetStream adds zero external deps. nats.c is C, not C++, so it adds no new C++ symbols.
Invariant preserved: the pure-C core libdoppler.so/.a never references
stream/nats objects, so the CI nm C++-free gate (ci.yml:~84) and the
-lm-only static-link smoke test stay green untouched. NATS lands entirely
inside the already-quarantined libdoppler_stream tier.
Update Dockerfile: drop libzmq3-dev/libzmq5 reliance toward the
vendored-static model; no NATS system package needed (client is static).
Phase 2 — NATS Core PUB/SUB + REQ/REP (native/src/stream/stream_nats.c)¶
New TU holding all nats.c-specific logic; stream_core.c stays the dispatcher.
- Connect / failover:
natsOptions_SetServers(localhost + optional seed URLs for mesh failover), auto-reconnect (SetMaxReconnect,SetReconnectBufSize), synchronous subscriptions only (natsSubscription_NextMsg) so the existing GIL-release-around-blocking-recv pattern instream_ext.ctransfers unchanged. - Subject scheme: endpoint
nats://host:4222/<base>→ base subject (defaultiq). Publish toiq.<stream_id>.<sample_type>; subscribe toiq.<base>.>(preserves ZMQ "subscribe to all"). Puttingsample_typein the subject enables broker-side type filtering — a free upgrade over ZMQ. - Message format: keep
dp_header_tas a 96-byte binary prefix in the single NATS payload ([header][I/Q]), NOT NATS string headers — preserves the cross-language byte-identical wire invariant and the zero-copy recv contract (dp_msg_datapoints into thenatsMsgbuffer;dp_msg_free→natsMsg_Destroyexactly once). - REQ/REP: requester uses
natsConnection_Request; replier stores the inboundnatsMsg_GetReplysubject (last_reply_subj) sodp_rep_sendanswers it — matching ZMQ's "recv-before-send" REP discipline.
Phase 3 — Chunking for >1 MB I/Q blocks (mandatory)¶
NATS default max_payload is 1 MiB; ZMQ has no such limit. A single 64K-sample
CF64 block (1 MiB) already exceeds it — a naive port silently fails to publish.
- Keep
dp_header_t96 bytes (wire-compatible). Useflags+reserved[]:DP_FLAG_CHUNKED,reserved[0]=chunk_index,reserved[1]=chunk_count,reserved[2]=total_num_samples. Samesequenceacross all chunks of one logical frame;(sequence, chunk_index)orders reassembly. - Auto-size the chunk limit from
natsConnection_GetMaxPayload()after connect (robust if an operator raises the server limit) — don't hardcode 1 MB. - Receiver reassembles into one doppler-owned buffer (a third
dp_msgunion variant:malloc'd, freed indp_msg_free) so the caller API is unchanged. Small blocks (the common live case) stay single-message and zero-copy.
Chunking is PUB/SUB-only. A fan-out subscriber sees the whole in-order chunk sequence and reassembles it. The durable PUSH/PULL work-queue does not chunk: it load-balances frames across workers (KEDA scales them 2→50), so a frame's chunks could land on different pullers that each see only a subset and could never reassemble. A work-queue frame must therefore fit in one message:
96-byte header + payload ≤ max_payload. An oversized PUSH (or REQ/REP) send returnsDP_ERR_TOO_LARGE("Frame exceeds transport max_payload"), surfaced in Python as aValueError— not a generic send failure. To carry larger durable frames, raise the brokermax_payload(config-only and multi-worker-safe — a bigger whole frame is still one atomic work item; NATS allows up to 64 MB). Seedeploy/nats/values.yamlanddeploy/docker-compose.nats.yml. For high-rate large frames prefer PUB/SUB (chunks) or ZMQ (no limit).
Phase 4 — JetStream PUSH/PULL + replay tier¶
- PUSH (
dp_push_createonnats://): getjsCtx, lazily + idempotently create a work-queue streamDP_WORK_<base>(subjects=["work.<base>.>"],retention=WorkQueue). Storage: memory for PUSH/PULL (ZMQ-ephemeral parity); swallowAlreadyInUse; return clearDP_ERR_INITif the server has no-js. - PULL (
dp_pull_create): durable pull consumerDP_PULL_<base>shared across workers (JetStream load-balances → round-robin analog with redelivery),ack_policy=Explicit,max_ack_pending=1000(the ZMQ HWM=1000 backpressure analog).dp_pull_recv=js_Fetch(batch=1, timeout); ack-at-recv for v1 (API-identical, at-most-once on crash — matching ZMQ). A futuredp_msg_ackcould add true at-least-once additively. - Replay tier: same mechanism with
retention=Limits+storage=File; document replay-from-sequence/time in the gallery.
Phase 5 — Python + Rust + tests¶
- Python: no binding-code change expected — sync recv + GIL release transfer,
dp_msg_freebranch is internal. ThePublisher/Subscriber/Push/Pull/...classes (native/src/stream/stream_ext.c) anddoppler.streamwork as-is withnats://endpoints. Add integration tests insrc/doppler/stream/tests/test_stream.pyparametrized overtcp://vsnats://. - C tests:
native/tests/test_stream_nats_core.c— connect, pub/sub round-trip, chunked >1 MB block reassembly, JetStream work-queue load-balance + ack,natsMsglifetime (no leak/double-free). These need a live broker (unlike the in-process ZMQ tests). - Rust ffi has no streaming bindings today — out of scope (note only).
wfm_sink(native/src/wfm/wfm_sink.c): add anats://path soZmqSink(or a renamedStreamSink) can publish over NATS via the same backend — reuses the weak-stub/strong-def split already in place.
Phase 6 — Kubernetes + HPA/KEDA + CI¶
New deploy/ tree (no existing k8s anything):
deploy/helm/nats/— NATS via the official chart values: JetStream enabled, 3-node cluster (RAFT), file storage PVCs, sidecar/DaemonSet model so each node has a local server; clients connect to127.0.0.1:4222.deploy/helm/doppler-pipeline/— Deployments for producers + consumers (consumers as the autoscaled tier),STREAM_ENDPOINT=nats://127.0.0.1:4222/....deploy/keda/scaledobject.yaml— KEDAnats-jetstreamtrigger scaling the consumer Deployment on pending/unacked lag (scale-to-zero), plus a CPU/mem HPA-style fallback trigger. (KEDA generates the HPA under the hood.)deploy/docker/— production multi-stage images (replace the demodocker-compose.ymlservices with NATS-based equivalents; keep compose for local dev with anats -jscontainer).- CI (
.github/workflows/ci.yml): add anats-server -jsservice container to the stream test job (chunking + JetStream tests need a live broker). Re-runjm status --check/nmgates unchanged.
Key risks (designed-for)¶
- >1 MB blocks → Phase 3 chunking is mandatory, not optional.
- nats.c is client-only → "embedded per node" is a sidecar deployment, not C.
- Loss/ordering differ per tier → Core NATS drops on slow consumer (like ZMQ
HWM) but we keep
dp_header_t.sequenceso subscribers detect gaps; JetStream is stronger (acked, redelivered). - Zero-copy send is lost on NATS (header‖data concat); recv stays zero-copy for unchunked. Note in benchmarks.
natsMsgownership (onenatsMsg_Destroyindp_msg_free; don't double free thejs_Fetchlist) → focused C lifetime test.- TLS off in v1 → plaintext localhost-sidecar; add OpenSSL-backed TLS + nkey/creds auth later (additive).
Open decisions (flag, non-blocking)¶
- PUSH/PULL JetStream storage: memory (ZMQ parity, recommended) vs file.
- Ack timing: ack-at-recv (API-identical, recommended) vs add
dp_msg_ack. - TLS/auth in v1: off (recommended) vs on.
Critical files¶
native/src/stream/stream_core.c— tagged-union refactor + backend dispatchnative/src/stream/stream_nats.c— new, all nats.c logicnative/inc/stream/stream.h— no API change; add chunk-flag macros, documentnats://scheme, keepdp_header_t96 bytesCMakeLists.txt+native/src/stream/CMakeLists.txt— vendor nats.c static, link, fold intodoppler_stream_staticcmake/merge_static_libs.cmake— fold a second static archivenative/src/stream/stream_ext.c+src/doppler/stream/tests/test_stream.py— Python integration tests overtcp://vsnats://native/src/wfm/wfm_sink.c—nats://sink pathnative/tests/test_stream_nats_core.c— new C testsdeploy/helm/**,deploy/keda/**,deploy/docker/**— new k8s/autoscale.github/workflows/ci.yml—nats-server -jsservice for stream testsDockerfile/docker-compose.yml— NATS-based images + local dev
Verification¶
- Refactor regression (Phase 0):
ctest --test-dir build -R streamandpytest src/doppler/stream/green with zero behavior change ontcp://. - Build invariants (Phase 1):
nm -CD build/libdoppler.so | grep -E 'GLIBCXX_|CXXABI_|std::'empty; static-consumer smoke linkslibdoppler.a -lmonly. - Round-trip (Phase 2-4): run a local
nats-server -js; C transmitter onnats://127.0.0.1:4222/iq→ PythonSubscriberreceives byte-identical samples + header; verifysequencegap detection; verify a >1 MB CF64 block reassembles correctly (chunking); verify two PULL workers load-balance a JetStream work-queue and ack. - Cross-language: C publisher ↔ Python subscriber and vice versa, proving
the
dp_header_tbinary-prefix wire is byte-identical across backends. - k8s/autoscale: deploy Helm charts to a kind/minikube cluster with KEDA; drive load and confirm the consumer Deployment scales up on JetStream lag and back to zero when idle; kill a NATS pod and confirm clients fail over (no data loss on JetStream tier).