1use async_nats::jetstream;
30use uuid::Uuid;
31
32use crate::{
33 error::{BusError, BusResult},
34 store::backend::{Envelope, Locator, MessagingBackend, Published},
35};
36
37pub const MSG_ID_HEADER: &str = "Nats-Msg-Id";
42
43pub const MAX_BROKER_MESSAGE_BYTES: i64 = 2 * 1024 * 1024;
53
54pub const DEFAULT_MAX_MESSAGES: i64 = 100_000;
61pub const DEFAULT_MAX_BYTES: i64 = 2 * 1024 * 1024 * 1024;
62pub const DEFAULT_INBOX_MAX_MESSAGES: i64 = 100_000;
66pub const DEFAULT_INBOX_MAX_BYTES: i64 = 256 * 1024 * 1024;
67
68pub fn stream_name(team_id: Uuid) -> String {
71 format!("ACS_T_{}", team_id.simple())
72}
73
74pub fn subject(team_id: Uuid, conversation_id: Uuid) -> String {
76 format!("acs.{}.conv.{}", team_id.simple(), conversation_id.simple())
77}
78
79pub fn subject_filter(team_id: Uuid) -> String {
81 format!("acs.{}.>", team_id.simple())
82}
83
84pub fn inbox_stream_name(team_id: Uuid) -> String {
90 format!("ACS_I_{}", team_id.simple())
91}
92
93pub 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
103pub const INBOX_MAX_AGE_SECS: u64 = 7 * 24 * 3600;
107pub const INBOX_MAX_DELIVER: i64 = 5;
110pub const INBOX_MAX_ACK_PENDING: i64 = 256;
112pub const INBOX_ACK_WAIT_SECS: u64 = 60;
115
116#[derive(Clone, Debug)]
118pub struct InboxRef {
119 pub payload: String,
120 pub ack_subject: String,
124 pub stream_seq: u64,
125 pub deliveries: u64,
128}
129
130#[derive(Clone, Debug, Default, serde::Serialize)]
132pub struct InboxStatus {
133 pub pending: u64,
134 pub awaiting_ack: u64,
135 pub redelivered: u64,
136 pub present: bool,
140}
141
142#[derive(Clone, Debug)]
145pub struct Config {
146 pub url: String,
149 pub credentials: Option<String>,
152 pub max_messages: i64,
157 pub max_bytes: i64,
158 pub inbox_max_messages: i64,
160 pub inbox_max_bytes: i64,
161}
162
163impl Config {
164 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 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 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 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
224pub const MIN_INBOX_BYTES: i64 = 1024 * 1024;
226
227pub 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
260pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum StreamKind {
283 Bodies,
284 Inbox,
285}
286
287#[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 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 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#[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 pub fn additional_bytes(&self) -> i64 {
332 (self.wanted_max_bytes - self.current_max_bytes).max(0)
333 }
334}
335
336#[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 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 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#[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 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 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 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 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 allow_direct: true,
429 ..Default::default()
430 }
431 }
432
433 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 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 pub async fn provision(config: &Config, team_id: Uuid) -> BusResult<Provisioned> {
477 Self::provision_kind(config, team_id, StreamKind::Bodies).await
478 }
479
480 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn deprovision(config: &Config, team_id: Uuid) -> BusResult<()> {
816 let client = connect_client(config).await?;
817 let context = jetstream::new(client);
818 for name in [stream_name(team_id), inbox_stream_name(team_id)] {
821 match context.delete_stream(&name).await {
822 Ok(_) => {}
823 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 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 pub async fn reachable(config: &Config) -> bool {
861 connect_client(config).await.is_ok()
862 }
863}
864
865async 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 .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 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 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 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 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 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 Ok(0)
1034 }
1035
1036 async fn reconcile(&self, envelope: &Envelope) -> BusResult<Option<Locator>> {
1037 match self.publish(envelope.clone()).await {
1049 Published::Confirmed(locator) => Ok(Some(locator)),
1050 Published::Retryable(_) | Published::Fatal(_) => Ok(None),
1053 }
1054 }
1055}
1056
1057fn 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
1071fn 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 || 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}