photon_backend_nats/config.rs
1//! Builder and resolved configuration for the NATS storage adapter.
2
3use std::time::Duration;
4
5use photon_backend::{BrokerTransportSecurity, PhotonError, Result, TransportCrypto};
6
7use crate::replicas::replicas_from_env;
8use crate::retention::retention_from_env;
9
10/// Environment variable for NATS server URL.
11pub const URL_ENV: &str = "PHOTON_NATS_URL";
12
13/// Environment variable for `JetStream` stream name.
14pub const STREAM_ENV: &str = "PHOTON_NATS_STREAM";
15
16/// Environment variable for NATS credentials file path (JWT + `NKey` `.creds`).
17pub const CREDS_ENV: &str = "PHOTON_NATS_CREDS";
18
19/// How durable replay and checkpoints map to `JetStream`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ReplayCursor {
22 /// Broker stream sequence on publish ack; `ByStartSequence` replay.
23 #[default]
24 StreamSeq,
25 /// Live tail only; checkpoints no-op; maximum publish throughput.
26 TailOnly,
27}
28
29/// Resolved NATS adapter settings (no env lookups at append time).
30#[derive(Clone)]
31pub struct NatsConfig {
32 /// NATS server URL(s).
33 pub url: String,
34 /// Optional path to a NATS `.creds` file (JWT + `NKey`). Prefer this over URL userinfo.
35 pub credentials_file: Option<String>,
36 /// `JetStream` stream name.
37 pub stream_name: String,
38 /// Stream max age / replay window.
39 pub retention: Duration,
40 /// `JetStream` stream replica count.
41 pub replicas: usize,
42 /// Payload envelope crypto.
43 pub crypto: TransportCrypto,
44 /// Replay and checkpoint semantics.
45 pub replay_cursor: ReplayCursor,
46 /// Await `JetStream` publish ack per message.
47 pub sync_ack: bool,
48 /// Max concurrent in-flight publishes.
49 pub max_inflight: u32,
50 /// Independent `JetStream` streams for ingress sharding (`1` = legacy single stream).
51 pub stream_shards: u32,
52 /// Plaintext vs TLS connect policy.
53 pub transport_security: BrokerTransportSecurity,
54}
55
56impl NatsConfig {
57 /// Replica count applied when creating shard streams.
58 #[must_use]
59 pub const fn effective_replicas(&self) -> usize {
60 if self.stream_shards > 1 {
61 1
62 } else {
63 self.replicas
64 }
65 }
66
67 /// Whether publishes route across multiple `JetStream` streams.
68 #[must_use]
69 pub const fn is_sharded(&self) -> bool {
70 self.stream_shards > 1
71 }
72}
73
74/// Environment variable for replay cursor mode.
75pub const REPLAY_CURSOR_ENV: &str = "PHOTON_NATS_REPLAY_CURSOR";
76
77/// Environment variable for synchronous publish ack (`1` / `0`).
78pub const SYNC_ACK_ENV: &str = "PHOTON_NATS_SYNC_ACK";
79
80/// Environment variable for max in-flight publishes per port.
81pub const MAX_INFLIGHT_ENV: &str = "PHOTON_NATS_MAX_INFLIGHT";
82
83/// Builder for [`super::port::NatsStoragePort`].
84///
85/// **Configuration lives here.** Set builder methods explicitly; unset fields fall back to
86/// `PHOTON_NATS_*` environment variables via [`from_env_defaults`](Self::from_env_defaults).
87///
88/// Use the **same** builder settings on every Brokered publisher and worker binary that shares a
89/// cluster. Getting started:
90/// [Brokered](https://docs.rs/uf-photon/latest/photon/#brokered-publisher--worker-binaries).
91/// Runnable: `cargo run -p uf-photon --example nats_worker --features runtime,nats` then
92/// `nats_publisher` (see `photon/README.md` § How to run examples).
93///
94/// # Options
95///
96/// | Method / env | Default | Purpose |
97/// |--------------|---------|---------|
98/// | [`.url`](Self::url) / [`URL_ENV`] | **required** | NATS server URL(s). Prefer `tls://` in production. |
99/// | [`.credentials_file`](Self::credentials_file) / [`CREDS_ENV`] | unset | Path to NATS `.creds` (JWT + `NKey`). Prefer over URL userinfo. |
100/// | [`.stream_name`](Self::stream_name) / [`STREAM_ENV`] | `photon` | `JetStream` stream name. |
101/// | [`.retention`](Self::retention) / [`RETENTION_ENV`](crate::retention::RETENTION_ENV) | `15m` | Stream max age (applied at stream create). |
102/// | [`.replicas`](Self::replicas) / [`REPLICAS_ENV`](crate::replicas::REPLICAS_ENV) | `1` | Stream replica count. |
103/// | [`.replay_cursor`](Self::replay_cursor) / [`REPLAY_CURSOR_ENV`] | `stream_seq` | [`ReplayCursor::StreamSeq`] or [`ReplayCursor::TailOnly`]. |
104/// | [`.sync_ack`](Self::sync_ack) / [`SYNC_ACK_ENV`] | `1` | Await `JetStream` publish ack (`0` = firehose). |
105/// | [`.max_inflight`](Self::max_inflight) / [`MAX_INFLIGHT_ENV`] | `1` / `256` | Concurrent in-flight publishes (`256` when `sync_ack` off). |
106/// | [`.stream_shards`](Self::stream_shards) / [`STREAM_SHARDS_ENV`](crate::stream_shard::STREAM_SHARDS_ENV) | `1` | `JetStream` stream shard count (`K>1` → `photon-0..K-1`). |
107/// | [`.require_tls`](Self::require_tls) | require TLS | Plaintext `nats://` needs [`.allow_insecure_plaintext`](Self::allow_insecure_plaintext). |
108///
109/// Set `.stream_shards(K)` consistently across embedded hosts (builder-first; not tied to publisher count).
110///
111/// # Examples
112///
113/// ## Publisher binary (TLS + credentials)
114///
115/// Publish only — skip `start_executor` unless this process also runs handlers.
116///
117/// ```rust,ignore
118/// use std::sync::Arc;
119///
120/// use photon_backend_nats::{NatsStoragePort, ReplayCursor};
121/// use photon_runtime::Photon;
122///
123/// # async fn boot_publisher() -> photon_backend::Result<()> {
124/// let port = Arc::new(
125/// NatsStoragePort::builder()
126/// .url("tls://nats.example:4222")
127/// .credentials_file("/run/secrets/nats.creds")
128/// .require_tls()
129/// .replay_cursor(ReplayCursor::StreamSeq)
130/// .sync_ack(true)
131/// .build()
132/// .await?,
133/// );
134/// let photon = Photon::builder()
135/// .storage_port(port)
136/// .auto_registry()
137/// .build()?;
138/// // EventType { … }.publish_on(&photon).await?;
139/// # let _ = photon;
140/// # Ok(())
141/// # }
142/// ```
143///
144/// ## Worker binary
145///
146/// Same port wiring as the publisher, plus `#[subscribe]` handlers and `start_executor`.
147/// Start workers before publishers.
148///
149/// ```rust,ignore
150/// use std::sync::Arc;
151///
152/// use photon_backend_nats::{NatsStoragePort, ReplayCursor};
153/// use photon_core::JsonIdentityFactory;
154/// use photon_runtime::Photon;
155///
156/// # async fn boot_worker() -> photon_backend::Result<()> {
157/// let port = Arc::new(
158/// NatsStoragePort::builder()
159/// .from_env_defaults()
160/// .replay_cursor(ReplayCursor::StreamSeq)
161/// .sync_ack(true)
162/// .build()
163/// .await?,
164/// );
165/// let photon = Photon::builder()
166/// .storage_port(port)
167/// .auto_registry()
168/// .build()?;
169/// photon.start_executor(Arc::new(JsonIdentityFactory))?;
170/// # let _ = photon;
171/// # Ok(())
172/// # }
173/// ```
174///
175/// See also: [`NatsConfig`] (resolved snapshot), crate-level topic mapping in [`crate`](index.html).
176#[derive(Default)]
177pub struct NatsStoragePortBuilder {
178 url: Option<String>,
179 credentials_file: Option<String>,
180 transport_security: Option<BrokerTransportSecurity>,
181 stream_name: Option<String>,
182 retention: Option<Duration>,
183 replicas: Option<usize>,
184 crypto: Option<TransportCrypto>,
185 replay_cursor: Option<ReplayCursor>,
186 sync_ack: Option<bool>,
187 max_inflight: Option<u32>,
188 stream_shards: Option<u32>,
189}
190
191impl NatsStoragePortBuilder {
192 /// Empty builder; set fields or call [`Self::from_env_defaults`].
193 #[must_use]
194 pub fn new() -> Self {
195 Self::default()
196 }
197
198 /// Fill unset fields from `PHOTON_NATS_*` environment variables.
199 #[must_use]
200 pub fn from_env_defaults(mut self) -> Self {
201 if self.url.is_none() {
202 self.url = std::env::var(URL_ENV).ok();
203 }
204 if self.credentials_file.is_none() {
205 self.credentials_file = std::env::var(CREDS_ENV).ok();
206 }
207 if self.stream_name.is_none() {
208 self.stream_name = Some(std::env::var(STREAM_ENV).unwrap_or_else(|_| "photon".into()));
209 }
210 if self.retention.is_none() {
211 self.retention = Some(retention_from_env());
212 }
213 if self.replicas.is_none() {
214 self.replicas = Some(replicas_from_env());
215 }
216 if self.replay_cursor.is_none() {
217 self.replay_cursor = Some(replay_cursor_from_env());
218 }
219 if self.sync_ack.is_none() {
220 self.sync_ack = Some(sync_ack_from_env());
221 }
222 if self.max_inflight.is_none() {
223 self.max_inflight = Some(max_inflight_from_env(self.sync_ack.unwrap_or(true)));
224 }
225 if self.stream_shards.is_none() {
226 self.stream_shards = Some(crate::stream_shard::stream_shards_from_env());
227 }
228 self
229 }
230
231 /// NATS server URL.
232 #[must_use]
233 pub fn url(mut self, url: impl Into<String>) -> Self {
234 self.url = Some(url.into());
235 self
236 }
237
238 /// Path to a NATS credentials file (JWT + `NKey`). Prefer over embedding secrets in the URL.
239 #[must_use]
240 pub fn credentials_file(mut self, path: impl Into<String>) -> Self {
241 self.credentials_file = Some(path.into());
242 self
243 }
244
245 /// `JetStream` stream name.
246 #[must_use]
247 pub fn stream_name(mut self, name: impl Into<String>) -> Self {
248 self.stream_name = Some(name.into());
249 self
250 }
251
252 /// Stream retention duration.
253 #[must_use]
254 pub const fn retention(mut self, retention: Duration) -> Self {
255 self.retention = Some(retention);
256 self
257 }
258
259 /// Stream replica count.
260 #[must_use]
261 pub const fn replicas(mut self, replicas: usize) -> Self {
262 self.replicas = Some(replicas);
263 self
264 }
265
266 /// Transport crypto for publish path.
267 #[must_use]
268 pub fn crypto(mut self, crypto: TransportCrypto) -> Self {
269 self.crypto = Some(crypto);
270 self
271 }
272
273 /// Replay cursor mode.
274 #[must_use]
275 pub const fn replay_cursor(mut self, cursor: ReplayCursor) -> Self {
276 self.replay_cursor = Some(cursor);
277 self
278 }
279
280 /// Whether to await publish ack.
281 #[must_use]
282 pub const fn sync_ack(mut self, wait: bool) -> Self {
283 self.sync_ack = Some(wait);
284 self
285 }
286
287 /// Pipeline depth for concurrent publishes.
288 #[must_use]
289 pub fn max_inflight(mut self, n: u32) -> Self {
290 self.max_inflight = Some(n.max(1));
291 self
292 }
293
294 /// `JetStream` stream shard count for ingress scaling (`1` = single shared stream).
295 #[must_use]
296 pub fn stream_shards(mut self, n: u32) -> Self {
297 self.stream_shards = Some(n.clamp(1, crate::stream_shard::MAX_STREAM_SHARDS));
298 self
299 }
300
301 /// Allow plaintext `nats://` endpoints (development/CI only).
302 #[must_use]
303 pub const fn allow_insecure_plaintext(mut self) -> Self {
304 self.transport_security = Some(BrokerTransportSecurity::AllowInsecurePlaintext);
305 self
306 }
307
308 /// Require TLS-oriented broker endpoints (default when unset and env opt-in absent).
309 #[must_use]
310 pub const fn require_tls(mut self) -> Self {
311 self.transport_security = Some(BrokerTransportSecurity::RequireTls);
312 self
313 }
314
315 /// Resolve configuration.
316 ///
317 /// # Errors
318 ///
319 /// Returns an error when required fields (URL) are missing.
320 pub fn resolve(self) -> Result<NatsConfig> {
321 let builder = self.from_env_defaults();
322 let url = builder.url.ok_or_else(|| {
323 PhotonError::Internal(format!("{URL_ENV} not set for nats storage adapter"))
324 })?;
325 Ok(NatsConfig {
326 url,
327 credentials_file: builder.credentials_file,
328 stream_name: builder.stream_name.unwrap_or_else(|| "photon".into()),
329 retention: builder.retention.unwrap_or_else(retention_from_env),
330 replicas: builder.replicas.unwrap_or_else(replicas_from_env),
331 crypto: match builder.crypto {
332 Some(c) => c,
333 None => TransportCrypto::from_env()?,
334 },
335 replay_cursor: builder.replay_cursor.unwrap_or(ReplayCursor::StreamSeq),
336 sync_ack: builder.sync_ack.unwrap_or(true),
337 max_inflight: builder
338 .max_inflight
339 .unwrap_or_else(|| max_inflight_from_env(builder.sync_ack.unwrap_or(true))),
340 stream_shards: builder
341 .stream_shards
342 .unwrap_or_else(crate::stream_shard::stream_shards_from_env),
343 transport_security: builder
344 .transport_security
345 .unwrap_or_else(BrokerTransportSecurity::from_env),
346 })
347 }
348}
349
350fn replay_cursor_from_env() -> ReplayCursor {
351 match std::env::var(REPLAY_CURSOR_ENV)
352 .unwrap_or_else(|_| "stream_seq".into())
353 .to_ascii_lowercase()
354 .as_str()
355 {
356 "tail_only" | "tail" | "none" => ReplayCursor::TailOnly,
357 _ => ReplayCursor::StreamSeq,
358 }
359}
360
361fn sync_ack_from_env() -> bool {
362 !matches!(
363 std::env::var(SYNC_ACK_ENV)
364 .unwrap_or_else(|_| "1".into())
365 .as_str(),
366 "0" | "false" | "off" | "no"
367 )
368}
369
370fn max_inflight_from_env(sync_ack: bool) -> u32 {
371 if let Ok(raw) = std::env::var(MAX_INFLIGHT_ENV) {
372 if let Ok(n) = raw.parse::<u32>() {
373 return n.max(1);
374 }
375 }
376 if sync_ack {
377 1
378 } else {
379 256
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn replay_cursor_parses_tail_only() {
389 std::env::set_var(REPLAY_CURSOR_ENV, "tail_only");
390 assert_eq!(replay_cursor_from_env(), ReplayCursor::TailOnly);
391 std::env::remove_var(REPLAY_CURSOR_ENV);
392 }
393}