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 adopt_disk_bypass_permissions(session, &latest);
160 }
161 self.storage.save_runtime_state(session).await
162 }
163
164 pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
174 let _guard = self.acquire_lock(&session.id).await;
175 self.storage.save_session(session).await
176 }
177
178 pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
195 self.merge_save_runtime_inner(session, true).await
196 }
197
198 pub async fn save_runtime_authoritative_flags(
207 &self,
208 session: &mut Session,
209 ) -> std::io::Result<()> {
210 self.merge_save_runtime_inner(session, false).await
211 }
212
213 async fn merge_save_runtime_inner(
214 &self,
215 session: &mut Session,
216 adopt_bypass: bool,
217 ) -> std::io::Result<()> {
218 let _guard = self.acquire_lock(&session.id).await;
219
220 let latest = self.storage.load_session(&session.id).await.ok().flatten();
227
228 let existing_message_count = latest.as_ref().map(|s| s.messages.len());
234 let incoming_message_count = session.messages.len();
235 if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
236 tracing::warn!(
237 "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
238 session.id,
239 existing_message_count,
240 incoming_message_count,
241 session.messages.last().map(|m| format!("{:?}", m.role)),
242 session.updated_at,
243 );
244 } else {
245 tracing::debug!(
246 "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
247 session.id,
248 existing_message_count,
249 incoming_message_count,
250 session.updated_at,
251 );
252 }
253
254 if let Some(latest) = latest.as_ref() {
255 apply_authoritative_metadata(session, latest);
256 if adopt_bypass {
260 adopt_disk_bypass_permissions(session, latest);
261 }
262 }
263 self.storage.save_session(session).await
264 }
265
266 pub async fn update_runtime_config<F>(
278 &self,
279 session_id: &str,
280 mutate: F,
281 ) -> std::io::Result<Option<Session>>
282 where
283 F: FnOnce(&mut Session),
284 {
285 let _guard = self.acquire_lock(session_id).await;
286 let Some(mut session) = self.storage.load_session(session_id).await? else {
287 return Ok(None);
288 };
289 mutate(&mut session);
290 self.storage.save_session(&session).await?;
291 Ok(Some(session))
292 }
293}
294
295#[async_trait::async_trait]
299impl RuntimeSessionPersistence for LockedSessionStore {
300 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
301 self.merge_save_runtime(session).await
302 }
303}
304
305async fn merge_authoritative_metadata_into_stale(
314 storage: &Arc<dyn Storage>,
315 session: &mut Session,
316) {
317 if let Ok(Some(latest)) = storage.load_session(&session.id).await {
318 apply_authoritative_metadata(session, &latest);
319 adopt_disk_bypass_permissions(session, &latest);
320 }
321}
322
323fn adopt_disk_bypass_permissions(session: &mut Session, latest: &Session) {
333 let Some(disk_bypass) = latest
338 .agent_runtime_state
339 .as_ref()
340 .map(|state| state.bypass_permissions)
341 else {
342 return;
343 };
344 match session.agent_runtime_state.as_mut() {
345 Some(state) => state.bypass_permissions = disk_bypass,
346 None if disk_bypass => {
349 session
350 .agent_runtime_state
351 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
352 .bypass_permissions = true;
353 }
354 None => {}
355 }
356}
357
358fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
364 if latest.metadata_version >= session.metadata_version {
365 session.title = latest.title.clone();
366 session.title_version = latest.title_version;
367 session.pinned = latest.pinned;
368 for key in AUTHORITATIVE_METADATA_KEYS {
369 if let Some(value) = latest.metadata.get(*key) {
370 session.metadata.insert((*key).to_string(), value.clone());
371 } else {
372 session.metadata.remove(*key);
373 }
374 }
375 session.metadata_version = latest.metadata_version;
376 }
377}
378
379pub async fn merge_save_session(
392 storage: &Arc<dyn Storage>,
393 session: &mut Session,
394) -> std::io::Result<()> {
395 merge_authoritative_metadata_into_stale(storage, session).await;
396 storage.save_session(session).await
397}
398
399#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::v2::SessionStoreV2;
405 use bamboo_domain::session::types::Session;
406
407 async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
408 let temp = tempfile::tempdir().unwrap();
409 let storage = SessionStoreV2::new(temp.path().to_path_buf())
410 .await
411 .expect("storage init");
412 (temp, Arc::new(storage) as Arc<dyn Storage>)
413 }
414
415 fn fresh(id: &str) -> Session {
416 Session::new(id.to_string(), "test-model".to_string())
417 }
418
419 #[tokio::test]
422 async fn update_runtime_config_preserves_concurrently_appended_messages() {
423 use bamboo_domain::session::types::Message;
424 use bamboo_domain::ReasoningEffort;
425
426 let (_temp, storage) = make_storage().await;
427 let store = LockedSessionStore::new(storage.clone());
428 let session_id = "cfg-preserve";
429
430 let mut initial = fresh(session_id);
432 initial.add_message(Message::user("hello"));
433 initial.add_message(Message::assistant("hi", None));
434 storage.save_session(&initial).await.unwrap();
435
436 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
438 after_chat.add_message(Message::user("second question"));
439 storage.save_session(&after_chat).await.unwrap();
440 assert_eq!(after_chat.messages.len(), 3);
441
442 let updated = store
446 .update_runtime_config(session_id, |s| {
447 s.reasoning_effort = Some(ReasoningEffort::Max);
448 })
449 .await
450 .unwrap()
451 .expect("session exists");
452
453 assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
454 assert_eq!(
455 updated.messages.len(),
456 3,
457 "config patch must not revert a concurrently-appended message"
458 );
459
460 let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
461 assert_eq!(on_disk.messages.len(), 3);
462 assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
463 }
464
465 #[tokio::test]
466 async fn update_runtime_config_returns_none_for_missing_session() {
467 use bamboo_domain::ReasoningEffort;
468
469 let (_temp, storage) = make_storage().await;
470 let store = LockedSessionStore::new(storage);
471 let result = store
472 .update_runtime_config("does-not-exist", |s| {
473 s.reasoning_effort = Some(ReasoningEffort::Low);
474 })
475 .await
476 .unwrap();
477 assert!(result.is_none());
478 }
479
480 #[tokio::test]
481 async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
482 use bamboo_domain::session::types::Message;
487
488 let (_temp, storage) = make_storage().await;
489 let store = LockedSessionStore::new(storage.clone());
490 let session_id = "stale-clobber";
491
492 let mut baseline = fresh(session_id);
494 baseline.add_message(Message::user("hello"));
495 storage.save_session(&baseline).await.unwrap();
496 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
497
498 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
500 after_chat.add_message(Message::user("second"));
501 storage.save_session(&after_chat).await.unwrap();
502 assert_eq!(
503 storage
504 .load_session(session_id)
505 .await
506 .unwrap()
507 .unwrap()
508 .messages
509 .len(),
510 2
511 );
512
513 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
515 let after = storage.load_session(session_id).await.unwrap().unwrap();
516 assert_eq!(
517 after.messages.len(),
518 1,
519 "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
520 );
521 }
522
523 #[tokio::test]
524 async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
525 let (_temp, storage) = make_storage().await;
531 let store = LockedSessionStore::new(storage.clone());
532 let session_id = "runtime-merge-meta";
533
534 let mut baseline = fresh(session_id);
536 baseline.title = "Auto Title".to_string();
537 baseline.metadata_version = 0;
538 storage.save_session(&baseline).await.unwrap();
539
540 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
542
543 let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
545 renamed.title = "User Renamed".to_string();
546 renamed.title_version = 1;
547 renamed.pinned = true;
548 renamed.metadata_version = 1;
549 store.commit_metadata(&renamed).await.unwrap();
550
551 stale_snapshot.title = "Auto Title".to_string();
553 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
554
555 let after = storage.load_session(session_id).await.unwrap().unwrap();
556 assert_eq!(after.title, "User Renamed");
557 assert!(after.pinned);
558 assert_eq!(after.metadata_version, 1);
559 assert_eq!(stale_snapshot.title, "User Renamed");
561 assert_eq!(stale_snapshot.metadata_version, 1);
562 }
563
564 #[tokio::test]
568 async fn merge_save_runtime_adopts_disk_bypass_permissions() {
569 use bamboo_domain::AgentRuntimeState;
570
571 let (_temp, storage) = make_storage().await;
572 let store = LockedSessionStore::new(storage.clone());
573 let session_id = "runtime-bypass";
574
575 let baseline = fresh(session_id);
577 storage.save_session(&baseline).await.unwrap();
578
579 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
581 loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
582
583 store
585 .update_runtime_config(session_id, |s| {
586 s.agent_runtime_state
587 .get_or_insert_with(AgentRuntimeState::default)
588 .bypass_permissions = true;
589 })
590 .await
591 .unwrap()
592 .expect("session exists");
593
594 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
597
598 let after = storage.load_session(session_id).await.unwrap().unwrap();
599 assert!(
600 after
601 .agent_runtime_state
602 .as_ref()
603 .is_some_and(|s| s.bypass_permissions),
604 "disk bypass=ON must survive a stale runtime save (#540)"
605 );
606 assert!(loop_snapshot
608 .agent_runtime_state
609 .as_ref()
610 .is_some_and(|s| s.bypass_permissions));
611 }
612
613 #[tokio::test]
616 async fn merge_save_runtime_adopts_disk_bypass_off() {
617 use bamboo_domain::AgentRuntimeState;
618
619 let (_temp, storage) = make_storage().await;
620 let store = LockedSessionStore::new(storage.clone());
621 let session_id = "runtime-bypass-off";
622
623 let mut baseline = fresh(session_id);
625 let mut on_state = AgentRuntimeState::default();
626 on_state.bypass_permissions = true;
627 baseline.agent_runtime_state = Some(on_state);
628 storage.save_session(&baseline).await.unwrap();
629
630 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
632
633 store
635 .update_runtime_config(session_id, |s| {
636 s.agent_runtime_state
637 .get_or_insert_with(AgentRuntimeState::default)
638 .bypass_permissions = false;
639 })
640 .await
641 .unwrap()
642 .expect("session exists");
643
644 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
645
646 let after = storage.load_session(session_id).await.unwrap().unwrap();
647 assert!(
648 !after
649 .agent_runtime_state
650 .as_ref()
651 .is_some_and(|s| s.bypass_permissions),
652 "disk bypass=OFF must survive a stale runtime save (#540)"
653 );
654 }
655
656 #[tokio::test]
659 async fn save_runtime_authoritative_flags_persists_in_memory_bypass() {
660 use bamboo_domain::AgentRuntimeState;
661
662 let (_temp, storage) = make_storage().await;
663 let store = LockedSessionStore::new(storage.clone());
664 let session_id = "child-reseed";
665
666 let mut baseline = fresh(session_id);
668 let mut on_state = AgentRuntimeState::default();
669 on_state.bypass_permissions = true;
670 baseline.agent_runtime_state = Some(on_state);
671 storage.save_session(&baseline).await.unwrap();
672
673 let mut child = storage.load_session(session_id).await.unwrap().unwrap();
676 child
677 .agent_runtime_state
678 .get_or_insert_with(AgentRuntimeState::default)
679 .bypass_permissions = false;
680
681 store
683 .save_runtime_authoritative_flags(&mut child)
684 .await
685 .unwrap();
686
687 let after = storage.load_session(session_id).await.unwrap().unwrap();
688 assert!(
689 !after
690 .agent_runtime_state
691 .as_ref()
692 .is_some_and(|s| s.bypass_permissions),
693 "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
694 );
695 }
696
697 #[tokio::test]
699 async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
700 use bamboo_domain::AgentRuntimeState;
701
702 let (_temp, storage) = make_storage().await;
703 let store = LockedSessionStore::new(storage.clone());
704 let session_id = "no-runtime-state";
705
706 let baseline = fresh(session_id);
708 assert!(baseline.agent_runtime_state.is_none());
709 storage.save_session(&baseline).await.unwrap();
710
711 let mut running = storage.load_session(session_id).await.unwrap().unwrap();
713 let mut on_state = AgentRuntimeState::default();
714 on_state.bypass_permissions = true;
715 running.agent_runtime_state = Some(on_state);
716
717 store.merge_save_runtime(&mut running).await.unwrap();
718
719 assert!(
720 running
721 .agent_runtime_state
722 .as_ref()
723 .is_some_and(|s| s.bypass_permissions),
724 "a runtime-state-less disk copy must not force bypass OFF (#540)"
725 );
726 }
727
728 #[tokio::test]
731 async fn merge_preserves_disk_title_when_versions_equal() {
732 let (_temp, storage) = make_storage().await;
733 let session_id = "merge-equal";
734
735 let mut on_disk = fresh(session_id);
736 on_disk.title = "User Set This".to_string();
737 on_disk.title_version = 0;
738 on_disk.metadata_version = 0;
739 storage.save_session(&on_disk).await.unwrap();
740
741 let mut runtime_copy = fresh(session_id);
742 runtime_copy.title = "Stale Default".to_string();
743 runtime_copy.title_version = 0;
744 runtime_copy.metadata_version = 0;
745 runtime_copy.messages = vec![];
746
747 merge_save_session(&storage, &mut runtime_copy)
748 .await
749 .unwrap();
750
751 let after = storage.load_session(session_id).await.unwrap().unwrap();
752 assert_eq!(after.title, "User Set This");
753 assert_eq!(after.title_version, 0);
754 assert_eq!(runtime_copy.title, "User Set This");
755 }
756
757 #[tokio::test]
758 async fn merge_preserves_disk_when_disk_version_higher() {
759 let (_temp, storage) = make_storage().await;
760 let session_id = "merge-higher";
761
762 let mut on_disk = fresh(session_id);
763 on_disk.title = "User Title v3".to_string();
764 on_disk.title_version = 3;
765 on_disk.metadata_version = 5;
766 storage.save_session(&on_disk).await.unwrap();
767
768 let mut runtime_copy = fresh(session_id);
769 runtime_copy.title = "Stale".to_string();
770 runtime_copy.title_version = 1;
771 runtime_copy.metadata_version = 0;
772
773 merge_save_session(&storage, &mut runtime_copy)
774 .await
775 .unwrap();
776
777 let after = storage.load_session(session_id).await.unwrap().unwrap();
778 assert_eq!(after.title, "User Title v3");
779 assert_eq!(after.title_version, 3);
780 assert_eq!(after.metadata_version, 5);
781 }
782
783 #[tokio::test]
784 async fn merge_now_preserves_disk_pinned_in_metadata_group() {
785 let (_temp, storage) = make_storage().await;
786 let session_id = "pinned-merge";
787
788 let mut on_disk = fresh(session_id);
789 on_disk.pinned = true;
790 on_disk.metadata_version = 2;
791 storage.save_session(&on_disk).await.unwrap();
792
793 let mut runtime_copy = fresh(session_id);
794 runtime_copy.pinned = false;
795 runtime_copy.metadata_version = 0;
796
797 merge_save_session(&storage, &mut runtime_copy)
798 .await
799 .unwrap();
800
801 let after = storage.load_session(session_id).await.unwrap().unwrap();
802 assert!(
803 after.pinned,
804 "disk pinned=true should win over runtime false"
805 );
806 assert_eq!(after.metadata_version, 2);
807 }
808
809 #[tokio::test]
810 async fn merge_keeps_in_memory_when_session_version_higher() {
811 let (_temp, storage) = make_storage().await;
812 let session_id = "merge-bumped";
813
814 let mut on_disk = fresh(session_id);
815 on_disk.title = "Old".to_string();
816 on_disk.title_version = 1;
817 on_disk.metadata_version = 3;
818 storage.save_session(&on_disk).await.unwrap();
819
820 let mut authoritative_copy = fresh(session_id);
821 authoritative_copy.title = "New Authoritative".to_string();
822 authoritative_copy.title_version = 2;
823 authoritative_copy.metadata_version = 4;
824 authoritative_copy.pinned = true;
825
826 merge_save_session(&storage, &mut authoritative_copy)
827 .await
828 .unwrap();
829
830 let after = storage.load_session(session_id).await.unwrap().unwrap();
831 assert_eq!(after.title, "New Authoritative");
832 assert_eq!(after.title_version, 2);
833 assert_eq!(after.metadata_version, 4);
834 assert!(after.pinned);
835 }
836
837 #[tokio::test]
838 async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
839 let (_temp, storage) = make_storage().await;
840 let session_id = "merge-messages";
841
842 let mut on_disk = fresh(session_id);
843 on_disk.title = "Fresh Title".to_string();
844 on_disk.title_version = 2;
845 on_disk.metadata_version = 5;
846 storage.save_session(&on_disk).await.unwrap();
847
848 let mut runtime_copy = fresh(session_id);
849 runtime_copy.title = "Stale".to_string();
850 runtime_copy.metadata_version = 0;
851 runtime_copy.messages = vec![bamboo_domain::session::types::Message {
852 role: bamboo_domain::session::types::Role::User,
853 content: "keep me".to_string(),
854 id: "msg-1".to_string(),
855 created_at: chrono::Utc::now(),
856 reasoning: None,
857 reasoning_signature: None,
858 content_parts: None,
859 image_ocr: None,
860 phase: None,
861 tool_calls: None,
862 tool_call_id: None,
863 tool_success: None,
864 compressed: false,
865 compressed_by_event_id: None,
866 never_compress: false,
867 compression_level: 0,
868 metadata: None,
869 }];
870
871 merge_save_session(&storage, &mut runtime_copy)
872 .await
873 .unwrap();
874
875 let after = storage.load_session(session_id).await.unwrap().unwrap();
876 assert_eq!(after.title, "Fresh Title");
877 assert_eq!(after.metadata_version, 5);
878 assert_eq!(after.messages.len(), 1);
879 assert_eq!(after.messages[0].content, "keep me");
880 }
881
882 #[tokio::test]
885 async fn locked_merge_save_runtime_serialises_concurrent_writes() {
886 let (_temp, storage) = make_storage().await;
887 let store = Arc::new(LockedSessionStore::new(storage));
888 let session_id = "lock-serial".to_string();
889
890 let base = fresh(&session_id);
892 store.storage().save_session(&base).await.unwrap();
893
894 let store_a = store.clone();
897 let store_b = store.clone();
898 let sid_a = session_id.clone();
899 let sid_b = session_id.clone();
900
901 let a = tokio::spawn(async move {
902 let _guard = store_a.acquire_lock(&sid_a).await;
903 let mut s = store_a
904 .storage()
905 .load_session(&sid_a)
906 .await
907 .unwrap()
908 .unwrap();
909 s.title = "Writer A".to_string();
910 s.title_version = s.title_version.saturating_add(1);
911 s.metadata_version = s.metadata_version.saturating_add(1);
912 s.updated_at = chrono::Utc::now();
913 store_a.storage().save_session(&s).await.unwrap();
914 s.title_version
915 });
916
917 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
919
920 let b = tokio::spawn(async move {
921 let _guard = store_b.acquire_lock(&sid_b).await;
922 let mut s = store_b
923 .storage()
924 .load_session(&sid_b)
925 .await
926 .unwrap()
927 .unwrap();
928 s.title = "Writer B".to_string();
929 s.title_version = s.title_version.saturating_add(1);
930 s.metadata_version = s.metadata_version.saturating_add(1);
931 s.updated_at = chrono::Utc::now();
932 store_b.storage().save_session(&s).await.unwrap();
933 s.title_version
934 });
935
936 let (ver_a, ver_b) = tokio::join!(a, b);
937 let final_s = store
938 .storage()
939 .load_session(&session_id)
940 .await
941 .unwrap()
942 .unwrap();
943 assert!(
944 ver_a.unwrap() != ver_b.unwrap(),
945 "concurrent writers must produce distinct versions"
946 );
947 assert_eq!(final_s.metadata_version, 2);
948 }
949
950 #[tokio::test]
951 async fn commit_metadata_is_plain_save_inside_lock() {
952 let (_temp, storage) = make_storage().await;
953 let store = LockedSessionStore::new(storage);
954 let session_id = "commit-plain";
955
956 let mut s = fresh(session_id);
957 s.title = "Committed".to_string();
958 s.metadata_version = 1;
959 s.title_version = 2;
960
961 store.commit_metadata(&s).await.unwrap();
962
963 let after = store
964 .storage()
965 .load_session(session_id)
966 .await
967 .unwrap()
968 .unwrap();
969 assert_eq!(after.title, "Committed");
970 assert_eq!(after.metadata_version, 1);
971 assert_eq!(after.title_version, 2);
972 }
973
974 #[tokio::test]
977 async fn acquire_lock_self_evicts_when_no_other_holder() {
978 let (_temp, storage) = make_storage().await;
979 let store = LockedSessionStore::new(storage);
980
981 {
982 let _guard = store.acquire_lock("solo").await;
983 assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
984 }
985 assert_eq!(
988 store.locks.len(),
989 0,
990 "lock entry must be evicted once released with no other holder"
991 );
992 }
993
994 #[tokio::test]
995 async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
996 let (_temp, storage) = make_storage().await;
997 let store = LockedSessionStore::new(storage);
998
999 for i in 0..100 {
1001 let _guard = store.acquire_lock(&format!("sess-{i}")).await;
1002 }
1003 assert_eq!(
1004 store.locks.len(),
1005 0,
1006 "acquiring locks for many distinct ids must not grow the map"
1007 );
1008 }
1009
1010 #[tokio::test]
1011 async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
1012 use std::sync::atomic::{AtomicUsize, Ordering};
1013
1014 let (_temp, storage) = make_storage().await;
1015 let store = Arc::new(LockedSessionStore::new(storage));
1016
1017 let active = Arc::new(AtomicUsize::new(0));
1019 let max_seen = Arc::new(AtomicUsize::new(0));
1020
1021 let mut handles = Vec::new();
1022 for _ in 0..8 {
1023 let store = store.clone();
1024 let active = active.clone();
1025 let max_seen = max_seen.clone();
1026 handles.push(tokio::spawn(async move {
1027 let _guard = store.acquire_lock("contended").await;
1028 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1029 max_seen.fetch_max(now, Ordering::SeqCst);
1030 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1032 active.fetch_sub(1, Ordering::SeqCst);
1033 }));
1034 }
1035 for h in handles {
1036 h.await.unwrap();
1037 }
1038
1039 assert_eq!(
1044 max_seen.load(Ordering::SeqCst),
1045 1,
1046 "at most one holder of a given session lock at a time"
1047 );
1048 assert_eq!(
1049 store.locks.len(),
1050 0,
1051 "after all holders release, the contended entry must be fully evicted"
1052 );
1053 }
1054}