1use std::sync::Arc;
35
36use bamboo_domain::session::types::Session;
37use bamboo_domain::storage::Storage;
38use bamboo_domain::RuntimeSessionPersistence;
39use dashmap::DashMap;
40use tokio::sync::{Mutex, OwnedMutexGuard};
41
42const AUTHORITATIVE_METADATA_KEYS: &[&str] = &["gold_config"];
43
44pub struct LockedSessionStore {
52 storage: Arc<dyn Storage>,
53 locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
54}
55
56pub struct SessionLockGuard {
79 guard: Option<OwnedMutexGuard<()>>,
81 locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
82 session_id: String,
83}
84
85impl Drop for SessionLockGuard {
86 fn drop(&mut self) {
87 self.guard.take();
90 self.locks
91 .remove_if(&self.session_id, |_, arc| Arc::strong_count(arc) == 1);
92 }
93}
94
95impl LockedSessionStore {
96 pub fn new(storage: Arc<dyn Storage>) -> Self {
98 Self {
99 storage,
100 locks: Arc::new(DashMap::new()),
101 }
102 }
103
104 pub fn storage(&self) -> &Arc<dyn Storage> {
106 &self.storage
107 }
108
109 pub async fn acquire_lock(&self, session_id: &str) -> SessionLockGuard {
121 let lock = self
126 .locks
127 .entry(session_id.to_string())
128 .or_insert_with(|| Arc::new(Mutex::new(())))
129 .clone();
130 let guard = lock.lock_owned().await;
131 SessionLockGuard {
132 guard: Some(guard),
133 locks: self.locks.clone(),
134 session_id: session_id.to_string(),
135 }
136 }
137
138 pub async fn save_runtime_only(&self, session: &mut Session) -> std::io::Result<()> {
154 let _guard = self.acquire_lock(&session.id).await;
155 if let Ok(Some(latest)) = self.storage.load_runtime_control_plane(&session.id).await {
156 apply_authoritative_metadata(session, &latest);
157 }
158 self.storage.save_runtime_state(session).await
159 }
160
161 pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
171 let _guard = self.acquire_lock(&session.id).await;
172 self.storage.save_session(session).await
173 }
174
175 pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
185 let _guard = self.acquire_lock(&session.id).await;
186
187 let latest = self.storage.load_session(&session.id).await.ok().flatten();
194
195 let existing_message_count = latest.as_ref().map(|s| s.messages.len());
201 let incoming_message_count = session.messages.len();
202 if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
203 tracing::warn!(
204 "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
205 session.id,
206 existing_message_count,
207 incoming_message_count,
208 session.messages.last().map(|m| format!("{:?}", m.role)),
209 session.updated_at,
210 );
211 } else {
212 tracing::debug!(
213 "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
214 session.id,
215 existing_message_count,
216 incoming_message_count,
217 session.updated_at,
218 );
219 }
220
221 if let Some(latest) = latest.as_ref() {
222 apply_authoritative_metadata(session, latest);
223 }
224 self.storage.save_session(session).await
225 }
226
227 pub async fn update_runtime_config<F>(
239 &self,
240 session_id: &str,
241 mutate: F,
242 ) -> std::io::Result<Option<Session>>
243 where
244 F: FnOnce(&mut Session),
245 {
246 let _guard = self.acquire_lock(session_id).await;
247 let Some(mut session) = self.storage.load_session(session_id).await? else {
248 return Ok(None);
249 };
250 mutate(&mut session);
251 self.storage.save_session(&session).await?;
252 Ok(Some(session))
253 }
254}
255
256#[async_trait::async_trait]
260impl RuntimeSessionPersistence for LockedSessionStore {
261 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
262 self.merge_save_runtime(session).await
263 }
264}
265
266async fn merge_authoritative_metadata_into_stale(
275 storage: &Arc<dyn Storage>,
276 session: &mut Session,
277) {
278 if let Ok(Some(latest)) = storage.load_session(&session.id).await {
279 apply_authoritative_metadata(session, &latest);
280 }
281}
282
283fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
289 if latest.metadata_version >= session.metadata_version {
290 session.title = latest.title.clone();
291 session.title_version = latest.title_version;
292 session.pinned = latest.pinned;
293 for key in AUTHORITATIVE_METADATA_KEYS {
294 if let Some(value) = latest.metadata.get(*key) {
295 session.metadata.insert((*key).to_string(), value.clone());
296 } else {
297 session.metadata.remove(*key);
298 }
299 }
300 session.metadata_version = latest.metadata_version;
301 }
302}
303
304pub async fn merge_save_session(
317 storage: &Arc<dyn Storage>,
318 session: &mut Session,
319) -> std::io::Result<()> {
320 merge_authoritative_metadata_into_stale(storage, session).await;
321 storage.save_session(session).await
322}
323
324#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::v2::SessionStoreV2;
330 use bamboo_domain::session::types::Session;
331
332 async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
333 let temp = tempfile::tempdir().unwrap();
334 let storage = SessionStoreV2::new(temp.path().to_path_buf())
335 .await
336 .expect("storage init");
337 (temp, Arc::new(storage) as Arc<dyn Storage>)
338 }
339
340 fn fresh(id: &str) -> Session {
341 Session::new(id.to_string(), "test-model".to_string())
342 }
343
344 #[tokio::test]
347 async fn update_runtime_config_preserves_concurrently_appended_messages() {
348 use bamboo_domain::session::types::Message;
349 use bamboo_domain::ReasoningEffort;
350
351 let (_temp, storage) = make_storage().await;
352 let store = LockedSessionStore::new(storage.clone());
353 let session_id = "cfg-preserve";
354
355 let mut initial = fresh(session_id);
357 initial.add_message(Message::user("hello"));
358 initial.add_message(Message::assistant("hi", None));
359 storage.save_session(&initial).await.unwrap();
360
361 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
363 after_chat.add_message(Message::user("second question"));
364 storage.save_session(&after_chat).await.unwrap();
365 assert_eq!(after_chat.messages.len(), 3);
366
367 let updated = store
371 .update_runtime_config(session_id, |s| {
372 s.reasoning_effort = Some(ReasoningEffort::Max);
373 })
374 .await
375 .unwrap()
376 .expect("session exists");
377
378 assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
379 assert_eq!(
380 updated.messages.len(),
381 3,
382 "config patch must not revert a concurrently-appended message"
383 );
384
385 let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
386 assert_eq!(on_disk.messages.len(), 3);
387 assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
388 }
389
390 #[tokio::test]
391 async fn update_runtime_config_returns_none_for_missing_session() {
392 use bamboo_domain::ReasoningEffort;
393
394 let (_temp, storage) = make_storage().await;
395 let store = LockedSessionStore::new(storage);
396 let result = store
397 .update_runtime_config("does-not-exist", |s| {
398 s.reasoning_effort = Some(ReasoningEffort::Low);
399 })
400 .await
401 .unwrap();
402 assert!(result.is_none());
403 }
404
405 #[tokio::test]
406 async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
407 use bamboo_domain::session::types::Message;
412
413 let (_temp, storage) = make_storage().await;
414 let store = LockedSessionStore::new(storage.clone());
415 let session_id = "stale-clobber";
416
417 let mut baseline = fresh(session_id);
419 baseline.add_message(Message::user("hello"));
420 storage.save_session(&baseline).await.unwrap();
421 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
422
423 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
425 after_chat.add_message(Message::user("second"));
426 storage.save_session(&after_chat).await.unwrap();
427 assert_eq!(
428 storage
429 .load_session(session_id)
430 .await
431 .unwrap()
432 .unwrap()
433 .messages
434 .len(),
435 2
436 );
437
438 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
440 let after = storage.load_session(session_id).await.unwrap().unwrap();
441 assert_eq!(
442 after.messages.len(),
443 1,
444 "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
445 );
446 }
447
448 #[tokio::test]
449 async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
450 let (_temp, storage) = make_storage().await;
456 let store = LockedSessionStore::new(storage.clone());
457 let session_id = "runtime-merge-meta";
458
459 let mut baseline = fresh(session_id);
461 baseline.title = "Auto Title".to_string();
462 baseline.metadata_version = 0;
463 storage.save_session(&baseline).await.unwrap();
464
465 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
467
468 let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
470 renamed.title = "User Renamed".to_string();
471 renamed.title_version = 1;
472 renamed.pinned = true;
473 renamed.metadata_version = 1;
474 store.commit_metadata(&renamed).await.unwrap();
475
476 stale_snapshot.title = "Auto Title".to_string();
478 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
479
480 let after = storage.load_session(session_id).await.unwrap().unwrap();
481 assert_eq!(after.title, "User Renamed");
482 assert!(after.pinned);
483 assert_eq!(after.metadata_version, 1);
484 assert_eq!(stale_snapshot.title, "User Renamed");
486 assert_eq!(stale_snapshot.metadata_version, 1);
487 }
488
489 #[tokio::test]
492 async fn merge_preserves_disk_title_when_versions_equal() {
493 let (_temp, storage) = make_storage().await;
494 let session_id = "merge-equal";
495
496 let mut on_disk = fresh(session_id);
497 on_disk.title = "User Set This".to_string();
498 on_disk.title_version = 0;
499 on_disk.metadata_version = 0;
500 storage.save_session(&on_disk).await.unwrap();
501
502 let mut runtime_copy = fresh(session_id);
503 runtime_copy.title = "Stale Default".to_string();
504 runtime_copy.title_version = 0;
505 runtime_copy.metadata_version = 0;
506 runtime_copy.messages = vec![];
507
508 merge_save_session(&storage, &mut runtime_copy)
509 .await
510 .unwrap();
511
512 let after = storage.load_session(session_id).await.unwrap().unwrap();
513 assert_eq!(after.title, "User Set This");
514 assert_eq!(after.title_version, 0);
515 assert_eq!(runtime_copy.title, "User Set This");
516 }
517
518 #[tokio::test]
519 async fn merge_preserves_disk_when_disk_version_higher() {
520 let (_temp, storage) = make_storage().await;
521 let session_id = "merge-higher";
522
523 let mut on_disk = fresh(session_id);
524 on_disk.title = "User Title v3".to_string();
525 on_disk.title_version = 3;
526 on_disk.metadata_version = 5;
527 storage.save_session(&on_disk).await.unwrap();
528
529 let mut runtime_copy = fresh(session_id);
530 runtime_copy.title = "Stale".to_string();
531 runtime_copy.title_version = 1;
532 runtime_copy.metadata_version = 0;
533
534 merge_save_session(&storage, &mut runtime_copy)
535 .await
536 .unwrap();
537
538 let after = storage.load_session(session_id).await.unwrap().unwrap();
539 assert_eq!(after.title, "User Title v3");
540 assert_eq!(after.title_version, 3);
541 assert_eq!(after.metadata_version, 5);
542 }
543
544 #[tokio::test]
545 async fn merge_now_preserves_disk_pinned_in_metadata_group() {
546 let (_temp, storage) = make_storage().await;
547 let session_id = "pinned-merge";
548
549 let mut on_disk = fresh(session_id);
550 on_disk.pinned = true;
551 on_disk.metadata_version = 2;
552 storage.save_session(&on_disk).await.unwrap();
553
554 let mut runtime_copy = fresh(session_id);
555 runtime_copy.pinned = false;
556 runtime_copy.metadata_version = 0;
557
558 merge_save_session(&storage, &mut runtime_copy)
559 .await
560 .unwrap();
561
562 let after = storage.load_session(session_id).await.unwrap().unwrap();
563 assert!(
564 after.pinned,
565 "disk pinned=true should win over runtime false"
566 );
567 assert_eq!(after.metadata_version, 2);
568 }
569
570 #[tokio::test]
571 async fn merge_keeps_in_memory_when_session_version_higher() {
572 let (_temp, storage) = make_storage().await;
573 let session_id = "merge-bumped";
574
575 let mut on_disk = fresh(session_id);
576 on_disk.title = "Old".to_string();
577 on_disk.title_version = 1;
578 on_disk.metadata_version = 3;
579 storage.save_session(&on_disk).await.unwrap();
580
581 let mut authoritative_copy = fresh(session_id);
582 authoritative_copy.title = "New Authoritative".to_string();
583 authoritative_copy.title_version = 2;
584 authoritative_copy.metadata_version = 4;
585 authoritative_copy.pinned = true;
586
587 merge_save_session(&storage, &mut authoritative_copy)
588 .await
589 .unwrap();
590
591 let after = storage.load_session(session_id).await.unwrap().unwrap();
592 assert_eq!(after.title, "New Authoritative");
593 assert_eq!(after.title_version, 2);
594 assert_eq!(after.metadata_version, 4);
595 assert!(after.pinned);
596 }
597
598 #[tokio::test]
599 async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
600 let (_temp, storage) = make_storage().await;
601 let session_id = "merge-messages";
602
603 let mut on_disk = fresh(session_id);
604 on_disk.title = "Fresh Title".to_string();
605 on_disk.title_version = 2;
606 on_disk.metadata_version = 5;
607 storage.save_session(&on_disk).await.unwrap();
608
609 let mut runtime_copy = fresh(session_id);
610 runtime_copy.title = "Stale".to_string();
611 runtime_copy.metadata_version = 0;
612 runtime_copy.messages = vec![bamboo_domain::session::types::Message {
613 role: bamboo_domain::session::types::Role::User,
614 content: "keep me".to_string(),
615 id: "msg-1".to_string(),
616 created_at: chrono::Utc::now(),
617 reasoning: None,
618 content_parts: None,
619 image_ocr: None,
620 phase: None,
621 tool_calls: None,
622 tool_call_id: None,
623 tool_success: None,
624 compressed: false,
625 compressed_by_event_id: None,
626 never_compress: false,
627 compression_level: 0,
628 metadata: None,
629 }];
630
631 merge_save_session(&storage, &mut runtime_copy)
632 .await
633 .unwrap();
634
635 let after = storage.load_session(session_id).await.unwrap().unwrap();
636 assert_eq!(after.title, "Fresh Title");
637 assert_eq!(after.metadata_version, 5);
638 assert_eq!(after.messages.len(), 1);
639 assert_eq!(after.messages[0].content, "keep me");
640 }
641
642 #[tokio::test]
645 async fn locked_merge_save_runtime_serialises_concurrent_writes() {
646 let (_temp, storage) = make_storage().await;
647 let store = Arc::new(LockedSessionStore::new(storage));
648 let session_id = "lock-serial".to_string();
649
650 let base = fresh(&session_id);
652 store.storage().save_session(&base).await.unwrap();
653
654 let store_a = store.clone();
657 let store_b = store.clone();
658 let sid_a = session_id.clone();
659 let sid_b = session_id.clone();
660
661 let a = tokio::spawn(async move {
662 let _guard = store_a.acquire_lock(&sid_a).await;
663 let mut s = store_a
664 .storage()
665 .load_session(&sid_a)
666 .await
667 .unwrap()
668 .unwrap();
669 s.title = "Writer A".to_string();
670 s.title_version = s.title_version.saturating_add(1);
671 s.metadata_version = s.metadata_version.saturating_add(1);
672 s.updated_at = chrono::Utc::now();
673 store_a.storage().save_session(&s).await.unwrap();
674 s.title_version
675 });
676
677 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
679
680 let b = tokio::spawn(async move {
681 let _guard = store_b.acquire_lock(&sid_b).await;
682 let mut s = store_b
683 .storage()
684 .load_session(&sid_b)
685 .await
686 .unwrap()
687 .unwrap();
688 s.title = "Writer B".to_string();
689 s.title_version = s.title_version.saturating_add(1);
690 s.metadata_version = s.metadata_version.saturating_add(1);
691 s.updated_at = chrono::Utc::now();
692 store_b.storage().save_session(&s).await.unwrap();
693 s.title_version
694 });
695
696 let (ver_a, ver_b) = tokio::join!(a, b);
697 let final_s = store
698 .storage()
699 .load_session(&session_id)
700 .await
701 .unwrap()
702 .unwrap();
703 assert!(
704 ver_a.unwrap() != ver_b.unwrap(),
705 "concurrent writers must produce distinct versions"
706 );
707 assert_eq!(final_s.metadata_version, 2);
708 }
709
710 #[tokio::test]
711 async fn commit_metadata_is_plain_save_inside_lock() {
712 let (_temp, storage) = make_storage().await;
713 let store = LockedSessionStore::new(storage);
714 let session_id = "commit-plain";
715
716 let mut s = fresh(session_id);
717 s.title = "Committed".to_string();
718 s.metadata_version = 1;
719 s.title_version = 2;
720
721 store.commit_metadata(&s).await.unwrap();
722
723 let after = store
724 .storage()
725 .load_session(session_id)
726 .await
727 .unwrap()
728 .unwrap();
729 assert_eq!(after.title, "Committed");
730 assert_eq!(after.metadata_version, 1);
731 assert_eq!(after.title_version, 2);
732 }
733
734 #[tokio::test]
737 async fn acquire_lock_self_evicts_when_no_other_holder() {
738 let (_temp, storage) = make_storage().await;
739 let store = LockedSessionStore::new(storage);
740
741 {
742 let _guard = store.acquire_lock("solo").await;
743 assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
744 }
745 assert_eq!(
748 store.locks.len(),
749 0,
750 "lock entry must be evicted once released with no other holder"
751 );
752 }
753
754 #[tokio::test]
755 async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
756 let (_temp, storage) = make_storage().await;
757 let store = LockedSessionStore::new(storage);
758
759 for i in 0..100 {
761 let _guard = store.acquire_lock(&format!("sess-{i}")).await;
762 }
763 assert_eq!(
764 store.locks.len(),
765 0,
766 "acquiring locks for many distinct ids must not grow the map"
767 );
768 }
769
770 #[tokio::test]
771 async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
772 use std::sync::atomic::{AtomicUsize, Ordering};
773
774 let (_temp, storage) = make_storage().await;
775 let store = Arc::new(LockedSessionStore::new(storage));
776
777 let active = Arc::new(AtomicUsize::new(0));
779 let max_seen = Arc::new(AtomicUsize::new(0));
780
781 let mut handles = Vec::new();
782 for _ in 0..8 {
783 let store = store.clone();
784 let active = active.clone();
785 let max_seen = max_seen.clone();
786 handles.push(tokio::spawn(async move {
787 let _guard = store.acquire_lock("contended").await;
788 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
789 max_seen.fetch_max(now, Ordering::SeqCst);
790 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
792 active.fetch_sub(1, Ordering::SeqCst);
793 }));
794 }
795 for h in handles {
796 h.await.unwrap();
797 }
798
799 assert_eq!(
804 max_seen.load(Ordering::SeqCst),
805 1,
806 "at most one holder of a given session lock at a time"
807 );
808 assert_eq!(
809 store.locks.len(),
810 0,
811 "after all holders release, the contended entry must be fully evicted"
812 );
813 }
814}