1use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Instant;
10
11use bamboo_agent_core::storage::Storage;
12use bamboo_domain::{
13 Session, SessionActivationDisposition, SessionActivationError, SessionActivationPolicy,
14 SessionActivationPort, SessionInboxError, SessionInboxPort, SessionInboxReceipt,
15 SessionMessageEnvelope, SessionMessageSource,
16};
17
18#[derive(Debug, Default)]
20pub struct SessionMessagingMetrics {
21 delivered: AtomicU64,
22 rejected: AtomicU64,
23 invalid_envelope: AtomicU64,
24 unauthorized: AtomicU64,
25 payload_too_large: AtomicU64,
26 backlog_full: AtomicU64,
27 storage_failed: AtomicU64,
28 activation_failed: AtomicU64,
29 active_notified: AtomicU64,
30 activation_reserved: AtomicU64,
31 activation_coalesced: AtomicU64,
32 delivery_latency_micros: AtomicU64,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct SessionMessagingMetricsSnapshot {
37 pub delivered: u64,
38 pub rejected: u64,
39 pub invalid_envelope: u64,
40 pub unauthorized: u64,
41 pub payload_too_large: u64,
42 pub backlog_full: u64,
43 pub storage_failed: u64,
44 pub activation_failed: u64,
45 pub active_notified: u64,
46 pub activation_reserved: u64,
47 pub activation_coalesced: u64,
48 pub delivery_latency_micros: u64,
49}
50
51impl SessionMessagingMetrics {
52 pub fn snapshot(&self) -> SessionMessagingMetricsSnapshot {
53 SessionMessagingMetricsSnapshot {
54 delivered: self.delivered.load(Ordering::Relaxed),
55 rejected: self.rejected.load(Ordering::Relaxed),
56 invalid_envelope: self.invalid_envelope.load(Ordering::Relaxed),
57 unauthorized: self.unauthorized.load(Ordering::Relaxed),
58 payload_too_large: self.payload_too_large.load(Ordering::Relaxed),
59 backlog_full: self.backlog_full.load(Ordering::Relaxed),
60 storage_failed: self.storage_failed.load(Ordering::Relaxed),
61 activation_failed: self.activation_failed.load(Ordering::Relaxed),
62 active_notified: self.active_notified.load(Ordering::Relaxed),
63 activation_reserved: self.activation_reserved.load(Ordering::Relaxed),
64 activation_coalesced: self.activation_coalesced.load(Ordering::Relaxed),
65 delivery_latency_micros: self.delivery_latency_micros.load(Ordering::Relaxed),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SessionMessengerReceipt {
72 pub delivery: SessionInboxReceipt,
73 pub activation: SessionActivationDisposition,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SessionMessengerAdmission {
80 pub envelope_id: String,
81 pub target_session_id: String,
82 pub delivery: SessionInboxReceipt,
83}
84
85#[derive(Debug, thiserror::Error)]
86pub enum SessionMessengerError {
87 #[error("invalid session message: {0}")]
88 InvalidEnvelope(String),
89 #[error("session message source not found: {0}")]
90 SourceNotFound(String),
91 #[error("session message target not found: {0}")]
92 TargetNotFound(String),
93 #[error("session {source_session_id} is not authorized to message session {target}")]
94 Unauthorized {
95 source_session_id: String,
96 target: String,
97 },
98 #[error(transparent)]
99 Inbox(#[from] SessionInboxError),
100 #[error("message {receipt_id} was durably delivered but activation failed: {source}")]
104 Activation {
105 receipt_id: String,
106 receipt: SessionInboxReceipt,
107 #[source]
108 source: SessionActivationError,
109 },
110 #[error("session store failure: {0}")]
111 Storage(String),
112}
113
114pub struct SessionMessenger {
117 sessions: Arc<dyn Storage>,
118 inbox: Arc<dyn SessionInboxPort>,
119 activation: Arc<dyn SessionActivationPort>,
120 metrics: Arc<SessionMessagingMetrics>,
121}
122
123impl SessionMessenger {
124 pub fn new(
125 sessions: Arc<dyn Storage>,
126 inbox: Arc<dyn SessionInboxPort>,
127 activation: Arc<dyn SessionActivationPort>,
128 ) -> Self {
129 Self {
130 sessions,
131 inbox,
132 activation,
133 metrics: Arc::new(SessionMessagingMetrics::default()),
134 }
135 }
136
137 pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
138 &self.inbox
139 }
140
141 pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
142 &self.activation
143 }
144
145 pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
146 &self.metrics
147 }
148
149 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
150 self.sessions
151 .load_session(id)
152 .await
153 .map_err(|error| SessionMessengerError::Storage(error.to_string()))
154 }
155
156 fn logical_root(session: &Session) -> &str {
157 if session.root_session_id.trim().is_empty() {
158 &session.id
159 } else {
160 &session.root_session_id
161 }
162 }
163
164 async fn validate_relationship(
165 &self,
166 envelope: &SessionMessageEnvelope,
167 ) -> Result<(), SessionMessengerError> {
168 envelope
169 .validate()
170 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
171 let target = self
172 .load_session(&envelope.target_session_id)
173 .await?
174 .ok_or_else(|| {
175 SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
176 })?;
177
178 let SessionMessageSource::Session { session_id } = &envelope.source else {
179 return Ok(());
180 };
181 let source = self
182 .load_session(session_id)
183 .await?
184 .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
185
186 let same_root = Self::logical_root(&source) == Self::logical_root(&target);
187 let source_project = source.project_id_meta();
188 let target_project = target.project_id_meta();
189 let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
190 (Some(left), Some(right)) => left == right,
191 _ => true,
194 };
195 if !same_root || !project_compatible {
196 return Err(SessionMessengerError::Unauthorized {
197 source_session_id: source.id,
198 target: target.id,
199 });
200 }
201 Ok(())
202 }
203
204 fn record_rejection(&self, error: &SessionMessengerError) {
205 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
206 match error {
207 SessionMessengerError::InvalidEnvelope(_) => {
208 self.metrics
209 .invalid_envelope
210 .fetch_add(1, Ordering::Relaxed);
211 }
212 SessionMessengerError::Unauthorized { .. }
213 | SessionMessengerError::SourceNotFound(_)
214 | SessionMessengerError::TargetNotFound(_) => {
215 self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
216 }
217 SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
218 self.metrics
219 .payload_too_large
220 .fetch_add(1, Ordering::Relaxed);
221 }
222 SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
223 self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
224 }
225 SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
226 self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
227 }
228 SessionMessengerError::Activation { .. } => {}
229 }
230 }
231
232 pub async fn admit(
233 &self,
234 envelope: SessionMessageEnvelope,
235 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
236 let started = Instant::now();
237 if let Err(error) = self.validate_relationship(&envelope).await {
238 self.record_rejection(&error);
239 tracing::warn!(
240 message_id = %envelope.id,
241 target_session_id = %envelope.target_session_id,
242 error = %error,
243 "session message rejected"
244 );
245 return Err(error);
246 }
247
248 let delivery = match self.inbox.deliver(&envelope).await {
249 Ok(receipt) => receipt,
250 Err(error) => {
251 let error = SessionMessengerError::Inbox(error);
252 self.record_rejection(&error);
253 return Err(error);
254 }
255 };
256 self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
257 self.metrics.delivery_latency_micros.fetch_add(
258 started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
259 Ordering::Relaxed,
260 );
261 Ok(SessionMessengerAdmission {
262 envelope_id: envelope.id.as_str().to_string(),
263 target_session_id: envelope.target_session_id,
264 delivery,
265 })
266 }
267
268 pub async fn activate(
269 &self,
270 admission: &SessionMessengerAdmission,
271 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
272 if let Err(error) = self
273 .inbox
274 .mark_activation_eligible(
275 &admission.target_session_id,
276 admission.delivery.generation,
277 SessionActivationPolicy::InterruptSpecificWait,
278 )
279 .await
280 {
281 self.metrics
282 .activation_failed
283 .fetch_add(1, Ordering::Relaxed);
284 return Err(SessionMessengerError::Activation {
285 receipt_id: admission.delivery.id.to_string(),
286 receipt: admission.delivery.clone(),
287 source: SessionActivationError::Internal(format!(
288 "persist activation watermark: {error}"
289 )),
290 });
291 }
292 self.activate_prepared(admission).await
293 }
294
295 pub async fn activate_prepared(
298 &self,
299 admission: &SessionMessengerAdmission,
300 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
301 let activation = match self
302 .activation
303 .request_activation(&admission.target_session_id, admission.delivery.generation)
304 .await
305 {
306 Ok(disposition) => disposition,
307 Err(source) => {
308 self.metrics
309 .activation_failed
310 .fetch_add(1, Ordering::Relaxed);
311 tracing::error!(
312 message_id = %admission.envelope_id,
313 target_session_id = %admission.target_session_id,
314 generation = admission.delivery.generation,
315 error = %source,
316 "session message durable but activation failed"
317 );
318 return Err(SessionMessengerError::Activation {
319 receipt_id: admission.delivery.id.to_string(),
320 receipt: admission.delivery.clone(),
321 source,
322 });
323 }
324 };
325 match activation {
326 SessionActivationDisposition::ActiveNotified => {
327 self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
328 }
329 SessionActivationDisposition::ActivationReserved => {
330 self.metrics
331 .activation_reserved
332 .fetch_add(1, Ordering::Relaxed);
333 }
334 SessionActivationDisposition::ActivationCoalesced => {
335 self.metrics
336 .activation_coalesced
337 .fetch_add(1, Ordering::Relaxed);
338 }
339 }
340 tracing::info!(
341 message_id = %admission.envelope_id,
342 target_session_id = %admission.target_session_id,
343 generation = admission.delivery.generation,
344 ?activation,
345 "session message durably delivered"
346 );
347 Ok(SessionMessengerReceipt {
348 delivery: admission.delivery.clone(),
349 activation,
350 })
351 }
352
353 pub async fn prepare_activation(
362 &self,
363 admission: &SessionMessengerAdmission,
364 ) -> Result<(), SessionMessengerError> {
365 self.inbox
366 .mark_activation_eligible(
367 &admission.target_session_id,
368 admission.delivery.generation,
369 SessionActivationPolicy::RespectSpecificWait,
370 )
371 .await
372 .map_err(SessionMessengerError::Inbox)
373 }
374
375 pub async fn send(
376 &self,
377 envelope: SessionMessageEnvelope,
378 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
379 let admission = self.admit(envelope).await?;
380 self.activate(&admission).await
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use async_trait::async_trait;
388 use bamboo_domain::{
389 SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
390 SessionMessageContent, SessionMessageId, SessionMessageKind,
391 };
392 use bamboo_storage::{FileSessionInbox, SessionStoreV2};
393 use tempfile::TempDir;
394
395 struct RecordingActivation {
396 calls: tokio::sync::Mutex<Vec<(String, u64)>>,
397 }
398
399 #[async_trait]
400 impl SessionActivationPort for RecordingActivation {
401 async fn request_activation(
402 &self,
403 target_session_id: &str,
404 inbox_generation: u64,
405 ) -> Result<SessionActivationDisposition, SessionActivationError> {
406 self.calls
407 .lock()
408 .await
409 .push((target_session_id.to_string(), inbox_generation));
410 Ok(SessionActivationDisposition::ActivationReserved)
411 }
412 }
413
414 async fn fixture() -> (
415 TempDir,
416 Arc<SessionStoreV2>,
417 Arc<RecordingActivation>,
418 SessionMessenger,
419 ) {
420 let temp = TempDir::new().unwrap();
421 let store = Arc::new(
422 SessionStoreV2::new(temp.path().to_path_buf())
423 .await
424 .unwrap(),
425 );
426 let activation = Arc::new(RecordingActivation {
427 calls: tokio::sync::Mutex::new(Vec::new()),
428 });
429 let inbox = Arc::new(FileSessionInbox::new(
430 store.clone(),
431 SessionInboxLimits::default(),
432 ));
433 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
434 (temp, store, activation, messenger)
435 }
436
437 fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
438 SessionMessageEnvelope {
439 id: SessionMessageId::parse(id).unwrap(),
440 source: SessionMessageSource::Session {
441 session_id: source.to_string(),
442 },
443 target_session_id: target.to_string(),
444 kind: SessionMessageKind::PeerMessage,
445 body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
446 created_at: chrono::Utc::now(),
447 thread_id: None,
448 in_reply_to: None,
449 attempt: None,
450 correlation_id: None,
451 }
452 }
453
454 #[tokio::test]
455 async fn same_root_delivery_enqueues_then_requests_activation() {
456 let (_temp, store, activation, messenger) = fixture().await;
457 let root = Session::new("root", "model");
458 let mut child = Session::new("child", "model");
459 child.kind = bamboo_domain::SessionKind::Child;
460 child.parent_session_id = Some("root".to_string());
461 child.root_session_id = "root".to_string();
462 store.save_session(&root).await.unwrap();
463 store.save_session(&child).await.unwrap();
464
465 let receipt = messenger
466 .send(peer("root", "child", "msg-1"))
467 .await
468 .unwrap();
469 assert_eq!(receipt.delivery.generation, 1);
470 assert_eq!(
471 activation.calls.lock().await.as_slice(),
472 &[("child".to_string(), 1)]
473 );
474 assert_eq!(messenger.metrics().snapshot().delivered, 1);
475 }
476
477 #[tokio::test]
478 async fn cross_root_peer_is_rejected_before_enqueue() {
479 let (_temp, store, activation, messenger) = fixture().await;
480 store
481 .save_session(&Session::new("root-a", "model"))
482 .await
483 .unwrap();
484 store
485 .save_session(&Session::new("root-b", "model"))
486 .await
487 .unwrap();
488
489 let error = messenger
490 .send(peer("root-a", "root-b", "msg-2"))
491 .await
492 .unwrap_err();
493 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
494 assert!(activation.calls.lock().await.is_empty());
495 let metrics = messenger.metrics().snapshot();
496 assert_eq!(metrics.rejected, 1);
497 assert_eq!(metrics.unauthorized, 1);
498 assert_eq!(metrics.payload_too_large, 0);
499 assert_eq!(metrics.backlog_full, 0);
500 }
501
502 #[tokio::test]
503 async fn same_root_different_project_peer_is_rejected_before_enqueue() {
504 let (_temp, store, activation, messenger) = fixture().await;
505 let mut source = Session::new("project-root", "model");
506 source.set_project_id_meta("project-a");
507 let mut target = Session::new("project-child", "model");
508 target.kind = bamboo_domain::SessionKind::Child;
509 target.parent_session_id = Some(source.id.clone());
510 target.root_session_id = source.id.clone();
511 target.set_project_id_meta("project-b");
512 store.save_session(&source).await.unwrap();
513 store.save_session(&target).await.unwrap();
514
515 let error = messenger
516 .send(peer(&source.id, &target.id, "different-project"))
517 .await
518 .unwrap_err();
519 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
520 assert!(activation.calls.lock().await.is_empty());
521 let metrics = messenger.metrics().snapshot();
522 assert_eq!(metrics.delivered, 0);
523 assert_eq!(metrics.rejected, 1);
524 assert_eq!(metrics.unauthorized, 1);
525 }
526
527 #[tokio::test]
528 async fn limit_rejections_have_distinct_metrics() {
529 let temp = TempDir::new().unwrap();
530 let store = Arc::new(
531 SessionStoreV2::new(temp.path().to_path_buf())
532 .await
533 .unwrap(),
534 );
535 store
536 .save_session(&Session::new("target", "model"))
537 .await
538 .unwrap();
539 let activation = Arc::new(RecordingActivation {
540 calls: tokio::sync::Mutex::new(Vec::new()),
541 });
542 let inbox = Arc::new(FileSessionInbox::new(
543 store.clone(),
544 SessionInboxLimits {
545 max_payload_bytes: 512,
546 max_backlog: 1,
547 max_claim_batch: 1,
548 },
549 ));
550 let messenger = SessionMessenger::new(store, inbox, activation.clone());
551
552 let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
553 assert!(matches!(
554 messenger.send(oversized).await,
555 Err(SessionMessengerError::Inbox(
556 SessionInboxError::PayloadTooLarge { .. }
557 ))
558 ));
559 messenger
560 .send(SessionMessageEnvelope::user_input("target", "first"))
561 .await
562 .unwrap();
563 assert!(matches!(
564 messenger
565 .send(SessionMessageEnvelope::user_input("target", "second"))
566 .await,
567 Err(SessionMessengerError::Inbox(
568 SessionInboxError::BacklogFull { .. }
569 ))
570 ));
571
572 let metrics = messenger.metrics().snapshot();
573 assert_eq!(metrics.rejected, 2);
574 assert_eq!(metrics.payload_too_large, 1);
575 assert_eq!(metrics.backlog_full, 1);
576 assert_eq!(activation.calls.lock().await.len(), 1);
577 }
578}