Skip to main content

cellos_server/
jetstream.rs

1//! JetStream wiring for `cellos-server`.
2//!
3//! Closes the two open SEAMs from ADR-0011 (projection replay on boot)
4//! and ADR-0015 (`?since=<seq>` historical replay via JetStream).
5//!
6//! ## Why the indirection
7//!
8//! `cellos-server` is a *projection* over the `cellos.events.>` event
9//! log (CHATROOM Session 16). The in-memory `AppState` registry MUST be
10//! rebuildable from JetStream alone — that is the property that makes
11//! the server safely restartable. This module owns:
12//!
13//! 1. **Stream provisioning.** [`ensure_stream`] gets-or-creates the
14//!    canonical `CELLOS_EVENTS` stream with the FC-74 retention floor
15//!    (90 days, file-backed). Idempotent; a pre-existing stream is left
16//!    untouched (we do not reconcile config drift here — that is a
17//!    cluster-admin concern, not an HTTP-control-plane one).
18//!
19//! 2. **Ephemeral consumer construction.** [`create_ephemeral_consumer`]
20//!    returns a pull consumer whose `DeliverPolicy` is selected from a
21//!    caller-supplied `start_seq`: `None` → `DeliverPolicy::All`
22//!    (replay everything from the earliest retained message), `Some(N)`
23//!    → `DeliverPolicy::ByStartSequence { N+1 }` (resume from the
24//!    message after the client's cursor, exactly matching the ADR-0015
25//!    §D3 contract).
26//!
27//! 3. **Boot-time replay.** [`replay_projection`] drains every message
28//!    currently in the stream into `AppState`, advancing the snapshot
29//!    cursor as it goes, then returns. Called before the HTTP listener
30//!    binds so the first `GET /v1/formations` after a restart returns
31//!    the fully-rebuilt view with a non-zero cursor.
32//!
33//! ## What we deliberately do *not* do
34//!
35//! - We never create *durable* consumers. The server's view is
36//!   ephemeral by design: every restart replays the stream, so there
37//!   is no consumer state to leak into JetStream's own consumer table.
38//! - We do not acknowledge messages. `AckPolicy::None` keeps the
39//!   consumer cheap and side-effect-free; this is a *read-only*
40//!   projection feed, not a work queue.
41//! - We do not reconcile stream-config drift. If `CELLOS_EVENTS`
42//!   already exists with a different `max_age`, we use it as-is. The
43//!   alternative — silently mutating an operator's stream config —
44//!   would violate doctrine on least surprise.
45
46use std::time::Duration;
47
48use anyhow::Context as _;
49use async_nats::jetstream::{
50    self,
51    consumer::{
52        pull::{Config as PullConfig, Stream as PullStream},
53        AckPolicy, Consumer, DeliverPolicy,
54    },
55    context::Context,
56    stream::{Config as StreamConfig, RetentionPolicy, StorageType, Stream},
57};
58use futures_util::StreamExt;
59use tracing::{debug, info, warn};
60
61use crate::state::{AppState, ApplyOutcome};
62
63/// Canonical stream name for every CloudEvent the platform emits.
64/// Mirrors `cellos-supervisor`'s producer-side constant.
65pub const STREAM_NAME: &str = "CELLOS_EVENTS";
66
67/// Subject filter for every CloudEvent. The MVP supervisor publishes
68/// to `cellos.events.<entity>.<id>.<phase>`; the wildcard captures all
69/// of them.
70pub const STREAM_SUBJECT: &str = "cellos.events.>";
71
72/// FC-74 retention floor — operators have committed to keeping the
73/// last 90 days of events available for forensic replay. Anything
74/// older falls off the tail.
75const RETENTION_MAX_AGE: Duration = Duration::from_secs(90 * 24 * 60 * 60);
76
77/// Boot-replay drains messages in batches of this size. Picked to be
78/// large enough that a fresh cluster with thousands of events doesn't
79/// pay one round-trip per event, but small enough that a single batch
80/// doesn't dominate startup latency on a small cluster.
81const REPLAY_BATCH: usize = 256;
82
83/// Timeout for a single batch fetch during replay. Short because we
84/// only need to detect "no more messages right now" — the next batch
85/// either returns immediately with data or this timeout fires and we
86/// exit the drain loop.
87const REPLAY_BATCH_TIMEOUT: Duration = Duration::from_millis(250);
88
89/// Server-side duplicate-detection window for publisher `Nats-Msg-Id` dedup
90/// (S16, ADR-0029). With this set, JetStream rejects a second publish carrying a
91/// `Nats-Msg-Id` already seen within the window, so the spool forwarder (S17) can
92/// re-publish a chain row keyed by its `row_hash` without creating a duplicate
93/// after a reconnect.
94///
95/// **Bounded — defense-in-depth, not the authority.** A duplicate `Nats-Msg-Id`
96/// arriving AFTER this window is NOT deduped by the server; idempotency
97/// ultimately rests on the forwarder's durable forward-cursor. The window only
98/// narrows the duplicate surface during normal reconnect timing (residual:
99/// bounded double-delivery beyond the window).
100const DUPLICATE_WINDOW: Duration = Duration::from_secs(120);
101
102/// Construct a JetStream context and ensure the canonical
103/// `CELLOS_EVENTS` stream exists with the FC-74 retention floor.
104///
105/// Idempotent: if the stream already exists with any config we leave
106/// it alone (see module docs for the rationale).
107pub async fn ensure_stream(client: &async_nats::Client) -> anyhow::Result<Context> {
108    let ctx = jetstream::new(client.clone());
109    let config = StreamConfig {
110        name: STREAM_NAME.to_string(),
111        subjects: vec![STREAM_SUBJECT.to_string()],
112        retention: RetentionPolicy::Limits,
113        storage: StorageType::File,
114        max_age: RETENTION_MAX_AGE,
115        // S16: bounded Nats-Msg-Id dedup window (see DUPLICATE_WINDOW).
116        duplicate_window: DUPLICATE_WINDOW,
117        ..Default::default()
118    };
119
120    // `get_or_create_stream` is server-idempotent: if a stream with
121    // this name already exists, the server returns it as-is and we
122    // never overwrite operator-tuned settings.
123    ctx.get_or_create_stream(config)
124        .await
125        .context("get_or_create CELLOS_EVENTS stream")?;
126
127    Ok(ctx)
128}
129
130/// Decide which `DeliverPolicy` to apply for a given resume cursor.
131///
132/// Pulled out as a pure function so the policy decision can be
133/// unit-tested without spinning up a NATS broker. The mapping is the
134/// ADR-0015 §D3 contract: `None` means "deliver everything currently
135/// retained" (used by boot replay), `Some(N)` means "deliver from N+1
136/// onward" (the client has already seen up through N).
137pub fn deliver_policy_for(start_seq: Option<u64>) -> DeliverPolicy {
138    match start_seq {
139        // Boot replay path: rebuild state from the oldest retained
140        // message. With FC-74 retention this is the last 90 days.
141        None => DeliverPolicy::All,
142        // Resume path: the client's cursor IS the last seq it applied,
143        // so we start delivery at seq+1 to avoid re-emitting frames it
144        // already processed.
145        Some(seq) => DeliverPolicy::ByStartSequence {
146            start_sequence: seq.saturating_add(1),
147        },
148    }
149}
150
151/// Live-tail policy for `/ws/events` opens without a `?since=` param.
152///
153/// Distinct from the resume path because "no cursor at all" should
154/// NOT replay history; ad-hoc subscribers (cellctl `--follow`, debug
155/// tools) want to see only what flows in after their connect.
156pub fn deliver_policy_live_tail() -> DeliverPolicy {
157    DeliverPolicy::New
158}
159
160/// How long a WS-bridge ephemeral consumer may sit idle on the broker
161/// after its client disappears before JetStream itself garbage-collects
162/// it. Without this, a WebSocket whose TCP connection died ungracefully
163/// (laptop suspend, NAT eviction, OOM kill) leaves the ephemeral
164/// consumer pinned to that ghost forever — under `AckPolicy::None` the
165/// pending-ack count stays at zero, but the consumer record still
166/// occupies broker resources.
167///
168/// 5 minutes is well clear of the web view's 30-second reconnect
169/// ceiling (ADR-0015 §D4) plus a couple of retries, and short enough
170/// that thousands of dead consumers do not accumulate over a day.
171const EPHEMERAL_INACTIVE_THRESHOLD: Duration = Duration::from_secs(5 * 60);
172
173/// Construct the pull-consumer config that backs the WebSocket bridge
174/// and boot-time replay. Pulled out as a pure function so the policy
175/// decision can be unit-tested without a live NATS broker.
176pub fn ephemeral_consumer_config_for(policy: DeliverPolicy, subject: Option<&str>) -> PullConfig {
177    PullConfig {
178        durable_name: None,
179        name: None,
180        deliver_policy: policy,
181        ack_policy: AckPolicy::None,
182        filter_subject: subject.unwrap_or("").to_string(),
183        inactive_threshold: EPHEMERAL_INACTIVE_THRESHOLD,
184        ..Default::default()
185    }
186}
187
188/// Create an ephemeral pull consumer against the canonical stream.
189///
190/// `start_seq` follows the same convention as
191/// [`deliver_policy_for`]: `None` → `DeliverPolicy::All`, `Some(N)` →
192/// `DeliverPolicy::ByStartSequence { N+1 }`. `subject` optionally
193/// filters to a sub-tree of `cellos.events.>` so a tenant-scoped
194/// WebSocket sees only its tenant's events.
195///
196/// Always ephemeral (`durable_name = None`), `AckPolicy::None`, and
197/// gated by `EPHEMERAL_INACTIVE_THRESHOLD` for broker-side GC — see
198/// module docs and that constant.
199pub async fn create_ephemeral_consumer(
200    stream: &Stream,
201    policy: DeliverPolicy,
202    subject: Option<&str>,
203) -> anyhow::Result<Consumer<PullConfig>> {
204    let config = ephemeral_consumer_config_for(policy, subject);
205    stream
206        .create_consumer(config)
207        .await
208        .context("create ephemeral pull consumer")
209}
210
211/// Drive an ephemeral `DeliverPolicy::All` consumer to completion,
212/// applying each event into `AppState` and advancing the snapshot
213/// cursor. Returns once a batch fetch comes back empty within the
214/// (module-private) `REPLAY_BATCH_TIMEOUT` window, which indicates the
215/// stream's tail has been reached.
216///
217/// This is the ADR-0011 SEAM closure: after this returns the
218/// in-memory registry reflects the current authoritative view of the
219/// event log, so the HTTP listener can open and serve `GET
220/// /v1/formations` with a non-zero `cursor` immediately.
221pub async fn replay_projection(state: &AppState, ctx: &Context) -> anyhow::Result<()> {
222    // Operator escape hatch — tests that drive `cellos-server` in
223    // isolation (no live NATS, no JetStream) set this to skip replay
224    // entirely. Production deployments leave it unset.
225    if std::env::var("CELLOS_SERVER_SKIP_REPLAY")
226        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
227        .unwrap_or(false)
228    {
229        info!("CELLOS_SERVER_SKIP_REPLAY set; skipping projection replay");
230        return Ok(());
231    }
232
233    let stream = ctx
234        .get_stream(STREAM_NAME)
235        .await
236        .context("get_stream CELLOS_EVENTS for replay")?;
237
238    let consumer = create_ephemeral_consumer(&stream, deliver_policy_for(None), None).await?;
239
240    let mut total_seen: u64 = 0;
241    let mut total_applied: u64 = 0;
242    let mut highest_seq: u64 = 0;
243
244    // We use `fetch().messages()` rather than `consumer.messages()`
245    // because `fetch` terminates on a single empty batch — exactly
246    // the "drain until idle" semantics replay needs. A long-lived
247    // `messages()` stream would idle forever waiting for new
248    // publishes, which would deadlock startup on a quiet cluster.
249    loop {
250        let mut batch = consumer
251            .fetch()
252            .max_messages(REPLAY_BATCH)
253            .expires(REPLAY_BATCH_TIMEOUT)
254            .messages()
255            .await
256            .context("replay: pull batch")?;
257
258        let mut batch_size: usize = 0;
259        while let Some(msg) = batch.next().await {
260            // async-nats yields Result<Message, Box<dyn StdError + Send + Sync>>;
261            // Box<dyn StdError> doesn't impl anyhow::Context. Convert via map_err.
262            let msg = msg.map_err(|e| anyhow::anyhow!("replay: read message from batch: {e}"))?;
263            batch_size += 1;
264            total_seen += 1;
265
266            let seq = match msg.info() {
267                Ok(info) => info.stream_sequence,
268                Err(e) => {
269                    warn!(error = %e, "replay: message missing JetStream info; skipping");
270                    continue;
271                }
272            };
273            highest_seq = highest_seq.max(seq);
274
275            match state.apply_event_payload(&msg.payload).await {
276                Ok(ApplyOutcome::Applied) => total_applied += 1,
277                Ok(ApplyOutcome::Ignored) => {}
278                Err(e) => {
279                    warn!(seq, error = %e, "replay: failed to apply event; skipping");
280                }
281            }
282            state.bump_cursor(seq);
283        }
284
285        if batch_size == 0 {
286            // No messages within the batch's expires window → we are
287            // at the tail. Done.
288            break;
289        }
290        debug!(batch_size, total_seen, "replay batch consumed");
291    }
292
293    let formations = state.formations.read().await.len();
294    info!(
295        cursor = highest_seq,
296        formations,
297        events_seen = total_seen,
298        events_applied = total_applied,
299        "replay_complete"
300    );
301    Ok(())
302}
303
304/// Drive a long-lived `DeliverPolicy::New` consumer that pumps every
305/// post-boot CloudEvent into `AppState` via `apply_event_payload`.
306///
307/// This closes the PROJECTION-LIVE-LAG gap (E2E-V2 acceptance report,
308/// 0.5.1 → 0.5.2 wave): [`replay_projection`] rebuilds the projection
309/// once at boot, but until this loop runs nothing fed new events into
310/// the projection while the server was alive — supervisor-emitted
311/// `cell.lifecycle.v1.*` events only appeared in `GET /v1/cells` after
312/// the next server restart re-replayed the stream.
313///
314/// The loop is intentionally minimal:
315///
316/// - `DeliverPolicy::New` (not `All`): replay has already drained the
317///   stream's history; we only want what publishes AFTER the listener
318///   binds. The two paths together cover the whole timeline without
319///   double-counting the boundary message.
320/// - No subject filter: same `cellos.events.>` surface the replay
321///   consumer used. `apply_event_payload` is the single discriminator.
322/// - `AckPolicy::None` + ephemeral: identical to the replay consumer's
323///   semantics, so a process restart re-derives the consumer cheaply
324///   and never accumulates broker-side state.
325/// - `messages()` (not `fetch()`): we want to block on the next publish
326///   indefinitely; this is the long-lived tail, not a drain.
327///
328/// The task is detached — `main.rs` keeps the `JoinHandle` only so a
329/// future graceful-shutdown path can abort it cleanly. On error the
330/// loop logs and exits; the projection will go stale until the server
331/// restarts, but that is strictly no worse than the pre-fix behavior
332/// (which never had a live projector at all).
333pub async fn spawn_live_projector(
334    state: AppState,
335    ctx: Context,
336) -> anyhow::Result<tokio::task::JoinHandle<()>> {
337    let stream = ctx
338        .get_stream(STREAM_NAME)
339        .await
340        .context("get_stream CELLOS_EVENTS for live projector")?;
341
342    // `DeliverPolicy::New` so we don't re-apply the replayed history.
343    // The post-boot stream tail is the projector's responsibility from
344    // here on.
345    let consumer = create_ephemeral_consumer(&stream, DeliverPolicy::New, None).await?;
346    let mut messages = consumer
347        .messages()
348        .await
349        .context("open live projector message stream")?;
350
351    let handle = tokio::spawn(async move {
352        info!("live projector subscribed; tailing cellos.events.>");
353        while let Some(msg) = messages.next().await {
354            let msg = match msg {
355                Ok(m) => m,
356                Err(e) => {
357                    warn!(error = %e, "live projector: read error; exiting tail");
358                    return;
359                }
360            };
361
362            let seq = match msg.info() {
363                Ok(info) => info.stream_sequence,
364                Err(e) => {
365                    warn!(error = %e, "live projector: message missing JetStream info; skipping");
366                    continue;
367                }
368            };
369
370            match state.apply_event_payload(&msg.payload).await {
371                Ok(ApplyOutcome::Applied) => {
372                    debug!(seq, "live projector: event applied");
373                }
374                Ok(ApplyOutcome::Ignored) => {}
375                Err(e) => {
376                    warn!(seq, error = %e, "live projector: failed to apply event; skipping");
377                }
378            }
379            // Mirror replay's contract: advance the cursor whether or
380            // not the projection mutated. WS clients reading the
381            // snapshot cursor see live events without a restart.
382            state.bump_cursor(seq);
383        }
384        debug!("live projector: stream ended");
385    });
386
387    Ok(handle)
388}
389
390/// Open an ephemeral pull-consumer message stream for the WebSocket
391/// bridge. Distinct from `replay_projection`'s consumer because the
392/// WS path needs a long-lived `messages()` stream (waits for new
393/// publishes) rather than a fetch-and-terminate loop.
394pub async fn open_ws_message_stream(
395    ctx: &Context,
396    subject: Option<&str>,
397    since: Option<u64>,
398) -> anyhow::Result<PullStream> {
399    let stream = ctx
400        .get_stream(STREAM_NAME)
401        .await
402        .context("get_stream CELLOS_EVENTS for ws")?;
403
404    let policy = match since {
405        Some(seq) => deliver_policy_for(Some(seq)),
406        None => deliver_policy_live_tail(),
407    };
408
409    let consumer = create_ephemeral_consumer(&stream, policy, subject).await?;
410    consumer
411        .messages()
412        .await
413        .context("open consumer messages stream")
414}
415
416/// Inspect the live stream's retained-sequence floor so the WS bridge
417/// can report it back to a client whose `since` was below the
418/// retention window. Returns `None` if the stream cannot be reached
419/// (in which case the caller should fall through to the generic
420/// close path).
421pub async fn stream_first_seq(ctx: &Context) -> Option<u64> {
422    match ctx.get_stream(STREAM_NAME).await {
423        Ok(mut stream) => match stream.info().await {
424            Ok(info) => Some(info.state.first_sequence),
425            Err(e) => {
426                debug!(error = %e, "stream_first_seq: info() failed");
427                None
428            }
429        },
430        Err(e) => {
431            debug!(error = %e, "stream_first_seq: get_stream failed");
432            None
433        }
434    }
435}
436
437/// Heuristic: did `create_ephemeral_consumer` fail because the
438/// caller's `start_seq` is older than the stream's retained floor?
439///
440/// JetStream surfaces this as a server-side `ErrConsumerCreate` with
441/// a description like "optional start sequence ... is too low".
442/// Rather than depend on a specific error-code shape we string-match
443/// the conventional message; if NATS changes the wording the worst
444/// case is we fall through to the generic close path, which is the
445/// same behavior as before this commit.
446pub fn looks_like_retention_exhausted(err: &anyhow::Error) -> bool {
447    let chain = format!("{err:#}").to_ascii_lowercase();
448    chain.contains("optional start sequence")
449        || chain.contains("too low")
450        || chain.contains("no such sequence")
451        || chain.contains("not found in stream")
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    /// ADR-0015 §D3 — boot replay (no cursor) must deliver every
459    /// retained message. Encoding this as a test pins the contract:
460    /// changing this enum variant in either direction breaks resume
461    /// or breaks boot.
462    #[test]
463    fn deliver_policy_none_is_all() {
464        assert!(matches!(deliver_policy_for(None), DeliverPolicy::All));
465    }
466
467    /// ADR-0015 §D3 — `?since=N` resumes at seq N+1, not N. The
468    /// client's cursor is the *last applied* sequence, so re-emitting
469    /// N would be a duplicate delivery.
470    #[test]
471    fn deliver_policy_some_starts_at_seq_plus_one() {
472        match deliver_policy_for(Some(42)) {
473            DeliverPolicy::ByStartSequence { start_sequence } => {
474                assert_eq!(start_sequence, 43);
475            }
476            other => panic!("expected ByStartSequence(43), got {other:?}"),
477        }
478    }
479
480    /// `?since=0` is a legitimate fresh client — they want everything
481    /// from seq 1 onward. The +1 still applies (`0 + 1 = 1`), which
482    /// is exactly the first retained sequence in a fresh stream.
483    #[test]
484    fn deliver_policy_since_zero_starts_at_one() {
485        match deliver_policy_for(Some(0)) {
486            DeliverPolicy::ByStartSequence { start_sequence } => {
487                assert_eq!(start_sequence, 1);
488            }
489            other => panic!("expected ByStartSequence(1), got {other:?}"),
490        }
491    }
492
493    /// `u64::MAX` saturates rather than overflows. A misbehaving
494    /// client that hands us a sentinel value cannot panic the server.
495    #[test]
496    fn deliver_policy_since_saturates_at_u64_max() {
497        match deliver_policy_for(Some(u64::MAX)) {
498            DeliverPolicy::ByStartSequence { start_sequence } => {
499                assert_eq!(start_sequence, u64::MAX);
500            }
501            other => panic!("expected ByStartSequence(u64::MAX), got {other:?}"),
502        }
503    }
504
505    /// Live-tail must NOT replay history; an ad-hoc `/ws/events`
506    /// subscriber sees only what publishes after their open.
507    #[test]
508    fn live_tail_is_deliver_new() {
509        assert!(matches!(deliver_policy_live_tail(), DeliverPolicy::New));
510    }
511
512    /// Retention-exhaustion detection is best-effort string matching.
513    /// These are the message shapes we currently know about — if the
514    /// regression here is a new NATS error wording, that's exactly
515    /// when this test should fail.
516    #[test]
517    fn retention_exhausted_matches_known_messages() {
518        let cases = [
519            "consumer create failed: optional start sequence 5 is too low",
520            "no such sequence in stream",
521            "sequence 7 not found in stream CELLOS_EVENTS",
522            "ErrConsumerCreate: optional start sequence too low",
523        ];
524        for case in cases {
525            let err = anyhow::anyhow!(case.to_string());
526            assert!(
527                looks_like_retention_exhausted(&err),
528                "expected match for {case:?}",
529            );
530        }
531    }
532
533    /// Red-team finding: ephemeral consumer config MUST set
534    /// `inactive_threshold` so the broker reclaims consumers whose WS
535    /// clients died without a clean close. Without this, half-open TCP
536    /// connections (laptop suspend, NAT eviction) accumulate consumer
537    /// records on the broker indefinitely.
538    #[test]
539    fn ephemeral_consumer_config_sets_inactive_threshold() {
540        let cfg = ephemeral_consumer_config_for(DeliverPolicy::New, None);
541        assert!(
542            cfg.inactive_threshold > Duration::ZERO,
543            "inactive_threshold must be non-zero so broker GCs dead consumers; got {:?}",
544            cfg.inactive_threshold,
545        );
546        assert!(
547            cfg.inactive_threshold >= Duration::from_secs(60),
548            "inactive_threshold must ride out transient reconnect; got {:?}",
549            cfg.inactive_threshold,
550        );
551        assert!(cfg.durable_name.is_none(), "consumer must be ephemeral");
552        assert!(
553            matches!(cfg.ack_policy, AckPolicy::None),
554            "consumer must remain AckPolicy::None for read-only projection feed"
555        );
556    }
557
558    /// Unrelated errors must NOT be classified as retention loss —
559    /// otherwise we'd send 4410 close codes for transient broker
560    /// hiccups and stampede clients into snapshot refetches.
561    #[test]
562    fn retention_exhausted_ignores_unrelated_errors() {
563        let cases = [
564            "broker connection refused",
565            "timed out",
566            "stream not found", // ambiguous; we match on "not found in stream" only
567        ];
568        for case in cases {
569            let err = anyhow::anyhow!(case.to_string());
570            assert!(
571                !looks_like_retention_exhausted(&err),
572                "unexpected match for {case:?}",
573            );
574        }
575    }
576}