Skip to main content

photon_backend_nats/
config.rs

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