Skip to main content

ai_crew_sync/store/
jetstream.rs

1//! The JetStream adapter, behind the phase 3 backend boundary.
2//!
3//! Enabled only for teams an operator explicitly routes here. A default
4//! installation never contacts a broker, never provisions a stream, and does
5//! not need NATS to be running: `conversations.backend` is `postgres` unless
6//! someone changes it, and ordinary teams stay on the synchronous Postgres
7//! path (ADR 0001, phase 4).
8//!
9//! What this phase is for: proving the adapter contract against a **real**
10//! broker — publish, fetch, idempotency, quotas, reconnect and refused
11//! authorization — before any body of anyone's is routed through it. The
12//! integration fixture is therefore required rather than skipped: a missing
13//! broker fails the suite visibly, because a silently skipped broker test
14//! proves nothing and reads like a pass.
15//!
16//! Three shapes worth stating, because they are easy to get wrong:
17//!
18//! * **NATS is internal.** No subject, stream or consumer name is ever a
19//!   client-facing argument, and ACS checks every ACL itself. A credential
20//!   that reaches the broker grants nothing on the bus.
21//! * **Provisioning and runtime are separate privileges.** Creating streams
22//!   is an operator action with its own credential; publishing and fetching
23//!   use a narrower one that cannot create or delete.
24//! * **The 1 MiB body contract is unchanged.** The broker limit is 2 MiB, so
25//!   a body at the contract's ceiling still fits once headers and framing
26//!   are added, and the test measures the serialized size rather than
27//!   assuming it.
28
29use async_nats::jetstream;
30use uuid::Uuid;
31
32use crate::{
33    error::{BusError, BusResult},
34    store::backend::{Envelope, Locator, MessagingBackend, Published},
35};
36
37/// Header carrying the idempotency key. JetStream deduplicates on
38/// `Nats-Msg-Id` within its window; the outbox's `publish_key` is what we
39/// put there, so a retry inside that window is recognised by the broker and
40/// a retry outside it is recognised by our own reconciliation.
41pub const MSG_ID_HEADER: &str = "Nats-Msg-Id";
42
43/// Largest message the broker accepts, including headers. Twice the body
44/// contract, so a 1 MiB body plus envelope has room.
45///
46/// **This has to be set in two places.** The stream's `max_message_size` is
47/// one; the server's own `max_payload` is the other, and its default is
48/// 1 MiB — which a 1 MiB body plus envelope headers exceeds by a couple of
49/// hundred bytes. A deployment that raises only the stream limit refuses
50/// exactly the messages the body contract allows. Measured against a real
51/// broker in the adapter test, not assumed.
52pub const MAX_BROKER_MESSAGE_BYTES: i64 = 2 * 1024 * 1024;
53
54/// Bodies a team's stream retains before new writes are refused. Bounded on
55/// purpose: a stream with no ceiling is an outage waiting for a quiet week.
56/// Every stream's `max_bytes` is **reserved** against the broker's
57/// `max_file_store` the moment it is created, used or not, so these are
58/// the numbers an operator sizes the broker by (`team stream` takes
59/// smaller ones).
60pub const DEFAULT_MAX_MESSAGES: i64 = 100_000;
61pub const DEFAULT_MAX_BYTES: i64 = 2 * 1024 * 1024 * 1024;
62/// The inbox stream holds references (a few hundred bytes each, gone after
63/// seven days or on acknowledgement), so its reservation is a fraction of
64/// the body stream's.
65pub const DEFAULT_INBOX_MAX_MESSAGES: i64 = 100_000;
66pub const DEFAULT_INBOX_MAX_BYTES: i64 = 256 * 1024 * 1024;
67
68/// Stream name for one team. Stable, opaque and derived from the team id, so
69/// renaming a team never moves its data and a slug never reaches the broker.
70pub fn stream_name(team_id: Uuid) -> String {
71    format!("ACS_T_{}", team_id.simple())
72}
73
74/// Subject one conversation's bodies are published on.
75pub fn subject(team_id: Uuid, conversation_id: Uuid) -> String {
76    format!("acs.{}.conv.{}", team_id.simple(), conversation_id.simple())
77}
78
79/// Every subject a team's stream owns.
80pub fn subject_filter(team_id: Uuid) -> String {
81    format!("acs.{}.>", team_id.simple())
82}
83
84/// The inbox stream for one team: references, not bodies.
85///
86/// A second stream on purpose. Bodies are canonical history and must not be
87/// dropped; references are an accelerator whose truth is in Postgres and can
88/// be dropped freely. One retention policy cannot be right for both.
89pub fn inbox_stream_name(team_id: Uuid) -> String {
90    format!("ACS_I_{}", team_id.simple())
91}
92
93/// One recipient's own subject. `recipient_key` is opaque and
94/// subject-safe by construction: a session id, or `a` plus an agent id.
95pub fn inbox_subject(team_id: Uuid, recipient_key: &str) -> String {
96    format!("acsi.{}.inbox.{}", team_id.simple(), recipient_key)
97}
98
99pub fn inbox_filter(team_id: Uuid) -> String {
100    format!("acsi.{}.>", team_id.simple())
101}
102
103/// How long an unread reference is kept before Postgres is the only place
104/// it exists. Reconciliation rebuilds it from there, so this is a cache
105/// horizon and not a data loss window.
106pub const INBOX_MAX_AGE_SECS: u64 = 7 * 24 * 3600;
107/// Redeliveries before the broker gives up on a reference. Postgres still
108/// has it, and a reader is told the difference.
109pub const INBOX_MAX_DELIVER: i64 = 5;
110/// References one recipient may hold un-acknowledged at once.
111pub const INBOX_MAX_ACK_PENDING: i64 = 256;
112/// How long a handed-out reference may stay unconfirmed before the broker
113/// offers it again.
114pub const INBOX_ACK_WAIT_SECS: u64 = 60;
115
116/// One reference as the broker handed it over.
117#[derive(Clone, Debug)]
118pub struct InboxRef {
119    pub payload: String,
120    /// Where to acknowledge it once the receipt is committed. Empty when
121    /// the broker offered no reply subject, which makes it unackable and
122    /// therefore redelivered — visible rather than silently dropped.
123    pub ack_subject: String,
124    pub stream_seq: u64,
125    /// How many times the broker has offered this one. Greater than one is
126    /// a redelivery, which is expected and must be idempotent.
127    pub deliveries: u64,
128}
129
130/// What a recipient's consumer holds, for an honest status.
131#[derive(Clone, Debug, Default, serde::Serialize)]
132pub struct InboxStatus {
133    pub pending: u64,
134    pub awaiting_ack: u64,
135    pub redelivered: u64,
136    /// False when the consumer does not exist: expired, never created, or
137    /// deleted by an operator. Postgres is then the only source, and
138    /// reconciliation says so rather than reporting an empty inbox.
139    pub present: bool,
140}
141
142/// Connection settings. Kept away from the bus's own configuration: this is
143/// infrastructure an operator points at, never something a client supplies.
144#[derive(Clone, Debug)]
145pub struct Config {
146    /// e.g. `nats://127.0.0.1:4222`. TLS in production; the fixture runs
147    /// plaintext on a private port.
148    pub url: String,
149    /// Runtime credential: publish and fetch only. Provisioning uses a
150    /// different one, which the server process does not hold.
151    pub credentials: Option<String>,
152    /// Per-team stream ceilings for the body stream. Quotas are an operator
153    /// decision — the right number depends on the disk the broker actually
154    /// has — so they are configuration with a documented default, not a
155    /// constant.
156    pub max_messages: i64,
157    pub max_bytes: i64,
158    /// The inbox stream's own ceilings, independent of the body stream's.
159    pub inbox_max_messages: i64,
160    pub inbox_max_bytes: i64,
161}
162
163impl Config {
164    /// Production defaults: bounded, file-backed, refusing new writes when
165    /// full rather than discarding history.
166    pub fn new(url: impl Into<String>) -> Self {
167        Self {
168            url: url.into(),
169            credentials: None,
170            max_messages: DEFAULT_MAX_MESSAGES,
171            max_bytes: DEFAULT_MAX_BYTES,
172            inbox_max_messages: DEFAULT_INBOX_MAX_MESSAGES,
173            inbox_max_bytes: DEFAULT_INBOX_MAX_BYTES,
174        }
175    }
176
177    /// Smaller ceilings for both streams, for a fixture whose broker has a
178    /// small store.
179    pub fn with_limits(mut self, max_messages: i64, max_bytes: i64) -> Self {
180        self.max_messages = max_messages;
181        self.max_bytes = max_bytes;
182        self.inbox_max_messages = max_messages;
183        self.inbox_max_bytes = max_bytes;
184        self
185    }
186
187    /// Ceilings for the inbox stream alone.
188    pub fn with_inbox_limits(mut self, max_messages: i64, max_bytes: i64) -> Self {
189        self.inbox_max_messages = max_messages;
190        self.inbox_max_bytes = max_bytes;
191        self
192    }
193
194    /// Refuse quotas that could never work before the broker is asked: a
195    /// body stream must hold at least one maximum-size message, and a
196    /// count of zero would refuse every write.
197    pub fn validate_quotas(&self) -> BusResult<()> {
198        if self.max_bytes < MAX_BROKER_MESSAGE_BYTES {
199            return Err(BusError::invalid(format!(
200                "--max-bytes must be at least {} ({}), the largest message the body stream \
201                 accepts; {} would refuse every body",
202                MAX_BROKER_MESSAGE_BYTES,
203                format_size(MAX_BROKER_MESSAGE_BYTES),
204                format_size(self.max_bytes)
205            )));
206        }
207        if self.inbox_max_bytes < MIN_INBOX_BYTES {
208            return Err(BusError::invalid(format!(
209                "--inbox-max-bytes must be at least {} ({}); references are small but a \
210                 team sends many",
211                MIN_INBOX_BYTES,
212                format_size(MIN_INBOX_BYTES)
213            )));
214        }
215        if self.max_messages < 1 || self.inbox_max_messages < 1 {
216            return Err(BusError::invalid(
217                "--max-messages and --inbox-max-messages must be at least 1",
218            ));
219        }
220        Ok(())
221    }
222}
223
224/// The smallest inbox reservation `team stream` accepts.
225pub const MIN_INBOX_BYTES: i64 = 1024 * 1024;
226
227/// Parse an operator-typed size: plain bytes, or a number with `K`, `M`, `G`
228/// (or `KiB`, `MiB`, `GiB`, `KB`, `MB`, `GB`), case-insensitive, all
229/// binary multiples. `1GiB`, `512 MiB`, `1048576` all work.
230pub fn parse_size(raw: &str) -> Result<i64, String> {
231    let text = raw.trim();
232    let split = text
233        .find(|c: char| !c.is_ascii_digit())
234        .unwrap_or(text.len());
235    let (digits, unit) = text.split_at(split);
236    if digits.is_empty() {
237        return Err(format!(
238            "'{raw}' is not a size; write bytes, or a number with KiB, MiB or GiB"
239        ));
240    }
241    let number: i64 = digits
242        .parse()
243        .map_err(|_| format!("'{raw}' is too large a number"))?;
244    let multiplier: i64 = match unit.trim().to_ascii_lowercase().as_str() {
245        "" | "b" => 1,
246        "k" | "kb" | "kib" => 1024,
247        "m" | "mb" | "mib" => 1024 * 1024,
248        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
249        other => {
250            return Err(format!(
251                "'{raw}': unknown unit '{other}'; use bytes, KiB, MiB or GiB"
252            ));
253        }
254    };
255    number
256        .checked_mul(multiplier)
257        .ok_or_else(|| format!("'{raw}' is too large a size"))
258}
259
260/// A size for a human, in the unit that reads best.
261pub fn format_size(bytes: i64) -> String {
262    const GIB: i64 = 1024 * 1024 * 1024;
263    const MIB: i64 = 1024 * 1024;
264    const KIB: i64 = 1024;
265    if bytes >= GIB && bytes % GIB == 0 {
266        format!("{} GiB", bytes / GIB)
267    } else if bytes >= MIB && bytes % MIB == 0 {
268        format!("{} MiB", bytes / MIB)
269    } else if bytes >= KIB && bytes % KIB == 0 {
270        format!("{} KiB", bytes / KIB)
271    } else if bytes >= GIB {
272        format!("{:.1} GiB", bytes as f64 / GIB as f64)
273    } else if bytes >= MIB {
274        format!("{:.1} MiB", bytes as f64 / MIB as f64)
275    } else {
276        format!("{bytes} B")
277    }
278}
279
280/// Which of a team's two streams an operation addresses.
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum StreamKind {
283    Bodies,
284    Inbox,
285}
286
287/// What provisioning found or made: the stream, whether this call created
288/// it, the limits it has **now** (an existing stream keeps its own; see
289/// [`JetStreamBackend::update_quotas`]) and what it currently holds.
290#[derive(Clone, Debug)]
291pub struct Provisioned {
292    pub name: String,
293    pub created: bool,
294    pub max_messages: i64,
295    pub max_bytes: i64,
296    pub messages: u64,
297    pub bytes: u64,
298}
299
300impl Provisioned {
301    /// Whether the stream's limits differ from what the caller asked for.
302    pub fn differs_from(&self, max_messages: i64, max_bytes: i64) -> bool {
303        self.max_messages != max_messages || self.max_bytes != max_bytes
304    }
305
306    /// Whether the stream holds more than its ceiling allows; after an
307    /// update this means a publisher got in between the check and the
308    /// change.
309    pub fn over_ceiling(&self) -> bool {
310        (self.messages as i64) > self.max_messages || (self.bytes as i64) > self.max_bytes
311    }
312}
313
314/// A vetted quota change: the limits to restore on rollback, the limits
315/// asked for, and what the stream holds.
316#[derive(Clone, Debug)]
317pub struct QuotaChange {
318    pub name: String,
319    pub kind: StreamKind,
320    pub current_max_messages: i64,
321    pub current_max_bytes: i64,
322    pub wanted_max_messages: i64,
323    pub wanted_max_bytes: i64,
324    pub messages: u64,
325    pub bytes: u64,
326}
327
328impl QuotaChange {
329    /// Bytes the broker must reserve on top of what this stream already
330    /// reserves; zero for a decrease.
331    pub fn additional_bytes(&self) -> i64 {
332        (self.wanted_max_bytes - self.current_max_bytes).max(0)
333    }
334}
335
336/// The broker's storage account, in bytes.
337#[derive(Clone, Copy, Debug)]
338pub struct StorageAccount {
339    pub used: u64,
340    pub reserved: u64,
341    pub budget: Option<i64>,
342}
343
344impl StorageAccount {
345    /// Whether `additional` more reserved bytes fit the budget.
346    pub fn fits(&self, additional: i64) -> bool {
347        match self.budget {
348            Some(budget) => (self.reserved as i64).saturating_add(additional) <= budget,
349            None => true,
350        }
351    }
352
353    /// One sentence with the numbers, for a refusal.
354    pub fn describe(&self) -> String {
355        let budget = match self.budget {
356            Some(m) => format!("the account's storage budget is {} ({})", m, format_size(m)),
357            None => "the account has no storage limit of its own, so the ceiling is the \
358                     server's max_file_store"
359                .to_owned(),
360        };
361        format!(
362            "{budget}; {} ({}) is already reserved by existing streams' max_bytes while only \
363             {} ({}) is actually used",
364            self.reserved,
365            format_size(self.reserved as i64),
366            self.used,
367            format_size(self.used as i64)
368        )
369    }
370}
371
372/// A connected adapter for one team.
373#[derive(Clone)]
374pub struct JetStreamBackend {
375    context: jetstream::Context,
376    team_id: Uuid,
377    stream: String,
378}
379
380impl JetStreamBackend {
381    pub const NAME: &'static str = "jetstream";
382
383    /// Connect and adopt the team's stream. Does **not** create it: that is
384    /// `provision`, an operator action with a different credential.
385    pub async fn connect(config: &Config, team_id: Uuid) -> BusResult<Self> {
386        let client = connect_client(config).await?;
387        let context = jetstream::new(client);
388        let stream = stream_name(team_id);
389        // Fail here rather than at the first publish: an unprovisioned team
390        // routed to JetStream is a configuration mistake, and it should say
391        // so at startup.
392        context.get_stream(&stream).await.map_err(|e| {
393            BusError::invalid(format!(
394                "team {team_id} is routed to JetStream but stream '{stream}' does not exist \
395                 or this credential cannot see it ({e}). Provision it first; the runtime \
396                 credential deliberately cannot create streams."
397            ))
398        })?;
399        Ok(Self {
400            context,
401            team_id,
402            stream,
403        })
404    }
405
406    /// The body stream's configuration, as `provision` creates it and
407    /// `update_quotas` rewrites it: one place, so the two never drift.
408    fn bodies_config(
409        team_id: Uuid,
410        max_messages: i64,
411        max_bytes: i64,
412    ) -> jetstream::stream::Config {
413        jetstream::stream::Config {
414            name: stream_name(team_id),
415            subjects: vec![subject_filter(team_id)],
416            // File-backed, bounded, and refusing new writes when full
417            // rather than silently dropping the oldest history.
418            storage: jetstream::stream::StorageType::File,
419            retention: jetstream::stream::RetentionPolicy::Limits,
420            discard: jetstream::stream::DiscardPolicy::New,
421            max_messages,
422            max_bytes,
423            max_message_size: MAX_BROKER_MESSAGE_BYTES as i32,
424            // History reads go straight to the stream rather than through
425            // a consumer: a body fetched by locator is a point read, not a
426            // subscription, and a consumer per read would be a consumer per
427            // read.
428            allow_direct: true,
429            ..Default::default()
430        }
431    }
432
433    /// The inbox stream's configuration; see `bodies_config`.
434    fn inbox_config(team_id: Uuid, max_messages: i64, max_bytes: i64) -> jetstream::stream::Config {
435        jetstream::stream::Config {
436            name: inbox_stream_name(team_id),
437            subjects: vec![inbox_filter(team_id)],
438            storage: jetstream::stream::StorageType::File,
439            // A reference is work: it is removed when its recipient
440            // acknowledges it. With one exact subject per recipient and one
441            // consumer on it, two recipients can never compete for each
442            // other's references.
443            retention: jetstream::stream::RetentionPolicy::WorkQueue,
444            discard: jetstream::stream::DiscardPolicy::Old,
445            max_age: std::time::Duration::from_secs(INBOX_MAX_AGE_SECS),
446            max_messages,
447            max_bytes,
448            allow_direct: true,
449            ..Default::default()
450        }
451    }
452
453    fn stream_config(
454        config: &Config,
455        team_id: Uuid,
456        kind: StreamKind,
457    ) -> jetstream::stream::Config {
458        match kind {
459            StreamKind::Bodies => {
460                Self::bodies_config(team_id, config.max_messages, config.max_bytes)
461            }
462            StreamKind::Inbox => {
463                Self::inbox_config(team_id, config.inbox_max_messages, config.inbox_max_bytes)
464            }
465        }
466    }
467
468    /// Create a team's body stream, or find it. Operator action: run with
469    /// the provisioning credential, not the one the server runs with.
470    ///
471    /// An existing stream **keeps its limits**, whatever `config` asks for:
472    /// a routine retry must never shrink or grow a quota on the quiet.
473    /// [`Provisioned::created`] and [`Provisioned::differs_from`] tell the
474    /// caller what happened, and [`Self::update_quotas`] is the explicit
475    /// way to change them.
476    pub async fn provision(config: &Config, team_id: Uuid) -> BusResult<Provisioned> {
477        Self::provision_kind(config, team_id, StreamKind::Bodies).await
478    }
479
480    /// Create or find a team's inbox stream. Same operator action and same
481    /// credential as `provision`; separate so a deployment can see what
482    /// each stream costs.
483    pub async fn provision_inbox(config: &Config, team_id: Uuid) -> BusResult<Provisioned> {
484        Self::provision_kind(config, team_id, StreamKind::Inbox).await
485    }
486
487    async fn provision_kind(
488        config: &Config,
489        team_id: Uuid,
490        kind: StreamKind,
491    ) -> BusResult<Provisioned> {
492        config.validate_quotas()?;
493        let client = connect_client(config).await?;
494        let context = jetstream::new(client);
495        let wanted = Self::stream_config(config, team_id, kind);
496        let name = wanted.name.clone();
497        let requested_bytes = wanted.max_bytes;
498        // Existing: found, limits untouched. Missing: created with the
499        // requested limits. `get_or_create_stream` is exactly that.
500        let existed = context.get_stream(&name).await.is_ok();
501        let mut stream = match context.get_or_create_stream(wanted).await {
502            Ok(stream) => stream,
503            Err(e) => {
504                return Err(
505                    provisioning_error(&context, &name, "create", requested_bytes, e).await,
506                );
507            }
508        };
509        let info = stream
510            .info()
511            .await
512            .map_err(|e| BusError::invalid(format!("could not read '{name}' back: {e}")))?;
513        Ok(Provisioned {
514            name,
515            created: !existed,
516            max_messages: info.config.max_messages,
517            max_bytes: info.config.max_bytes,
518            messages: info.state.messages,
519            bytes: info.state.bytes,
520        })
521    }
522
523    /// What changing a stream's quotas would do, without doing it: the
524    /// limits it has now (what a rollback restores), what it holds, and the
525    /// extra bytes the broker would have to reserve. Refused when the
526    /// stream already holds more than the new ceiling would allow, since a
527    /// ceiling below the current contents would make the next write fail
528    /// (bodies) or start dropping (references) at once.
529    pub async fn check_update(
530        config: &Config,
531        team_id: Uuid,
532        kind: StreamKind,
533    ) -> BusResult<QuotaChange> {
534        config.validate_quotas()?;
535        let client = connect_client(config).await?;
536        let context = jetstream::new(client);
537        let wanted = Self::stream_config(config, team_id, kind);
538        let name = wanted.name.clone();
539        let mut current = context.get_stream(&name).await.map_err(|e| {
540            BusError::invalid(format!(
541                "stream '{name}' does not exist ({e}); provision it first, quotas are set at \
542                 creation and changed here"
543            ))
544        })?;
545        let info = current
546            .info()
547            .await
548            .map_err(|e| BusError::invalid(format!("could not read '{name}': {e}")))?;
549        if (info.state.messages as i64) > wanted.max_messages {
550            return Err(BusError::invalid(format!(
551                "stream '{name}' holds {} messages, more than the requested ceiling of {}; \
552                 nothing was changed. Prune first (`team prune`) or ask for a higher count",
553                info.state.messages, wanted.max_messages
554            )));
555        }
556        if (info.state.bytes as i64) > wanted.max_bytes {
557            return Err(BusError::invalid(format!(
558                "stream '{name}' holds {} ({}), more than the requested ceiling of {} ({}); \
559                 nothing was changed. Prune first (`team prune`) or ask for a higher quota",
560                info.state.bytes,
561                format_size(info.state.bytes as i64),
562                wanted.max_bytes,
563                format_size(wanted.max_bytes)
564            )));
565        }
566        Ok(QuotaChange {
567            name,
568            kind,
569            current_max_messages: info.config.max_messages,
570            current_max_bytes: info.config.max_bytes,
571            wanted_max_messages: wanted.max_messages,
572            wanted_max_bytes: wanted.max_bytes,
573            messages: info.state.messages,
574            bytes: info.state.bytes,
575        })
576    }
577
578    /// Change an existing stream's quotas to what `config` asks for: the
579    /// explicit counterpart of the retry that keeps them. Runs
580    /// [`Self::check_update`] first. The check is a snapshot: a publisher can
581    /// add to the stream between it and the update, so the outcome carries
582    /// the contents read back afterwards and the caller says so when they
583    /// exceed the new ceiling. Neither stream loses data to that race: the
584    /// body stream discards **new** writes when full, and an inbox reference
585    /// the broker drops is rebuilt from Postgres, which is its authority.
586    pub async fn update_quotas(
587        config: &Config,
588        team_id: Uuid,
589        kind: StreamKind,
590    ) -> BusResult<Provisioned> {
591        let change = Self::check_update(config, team_id, kind).await?;
592        Self::apply_update(config, team_id, kind, &change).await
593    }
594
595    /// Apply a change that [`Self::check_update`] already vetted.
596    pub async fn apply_update(
597        config: &Config,
598        team_id: Uuid,
599        kind: StreamKind,
600        change: &QuotaChange,
601    ) -> BusResult<Provisioned> {
602        let client = connect_client(config).await?;
603        let context = jetstream::new(client);
604        let wanted = Self::stream_config(config, team_id, kind);
605        let name = wanted.name.clone();
606        let info = match context.update_stream(wanted).await {
607            Ok(info) => info,
608            Err(e) => {
609                return Err(provisioning_error(
610                    &context,
611                    &name,
612                    "update",
613                    change.additional_bytes(),
614                    e,
615                )
616                .await);
617            }
618        };
619        Ok(Provisioned {
620            name,
621            created: false,
622            max_messages: info.config.max_messages,
623            max_bytes: info.config.max_bytes,
624            messages: info.state.messages,
625            bytes: info.state.bytes,
626        })
627    }
628
629    /// The broker's storage account as it stands: bytes used, bytes
630    /// reserved by every stream's `max_bytes`, and the store's budget
631    /// (`None` when the account is unlimited). Lets a caller refuse a
632    /// change that could not be reserved before touching anything.
633    pub async fn storage_account(config: &Config) -> BusResult<StorageAccount> {
634        let client = connect_client(config).await?;
635        let context = jetstream::new(client);
636        let account = context
637            .query_account()
638            .await
639            .map_err(|e| BusError::invalid(format!("could not read the broker's account: {e}")))?;
640        Ok(StorageAccount {
641            used: account.storage,
642            reserved: account.reserved_storage,
643            budget: account.limits.max_storage.filter(|m| *m > 0),
644        })
645    }
646
647    /// Publish one reference to a recipient's own subject.
648    ///
649    /// Deduplicated on the event id, so a retry of the same reference is
650    /// the same notification rather than a second one.
651    pub async fn publish_reference(
652        &self,
653        recipient_key: &str,
654        event_id: Uuid,
655        payload: &str,
656    ) -> Published {
657        let mut headers = async_nats::HeaderMap::new();
658        headers.insert(MSG_ID_HEADER, event_id.to_string().as_str());
659        headers.insert("Acs-Team-Id", self.team_id.to_string().as_str());
660        let ack = self
661            .context
662            .publish_with_headers(
663                inbox_subject(self.team_id, recipient_key),
664                headers,
665                payload.to_owned().into(),
666            )
667            .await;
668        let ack = match ack {
669            Ok(ack) => ack,
670            Err(e) => return classify(&e.to_string()),
671        };
672        match ack.await {
673            Ok(ack) => Published::Confirmed(Locator(format!(
674                "jetstream:{}:{}",
675                ack.stream, ack.sequence
676            ))),
677            Err(e) => classify(&e.to_string()),
678        }
679    }
680
681    /// Adopt (creating if needed) the durable pull consumer for one
682    /// recipient. One consumer, one exact subject, explicit acknowledgement.
683    async fn inbox_consumer(
684        &self,
685        recipient_key: &str,
686    ) -> BusResult<jetstream::consumer::Consumer<jetstream::consumer::pull::Config>> {
687        let stream = self
688            .context
689            .get_stream(inbox_stream_name(self.team_id))
690            .await
691            .map_err(|e| {
692                BusError::invalid(format!(
693                    "this team's inbox stream is not provisioned or is unreachable ({e}). \
694                     Run `ai-crew-sync team stream --team <team> --nats-url <url>`; \
695                     references are still in Postgres meanwhile."
696                ))
697            })?;
698        let durable = format!("IN_{recipient_key}");
699        stream
700            .get_or_create_consumer(
701                &durable,
702                jetstream::consumer::pull::Config {
703                    durable_name: Some(durable.clone()),
704                    filter_subject: inbox_subject(self.team_id, recipient_key),
705                    ack_policy: jetstream::consumer::AckPolicy::Explicit,
706                    ack_wait: std::time::Duration::from_secs(INBOX_ACK_WAIT_SECS),
707                    max_deliver: INBOX_MAX_DELIVER,
708                    max_ack_pending: INBOX_MAX_ACK_PENDING,
709                    ..Default::default()
710                },
711            )
712            .await
713            .map_err(|e| BusError::invalid(format!("could not open the inbox: {e}")))
714    }
715
716    /// Take up to `limit` references without acknowledging any of them.
717    /// Acknowledgement happens only once the receipt is committed.
718    pub async fn fetch_references(
719        &self,
720        recipient_key: &str,
721        limit: usize,
722    ) -> BusResult<Vec<InboxRef>> {
723        use futures::StreamExt;
724        let consumer = self.inbox_consumer(recipient_key).await?;
725        let mut batch = consumer
726            .fetch()
727            .max_messages(limit)
728            .messages()
729            .await
730            .map_err(|e| BusError::invalid(format!("could not read the inbox: {e}")))?;
731        let mut out = Vec::new();
732        while let Some(message) = batch.next().await {
733            let message =
734                message.map_err(|e| BusError::invalid(format!("inbox read failed: {e}")))?;
735            let info = message.info().ok();
736            out.push(InboxRef {
737                payload: String::from_utf8_lossy(&message.payload).into_owned(),
738                ack_subject: message
739                    .reply
740                    .as_ref()
741                    .map(|s| s.to_string())
742                    .unwrap_or_default(),
743                stream_seq: info.as_ref().map(|i| i.stream_sequence).unwrap_or(0),
744                deliveries: info.as_ref().map(|i| i.delivered as u64).unwrap_or(1),
745            });
746        }
747        Ok(out)
748    }
749
750    /// Acknowledge one reference by the subject it was offered on. Sent
751    /// after the receipt has been committed, never before.
752    pub async fn ack_reference(&self, ack_subject: &str) -> BusResult<()> {
753        if ack_subject.is_empty() {
754            return Ok(());
755        }
756        let client = self.context.client();
757        client
758            .publish(ack_subject.to_owned(), bytes::Bytes::from_static(b"+ACK"))
759            .await
760            .map_err(|e| BusError::invalid(format!("could not acknowledge: {e}")))?;
761        client
762            .flush()
763            .await
764            .map_err(|e| BusError::invalid(format!("could not acknowledge: {e}")))?;
765        Ok(())
766    }
767
768    /// What this recipient's consumer holds. A missing consumer is a fact,
769    /// not an empty inbox.
770    pub async fn inbox_status(&self, recipient_key: &str) -> BusResult<InboxStatus> {
771        let stream = match self
772            .context
773            .get_stream(inbox_stream_name(self.team_id))
774            .await
775        {
776            Ok(stream) => stream,
777            // A stream that is not there is a fact. A broker that cannot be
778            // reached is a different one, and reporting an empty cache
779            // during an outage is how an operator concludes the inbox is
780            // drained when it is not.
781            Err(e) if e.to_string().contains("not found") => return Ok(InboxStatus::default()),
782            Err(e) => {
783                return Err(BusError::invalid(format!(
784                    "could not read the inbox stream: {e}"
785                )));
786            }
787        };
788        let durable = format!("IN_{recipient_key}");
789        let mut consumer = match stream
790            .get_consumer::<jetstream::consumer::pull::Config>(&durable)
791            .await
792        {
793            Ok(consumer) => consumer,
794            Err(e) if e.to_string().contains("not found") => return Ok(InboxStatus::default()),
795            Err(e) => {
796                return Err(BusError::invalid(format!(
797                    "could not read this window's consumer: {e}"
798                )));
799            }
800        };
801        let info = consumer
802            .info()
803            .await
804            .map_err(|e| BusError::invalid(format!("could not read the inbox state: {e}")))?;
805        Ok(InboxStatus {
806            pending: info.num_pending,
807            awaiting_ack: info.num_ack_pending as u64,
808            redelivered: info.num_redelivered as u64,
809            present: true,
810        })
811    }
812
813    /// Remove a team's stream. Operator action, and a destructive one: it
814    /// drops every body the stream holds.
815    pub async fn deprovision(config: &Config, team_id: Uuid) -> BusResult<()> {
816        let client = connect_client(config).await?;
817        let context = jetstream::new(client);
818        // Both streams, and both report. An operator told "removed" while
819        // authorization was refused believes the data is gone.
820        for name in [stream_name(team_id), inbox_stream_name(team_id)] {
821            match context.delete_stream(&name).await {
822                Ok(_) => {}
823                // Already gone is the outcome the caller asked for.
824                Err(e) if e.to_string().contains("not found") => {}
825                Err(e) => {
826                    return Err(BusError::invalid(format!(
827                        "could not delete '{name}' ({e}). The stream and its contents are \
828                         still there."
829                    )));
830                }
831            }
832        }
833        Ok(())
834    }
835
836    pub fn stream(&self) -> &str {
837        &self.stream
838    }
839
840    /// A sequence in **this** team's stream, or a refusal.
841    ///
842    /// The stream name is checked, not discarded. A forged
843    /// `jetstream:<someone-elses-stream>:<n>` would otherwise be read as
844    /// sequence n of this one, and the envelope check would pass whenever
845    /// that sequence happened to be ours.
846    fn parse_own_locator(&self, raw: &str) -> BusResult<u64> {
847        let (stream, sequence) = parse_locator(raw)?;
848        if stream != self.stream {
849            return Err(BusError::Forbidden(
850                "that locator names another stream".to_owned(),
851            ));
852        }
853        Ok(sequence)
854    }
855
856    /// Can this process reach the broker at all? For health checks, which
857    /// is why it opens and drops a connection rather than reusing a
858    /// team's: it answers a question about the deployment, not about a
859    /// stream.
860    pub async fn reachable(config: &Config) -> bool {
861        connect_client(config).await.is_ok()
862    }
863}
864
865/// Turn a create or update failure into something an operator can act on.
866/// The one that bites in practice is 10047, "insufficient storage
867/// resources": the broker reserves every stream's `max_bytes` against its
868/// `max_file_store` when the stream is created or enlarged, so a store can
869/// be almost empty and still refuse. Say so, with the numbers when the
870/// account answers and with an honest "unavailable" when it does not.
871/// `additional_bytes` is what the operation asked the broker to reserve on
872/// top of what it already had: the whole quota for a new stream, the
873/// difference for an update.
874async fn provisioning_error(
875    context: &jetstream::Context,
876    name: &str,
877    operation: &str,
878    additional_bytes: i64,
879    error: jetstream::context::CreateStreamError,
880) -> BusError {
881    let exhausted = matches!(
882        error.kind(),
883        jetstream::context::CreateStreamErrorKind::JetStream(e)
884            if e.error_code() == jetstream::ErrorCode::STORAGE_RESOURCES_EXCEEDED
885    );
886    if !exhausted {
887        return BusError::invalid(format!("could not {operation} '{name}': {error}"));
888    }
889    let account = match context.query_account().await {
890        Ok(a) => StorageAccount {
891            used: a.storage,
892            reserved: a.reserved_storage,
893            budget: a.limits.max_storage.filter(|m| *m > 0),
894        }
895        .describe(),
896        Err(e) => format!(
897            "the broker's account statistics are unavailable ({e}), so the reserved and used \
898             figures cannot be shown; `nats account info` on the broker has them"
899        ),
900    };
901    BusError::invalid(format!(
902        "could not {operation} '{name}': the broker cannot reserve {} ({}) more. Reservation, \
903         not disk, is what ran out: {account}. Ask for a smaller quota (--max-bytes / \
904         --inbox-max-bytes), lower another stream's quota (`team stream --update-quotas`) or \
905         remove one, or raise the broker's max_file_store.",
906        additional_bytes,
907        format_size(additional_bytes)
908    ))
909}
910
911async fn connect_client(config: &Config) -> BusResult<async_nats::Client> {
912    let options = match &config.credentials {
913        Some(path) => {
914            async_nats::ConnectOptions::with_credentials_file(std::path::PathBuf::from(path))
915                .await
916                .map_err(|e| BusError::invalid(format!("could not read NATS credentials: {e}")))?
917        }
918        None => async_nats::ConnectOptions::new(),
919    };
920    options
921        // Reconnect is the client's job and it does it; what matters here is
922        // that a publish which cannot reach the broker fails *retryably*
923        // rather than blocking a worker for ever.
924        .request_timeout(Some(std::time::Duration::from_secs(10)))
925        .connect(&config.url)
926        .await
927        .map_err(|e| BusError::invalid(format!("could not reach NATS at {}: {e}", config.url)))
928}
929
930impl MessagingBackend for JetStreamBackend {
931    fn name(&self) -> &'static str {
932        Self::NAME
933    }
934
935    async fn publish(&self, envelope: Envelope) -> Published {
936        // The subject comes from this adapter's team and the header from
937        // the envelope's. If they disagree, publishing would write one
938        // team's body into another's stream, where the team it belongs to
939        // could never read it.
940        if envelope.team_id != self.team_id {
941            return Published::Fatal(format!(
942                "this adapter serves team {} and the envelope is for {}",
943                self.team_id, envelope.team_id
944            ));
945        }
946        let subject = subject(self.team_id, envelope.conversation_id);
947        let mut headers = async_nats::HeaderMap::new();
948        // The broker's own deduplication, inside its window; ours covers the
949        // rest, which is why the same key is used for both.
950        headers.insert(MSG_ID_HEADER, envelope.publish_key.to_string().as_str());
951        headers.insert("Acs-Message-Id", envelope.message_id.to_string().as_str());
952        headers.insert(
953            "Acs-Conversation-Id",
954            envelope.conversation_id.to_string().as_str(),
955        );
956        headers.insert("Acs-Team-Id", envelope.team_id.to_string().as_str());
957
958        let body_len = envelope.body.len() as i64;
959        if body_len > MAX_BROKER_MESSAGE_BYTES {
960            return Published::Fatal(format!(
961                "body is {body_len} bytes; this broker accepts {MAX_BROKER_MESSAGE_BYTES}"
962            ));
963        }
964
965        let ack = self
966            .context
967            .publish_with_headers(subject, headers, envelope.body.into())
968            .await;
969        let ack = match ack {
970            Ok(ack) => ack,
971            Err(e) => return classify(&e.to_string()),
972        };
973        // Await the PubAck: a publish that has not been acknowledged is not
974        // stored, and reporting it as such is the mistake this whole phase
975        // exists to avoid.
976        match ack.await {
977            Ok(ack) => Published::Confirmed(Locator(format!(
978                "jetstream:{}:{}",
979                ack.stream, ack.sequence
980            ))),
981            Err(e) => classify(&e.to_string()),
982        }
983    }
984
985    async fn fetch(&self, locator: &Locator, message_id: Uuid) -> BusResult<Option<String>> {
986        let sequence = self.parse_own_locator(&locator.0)?;
987        let stream = self
988            .context
989            .get_stream(&self.stream)
990            .await
991            .map_err(|e| BusError::invalid(format!("stream unavailable: {e}")))?;
992        match stream.direct_get(sequence).await {
993            Ok(message) => {
994                // The envelope's team is checked here, not trusted: a
995                // locator is opaque, and a caller that guessed one must not
996                // read another team's body.
997                let ours = message
998                    .headers
999                    .get("Acs-Team-Id")
1000                    .map(|v| v.as_str() == self.team_id.to_string())
1001                    .unwrap_or(false);
1002                if !ours {
1003                    return Err(BusError::Forbidden(
1004                        "that locator belongs to another team".to_owned(),
1005                    ));
1006                }
1007                // And the message the caller asked for. A sequence in the
1008                // right stream is not proof that it is the right body: a
1009                // locator from another conversation of the same team would
1010                // otherwise return whatever sits at that position.
1011                let expected = message
1012                    .headers
1013                    .get("Acs-Message-Id")
1014                    .map(|v| v.as_str() == message_id.to_string())
1015                    .unwrap_or(false);
1016                if !expected {
1017                    return Err(BusError::Forbidden(
1018                        "that locator names another message".to_owned(),
1019                    ));
1020                }
1021                Ok(Some(String::from_utf8_lossy(&message.payload).into_owned()))
1022            }
1023            Err(e) if e.to_string().contains("not found") => Ok(None),
1024            Err(e) => Err(BusError::invalid(format!("could not read the body: {e}"))),
1025        }
1026    }
1027
1028    async fn retain(&self, _before: chrono::DateTime<chrono::Utc>) -> BusResult<u64> {
1029        // Retention is the stream's own policy, set at provisioning: limits
1030        // by count and bytes, discarding new writes when full rather than
1031        // dropping history behind the operator's back. Nothing to do per
1032        // call, and deleting messages here would fight that policy.
1033        Ok(0)
1034    }
1035
1036    async fn reconcile(&self, envelope: &Envelope) -> BusResult<Option<Locator>> {
1037        // Present the real envelope again under the same `Nats-Msg-Id`.
1038        // Inside the deduplication window the broker recognises it and
1039        // answers with the original sequence; outside it, the body lands
1040        // now. Both answers are a canonical locator for one logical
1041        // message, which is what the caller needs.
1042        //
1043        // The alternative — a small probe carrying the key — is worse than
1044        // useless. Deduplication is per stream, so a probe that could
1045        // answer at all is a probe that was stored, it consumes the bounded
1046        // stream, and it takes the key the real body needed. The locator
1047        // then names an empty message and the body never lands at all.
1048        match self.publish(envelope.clone()).await {
1049            Published::Confirmed(locator) => Ok(Some(locator)),
1050            // Still no answer, or a refusal. Nothing is claimed either way;
1051            // the slot stays and the ordinary path retries or gives up.
1052            Published::Retryable(_) | Published::Fatal(_) => Ok(None),
1053        }
1054    }
1055}
1056
1057/// `jetstream:<stream>:<sequence>`, parsed into its parts.
1058fn parse_locator(raw: &str) -> BusResult<(&str, u64)> {
1059    let mut parts = raw.split(':');
1060    let (Some("jetstream"), Some(stream), Some(sequence), None) =
1061        (parts.next(), parts.next(), parts.next(), parts.next())
1062    else {
1063        return Err(BusError::invalid("not a locator this backend issued"));
1064    };
1065    let sequence = sequence
1066        .parse::<u64>()
1067        .map_err(|_| BusError::invalid("not a locator this backend issued"))?;
1068    Ok((stream, sequence))
1069}
1070
1071/// Tell apart "try again" from "this will never work". Getting this wrong in
1072/// either direction is expensive: a fatal error retried for ever, or a
1073/// transient one that loses a message.
1074fn classify(error: &str) -> Published {
1075    let lower = error.to_lowercase();
1076    if lower.contains("maximum messages")
1077        || lower.contains("maximum bytes")
1078        || lower.contains("message size exceeds")
1079        // The server-wide payload ceiling. Retrying a message the broker is
1080        // configured never to accept is a loop, not a recovery.
1081        || lower.contains("max payload size exceeded")
1082        || lower.contains("too large")
1083        || lower.contains("authorization")
1084        || lower.contains("permissions violation")
1085        || lower.contains("no responders")
1086    {
1087        Published::Fatal(error.to_owned())
1088    } else {
1089        Published::Retryable(error.to_owned())
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    #[test]
1098    fn sizes_parse_in_binary_units_and_refuse_nonsense() {
1099        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1100        assert_eq!(parse_size("64MiB").unwrap(), 64 * 1024 * 1024);
1101        assert_eq!(parse_size("64 mib").unwrap(), 64 * 1024 * 1024);
1102        assert_eq!(parse_size("2G").unwrap(), 2 * 1024 * 1024 * 1024);
1103        assert_eq!(parse_size("512kb").unwrap(), 512 * 1024);
1104        assert!(parse_size("").unwrap_err().contains("not a size"));
1105        assert!(parse_size("MiB").unwrap_err().contains("not a size"));
1106        assert!(
1107            parse_size("12 parsecs")
1108                .unwrap_err()
1109                .contains("unknown unit")
1110        );
1111        assert!(
1112            parse_size("99999999999999999999")
1113                .unwrap_err()
1114                .contains("too large")
1115        );
1116        assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2 GiB");
1117        assert_eq!(format_size(256 * 1024 * 1024), "256 MiB");
1118        assert_eq!(format_size(1536 * 1024), "1536 KiB");
1119        assert_eq!(format_size(5_289_810), "5.0 MiB");
1120    }
1121
1122    #[test]
1123    fn quotas_are_validated_before_the_broker_is_asked() {
1124        let ok = Config::new("nats://x").with_limits(10, 4 * 1024 * 1024);
1125        assert!(ok.validate_quotas().is_ok());
1126        let tiny = Config::new("nats://x").with_limits(10, MAX_BROKER_MESSAGE_BYTES - 1);
1127        let err = tiny.validate_quotas().unwrap_err().to_string();
1128        assert!(err.contains("--max-bytes must be at least"), "{err}");
1129        let inbox = Config::new("nats://x").with_inbox_limits(10, MIN_INBOX_BYTES - 1);
1130        let err = inbox.validate_quotas().unwrap_err().to_string();
1131        assert!(err.contains("--inbox-max-bytes"), "{err}");
1132        let none = Config::new("nats://x").with_limits(0, 4 * 1024 * 1024);
1133        let err = none.validate_quotas().unwrap_err().to_string();
1134        assert!(err.contains("at least 1"), "{err}");
1135        let defaults = Config::new("nats://x");
1136        assert_eq!(defaults.inbox_max_bytes, DEFAULT_INBOX_MAX_BYTES);
1137        assert!(defaults.inbox_max_bytes < defaults.max_bytes);
1138    }
1139
1140    #[test]
1141    fn names_are_opaque_and_stable() {
1142        let team = Uuid::nil();
1143        assert_eq!(stream_name(team), "ACS_T_00000000000000000000000000000000");
1144        assert!(
1145            subject(team, Uuid::nil()).starts_with("acs.00000000"),
1146            "a subject never carries a slug a team could rename"
1147        );
1148        assert!(subject_filter(team).ends_with(".>"));
1149    }
1150
1151    #[test]
1152    fn a_locator_round_trips_and_a_forged_one_is_refused() {
1153        assert_eq!(
1154            parse_locator("jetstream:ACS_T_x:42").unwrap(),
1155            ("ACS_T_x", 42)
1156        );
1157        assert!(parse_locator("nonsense").is_err());
1158        assert!(
1159            parse_locator("ACS_T_x:42").is_err(),
1160            "the prefix is checked"
1161        );
1162        assert!(
1163            parse_locator("jetstream:ACS_T_x:42:extra").is_err(),
1164            "a locator has three parts and no more"
1165        );
1166    }
1167
1168    #[test]
1169    fn full_and_refused_are_fatal_while_a_timeout_is_not() {
1170        assert!(matches!(
1171            classify("maximum messages exceeded"),
1172            Published::Fatal(_)
1173        ));
1174        assert!(matches!(
1175            classify("permissions violation for publish"),
1176            Published::Fatal(_)
1177        ));
1178        assert!(matches!(
1179            classify("max payload size exceeded: Payload size limit of 1048576 exceeded"),
1180            Published::Fatal(_)
1181        ));
1182        assert!(matches!(classify("timed out"), Published::Retryable(_)));
1183        assert!(matches!(
1184            classify("connection reset by peer"),
1185            Published::Retryable(_)
1186        ));
1187    }
1188}