NATS JetStream sink
Status: Available. The nats kind is a batch sink that publishes one dataset’s events to a NATS JetStream stream — one acknowledged message per event, as JSON, deduplicated by the event id. It complements the file-oriented sinks: use it to fan captured events back out to low-latency subscribers (dashboards, alerting, downstream services) directly from the replay edge.
How it works
- The runtime forms batches on WAL frame boundaries and hands each batch to the sink with a stable batch id.
- The sink renders each event’s publish subject from the
subjecttemplate, encodes the event as one JSON document, and publishes it with aNats-Msg-Idheader set to the event id (hex). At mostmax_in_flightpublishes wait for their acknowledgement at any moment;writereturns when every event is acknowledged or individually rejected. - A bounded stream with a positive
duplicate_windowdeduplicatesNats-Msg-Idrepeats server-side, which is exactly what the runtime’s retries produce.openprobes the stream and reportsidempotent_batchonly when the window is positive — the deployed stream, not the configuration, decides what/statusand metrics report. A window of zero reportsat_least_once. - The stream must already exist; a missing stream at
openis a configuration error unlesscreate_stream: true. Thadam deliberately never provisions infrastructure behind your back.
Configuration
sinks:
- id: nats-out
kind: nats
config:
url: nats://nats.internal:4222 # or tls:// for TLS
subject: thadam.{dataset}.{robot} # template; {dataset} + identity level names
stream: THADAM # must exist unless create_stream: true
create_stream: false # default; create the stream at open when missing
duplicate_window: 2m # window of a created stream (an existing stream keeps its own)
token: "${file:/run/secrets/nats_token}" # one credential: token, ...
# user: robot # ... user + password (both required), ...
# creds_file: /etc/thadam/nats.creds # ... or a NATS credentials file
tls: # optional; setting it requires TLS
ca_file: /etc/thadam/ca.pem # replaces the platform trust roots
client_cert: /etc/thadam/cert.pem # client certificate (with client_key)
client_key: /etc/thadam/key.pem
payload: json # the only payload
request_timeout: 10s # connect and per-request timeout (default)
max_in_flight: 256 # publishes awaiting their ack (default)
datasets: [imu] # one dataset per sink entry
Notes:
- Secrets are plain strings already resolved by
${env:}/${file:}interpolation (secrets); the sink wraps them in redacting types and never logs them. subjecttemplates interpolate at whole-token granularity:thadam.{dataset}.{robot}is valid,pre-{robot}is refused at validation time (it could never match a stream’s wildcard subject). Non-datasetplaceholders must be identity levels of the selected dataset — a typo fails atvalidate/open, not per event. Rendered values must be usable subject tokens (no.,*,>, or whitespace); an event that fails rendering is rejected individually to quarantine.- A
kind: natssink publishes exactly one dataset; add one sink entry per dataset. The restriction lifts when the batch contract groups events per dataset (ADR-0002 open work). - TLS uses rustls with the
ringprovider only (ADR-0020):ca_filereplaces the platform roots and, withclient_cert/client_key, enables mutual TLS. Settingtls:(or atls://URL) requires TLS. Configuringtokenoruser/passwordover a non-loopbacknats://URL withouttlslogs a warning because the credential travels in cleartext.
The JSON envelope
Each event publishes as one JSON object merging three regions; later regions never overwrite an earlier key, so a record field can never shadow lineage:
{
"_event_id": "0123456789abcdef0011223344556677",
"_ingest_time": "2026-10-10T06:00:00.000000001Z",
"_event_time": "2026-10-10T05:59:59.500000000Z",
"_quality_status": "good",
"robot": "r7",
"temp": 43.25
}
_event_id— the event id, lowercase hex; also theNats-Msg-Idheader._ingest_time— edge receipt time, RFC 3339 with nanosecond precision, always present. (Integer epoch nanoseconds would silently lose precision through a JSON number beyond 2^53, so times are text.)_event_time— the corrected time if a correction was applied, else the source time when its domain is UTC, elsenull(the same rule the Parquet layout uses)._quality_status—good|uncertain|bad.- Identity levels appear as
{"level": "value"}columns; the dataset’s record fields follow. Field values map naturally: integers stay numbers, floats are numbers (nullwhen NaN or infinite — JSON has neither), strings stay strings,bytesvalues are lowercase hex, timestamps are RFC 3339 text, lists and records nest.
payload: json expects decoded record events; a raw-payload or object-reference event is rejected individually.
Delivery guarantees
- Reported class.
openprobesSTREAM.INFO:duplicate_window > 0reportsidempotent_batch, otherwiseat_least_once. Operators control the class through the stream definition; what you deployed is what you see. - Retries. Publish timeouts, no-responders, and dropped connections are transient: the runtime retries the same batch with the same batch id, and the
Nats-Msg-Idduplicate window makes those resends idempotent server-side. Failed credentials and permission violations are fatal (the component is markedFailed). A message exceeding the server’smax_payloador the stream’smax_msg_sizeis a permanent per-event rejection — the event goes to quarantine, the batch proceeds. - Permission violations. A denied publish is never acknowledged: the server only announces
Permissions Violationon the connection. The sink records that announcement, so the acknowledgement timeout that follows is reported as fatal (auth/auth.forbidden) instead of being retried forever. Credentials the server refuses at connect time are fatal too. - Restarts. The client keeps publishing across a broker restart; an ack that never arrives during an outage counts as unknown-outcome (transient), exactly the case the duplicate window exists for.
- Window limit. Deduplication only covers the stream’s
duplicate_window(2 minutes by default). A retry that arrives after the window can store an event twice; keep the window longer than your longest expected outage.
Integration tests
Docker-gated tests run the sink against a real broker (nats:2.12.2 pinned by tag, run with -js; public Docker Hub only, never a mirror; re-pin with THADAM_TEST_NATS_IMAGE):
cargo nextest run -p thadam-nats --run-ignored only -E 'binary(docker_nats)'
They cover publish-and-consume-back (subjects, envelope, headers), broker stop/start recovery without duplicate delivered messages within the stream’s duplicate window, retried batches deduplicating by Nats-Msg-Id, a missing stream failing open with a configuration error, delivery-class probing against an operator-zeroed window, create_stream, per-event rejection (unrenderable subject, payload over the server’s max_payload, message over the stream’s max_msg_size), no responders classified transient, token and user/password credentials with wrong credentials and denied publishes classified fatal, and mutual TLS with a CA and client certificate. Set RUST_LOG=debug for sink logs during the run.
Each test starts its own container (labelled thadam-nats-test) and removes it when the test ends, including on panic. Containers that needed a config file (auth, TLS) get it through docker cp before they start; no host path is mounted. To clean up after a killed run: docker rm -f $(docker ps -aq --filter label=thadam-nats-test).