1mod file_store;
2
3pub use file_store::FileAttachmentStore;
4
5use std::collections::{BTreeSet, HashMap};
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
10use sha2::{Digest, Sha256};
11
12use crate::store::{AttachmentIntent, AttachmentManifest, StoreError};
13
14#[derive(Debug, thiserror::Error)]
15pub enum AttachmentStoreError {
16 #[error("attachment `{0}` was not found")]
17 NotFound(AttachmentId),
18 #[error("attachment store I/O failed at {path}: {source}")]
19 Io {
20 path: PathBuf,
21 #[source]
22 source: std::io::Error,
23 },
24 #[error("attachment manifest write failed: {0}")]
25 ManifestRecordFailed(String),
26 #[error("attachment store backend failed: {0}")]
27 Backend(String),
28}
29
30#[derive(Clone, Debug)]
31pub struct StoredAttachment {
32 pub bytes: Vec<u8>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct StoredBlobRef {
42 pub id: AttachmentId,
43 pub last_modified_epoch_ms: Option<u64>,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum AttachmentStorePersistence {
48 Ephemeral,
49 Durable,
50}
51
52impl AttachmentStorePersistence {
53 pub fn durability_tier(self) -> crate::DurabilityTier {
58 match self {
59 Self::Ephemeral => crate::DurabilityTier::Inline,
60 Self::Durable => crate::DurabilityTier::Durable,
61 }
62 }
63}
64
65#[async_trait::async_trait]
79pub trait AttachmentStore: Send + Sync {
80 fn persistence(&self) -> AttachmentStorePersistence {
81 AttachmentStorePersistence::Ephemeral
82 }
83
84 async fn put(
85 &self,
86 bytes: Vec<u8>,
87 meta: AttachmentCreateMeta,
88 ) -> Result<AttachmentRef, AttachmentStoreError>;
89
90 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError>;
91
92 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError>;
97
98 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError>;
102
103 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
112 Ok(self.list().await?.into_iter().find(|blob| &blob.id == id))
113 }
114}
115
116#[async_trait::async_trait]
125pub trait AttachmentRootSet: Send + Sync {
126 async fn live_attachment_refs(
137 &self,
138 intent_grace_cutoff_epoch_ms: u64,
139 ) -> Result<BTreeSet<AttachmentId>, StoreError>;
140
141 async fn has_live_attachment_ref(
153 &self,
154 id: &AttachmentId,
155 intent_grace_cutoff_epoch_ms: u64,
156 ) -> Result<bool, StoreError> {
157 Ok(self
158 .live_attachment_refs(intent_grace_cutoff_epoch_ms)
159 .await?
160 .contains(id))
161 }
162}
163
164#[async_trait::async_trait]
170impl<T: crate::SessionStoreFactory + ?Sized> AttachmentRootSet for T {
171 async fn live_attachment_refs(
172 &self,
173 intent_grace_cutoff_epoch_ms: u64,
174 ) -> Result<BTreeSet<AttachmentId>, StoreError> {
175 crate::SessionStoreFactory::live_attachment_refs(self, intent_grace_cutoff_epoch_ms).await
176 }
177
178 async fn has_live_attachment_ref(
179 &self,
180 id: &AttachmentId,
181 intent_grace_cutoff_epoch_ms: u64,
182 ) -> Result<bool, StoreError> {
183 crate::SessionStoreFactory::has_live_attachment_ref(self, id, intent_grace_cutoff_epoch_ms)
184 .await
185 }
186}
187
188#[derive(Clone, Debug, Default, PartialEq, Eq)]
194pub struct AttachmentReclamationReport {
195 pub scanned_blob_count: usize,
197 pub reclaimed_count: usize,
199 pub failed_ids: Vec<AttachmentId>,
202 pub deleted_while_referenced: Vec<AttachmentId>,
210}
211
212pub async fn reclaim_unreferenced_attachments<R>(
275 root_set: &R,
276 backend: &dyn AttachmentStore,
277 grace_period_ms: u64,
278) -> Result<AttachmentReclamationReport, AttachmentStoreError>
279where
280 R: AttachmentRootSet + ?Sized,
281{
282 let now = now_epoch_ms();
283 let intent_grace_cutoff = now.saturating_sub(grace_period_ms);
284 let live = root_set
285 .live_attachment_refs(intent_grace_cutoff)
286 .await
287 .map_err(|err| {
288 AttachmentStoreError::Backend(format!(
289 "failed to enumerate live attachment refs: {err}"
290 ))
291 })?;
292 let blobs = backend.list().await?;
293 let mut report = AttachmentReclamationReport::default();
294 for blob in blobs {
295 report.scanned_blob_count += 1;
296 if live.contains(&blob.id) {
297 continue;
298 }
299 if within_grace(blob.last_modified_epoch_ms, now, grace_period_ms) {
300 continue;
302 }
303 match backend.head(&blob.id).await {
309 Ok(Some(fresh)) => {
310 if within_grace(
311 fresh.last_modified_epoch_ms,
312 now_epoch_ms(),
313 grace_period_ms,
314 ) {
315 continue;
316 }
317 }
318 Ok(None) => continue,
320 Err(_) => {
323 report.failed_ids.push(blob.id);
324 continue;
325 }
326 }
327 match root_set
335 .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
336 .await
337 {
338 Ok(true) => continue,
339 Ok(false) => {}
340 Err(_) => {
343 report.failed_ids.push(blob.id);
344 continue;
345 }
346 }
347 match backend.delete(&blob.id).await {
349 Ok(()) => {
350 report.reclaimed_count += 1;
351 if let Ok(true) = root_set
364 .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
365 .await
366 {
367 tracing::error!(
368 attachment_id = %blob.id,
369 "attachment GC deleted a blob that was re-referenced in the \
370 delete window; bytes are unrecoverable but a subsequent put \
371 self-heals"
372 );
373 report.deleted_while_referenced.push(blob.id);
374 }
375 }
376 Err(_) => report.failed_ids.push(blob.id),
377 }
378 }
379 Ok(report)
380}
381
382fn within_grace(last_modified_epoch_ms: Option<u64>, now: u64, grace_period_ms: u64) -> bool {
386 last_modified_epoch_ms.is_some_and(|modified| now.saturating_sub(modified) < grace_period_ms)
387}
388
389struct InMemoryBlob {
390 stored: StoredAttachment,
391 stored_at_epoch_ms: u64,
392}
393
394#[derive(Default)]
395pub struct InMemoryAttachmentStore {
396 attachments: Mutex<HashMap<AttachmentId, InMemoryBlob>>,
397}
398
399impl InMemoryAttachmentStore {
400 pub fn new() -> Self {
401 Self::default()
402 }
403}
404
405#[async_trait::async_trait]
406impl AttachmentStore for InMemoryAttachmentStore {
407 async fn put(
408 &self,
409 bytes: Vec<u8>,
410 meta: AttachmentCreateMeta,
411 ) -> Result<AttachmentRef, AttachmentStoreError> {
412 let meta = stored_meta(&bytes, meta);
413 let reference = meta.as_ref();
414 let now = now_epoch_ms();
415 let mut attachments = self.attachments.lock().expect("attachment store lock");
416 match attachments.entry(reference.id.clone()) {
417 std::collections::hash_map::Entry::Occupied(mut existing) => {
418 existing.get_mut().stored_at_epoch_ms = now;
422 }
423 std::collections::hash_map::Entry::Vacant(slot) => {
424 slot.insert(InMemoryBlob {
425 stored: StoredAttachment { bytes },
426 stored_at_epoch_ms: now,
427 });
428 }
429 }
430 Ok(reference)
431 }
432
433 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
434 self.attachments
435 .lock()
436 .expect("attachment store lock")
437 .get(id)
438 .map(|blob| blob.stored.clone())
439 .ok_or_else(|| AttachmentStoreError::NotFound(id.clone()))
440 }
441
442 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
443 self.attachments
444 .lock()
445 .expect("attachment store lock")
446 .remove(id);
447 Ok(())
448 }
449
450 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
451 Ok(self
452 .attachments
453 .lock()
454 .expect("attachment store lock")
455 .iter()
456 .map(|(id, blob)| StoredBlobRef {
457 id: id.clone(),
458 last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
459 })
460 .collect())
461 }
462
463 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
464 Ok(self
465 .attachments
466 .lock()
467 .expect("attachment store lock")
468 .get(id)
469 .map(|blob| StoredBlobRef {
470 id: id.clone(),
471 last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
472 }))
473 }
474}
475
476pub fn content_id(bytes: &[u8]) -> AttachmentId {
477 AttachmentId::new(format!("{:x}", Sha256::digest(bytes)))
478}
479
480pub struct SessionAttachmentStore {
499 backend: Arc<dyn AttachmentStore>,
500 manifest: Arc<dyn AttachmentManifest>,
501 session_id: String,
502 pending_manifest_commit_ids: Mutex<BTreeSet<AttachmentId>>,
503}
504
505impl SessionAttachmentStore {
506 pub fn new(
507 backend: Arc<dyn AttachmentStore>,
508 manifest: Arc<dyn AttachmentManifest>,
509 session_id: impl Into<String>,
510 ) -> Self {
511 Self::new_with_pending(backend, manifest, session_id, std::iter::empty())
512 }
513
514 pub fn new_with_pending(
515 backend: Arc<dyn AttachmentStore>,
516 manifest: Arc<dyn AttachmentManifest>,
517 session_id: impl Into<String>,
518 pending_manifest_commit_ids: impl IntoIterator<Item = AttachmentId>,
519 ) -> Self {
520 Self {
521 backend,
522 manifest,
523 session_id: session_id.into(),
524 pending_manifest_commit_ids: Mutex::new(
525 pending_manifest_commit_ids.into_iter().collect(),
526 ),
527 }
528 }
529
530 pub fn ephemeral(backend: Arc<dyn AttachmentStore>) -> Self {
534 Self::new(backend, Arc::new(NoopAttachmentManifest), String::new())
535 }
536
537 pub fn in_memory() -> Self {
539 Self::ephemeral(Arc::new(InMemoryAttachmentStore::new()))
540 }
541
542 pub fn backend(&self) -> &Arc<dyn AttachmentStore> {
543 &self.backend
544 }
545
546 pub fn manifest(&self) -> &Arc<dyn AttachmentManifest> {
547 &self.manifest
548 }
549
550 pub fn session_id(&self) -> &str {
551 &self.session_id
552 }
553
554 pub fn persistence(&self) -> AttachmentStorePersistence {
555 self.backend.persistence()
556 }
557
558 pub fn pending_manifest_commit_ids(&self) -> Vec<AttachmentId> {
561 self.pending_manifest_commit_ids
562 .lock()
563 .expect("attachment manifest commit tracker lock")
564 .iter()
565 .cloned()
566 .collect()
567 }
568
569 pub fn mark_manifest_committed(&self, ids: &[AttachmentId]) {
572 if ids.is_empty() {
573 return;
574 }
575 let mut pending = self
576 .pending_manifest_commit_ids
577 .lock()
578 .expect("attachment manifest commit tracker lock");
579 for id in ids {
580 pending.remove(id);
581 }
582 }
583
584 pub async fn put(
585 &self,
586 bytes: Vec<u8>,
587 meta: AttachmentCreateMeta,
588 ) -> Result<AttachmentRef, AttachmentStoreError> {
589 let attachment_id = content_id(&bytes);
590 let intent = AttachmentIntent {
591 attachment_id: attachment_id.clone(),
592 session_id: self.session_id.clone(),
593 canonical_uri: attachment_uri(&attachment_id),
594 intent_at_epoch_ms: now_epoch_ms(),
595 };
596 self.manifest.record_intent(intent).map_err(|err| {
599 AttachmentStoreError::ManifestRecordFailed(format!(
600 "failed to record attachment intent for `{attachment_id}`: {err}"
601 ))
602 })?;
603 let reference = self.backend.put(bytes, meta).await?;
604 if reference.id != attachment_id {
605 return Err(AttachmentStoreError::Backend(format!(
606 "attachment store returned id `{}` after manifest intent for `{attachment_id}`",
607 reference.id
608 )));
609 }
610 self.pending_manifest_commit_ids
611 .lock()
612 .expect("attachment manifest commit tracker lock")
613 .insert(reference.id.clone());
614 Ok(reference)
615 }
616
617 pub async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
618 let holds_ref = self
622 .manifest
623 .holds_ref(&self.session_id, id)
624 .map_err(|err| {
625 AttachmentStoreError::Backend(format!(
626 "failed to check attachment manifest for `{id}`: {err}"
627 ))
628 })?;
629 if !holds_ref {
630 return Err(AttachmentStoreError::NotFound(id.clone()));
631 }
632 self.backend.get(id).await
633 }
634
635 pub async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
636 self.pending_manifest_commit_ids
639 .lock()
640 .expect("attachment manifest commit tracker lock")
641 .remove(id);
642 self.manifest.forget(&self.session_id, id).map_err(|err| {
643 AttachmentStoreError::ManifestRecordFailed(format!(
644 "failed to forget attachment ref for `{id}`: {err}"
645 ))
646 })?;
647 Ok(())
648 }
649}
650
651pub struct NoopAttachmentManifest;
655
656impl AttachmentManifest for NoopAttachmentManifest {
657 fn record_intent(&self, _intent: AttachmentIntent) -> Result<(), StoreError> {
658 Ok(())
659 }
660
661 fn commit_refs(
662 &self,
663 _session_id: &str,
664 _attachment_ids: &[AttachmentId],
665 ) -> Result<(), StoreError> {
666 Ok(())
667 }
668
669 fn list_uncommitted(
670 &self,
671 _older_than_epoch_ms: u64,
672 ) -> Result<Vec<crate::AttachmentManifestEntry>, StoreError> {
673 Ok(Vec::new())
674 }
675
676 fn forget(&self, _session_id: &str, _attachment_id: &AttachmentId) -> Result<(), StoreError> {
677 Ok(())
678 }
679
680 fn holds_ref(
681 &self,
682 _session_id: &str,
683 _attachment_id: &AttachmentId,
684 ) -> Result<bool, StoreError> {
685 Ok(true)
686 }
687
688 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, StoreError> {
689 Ok(Vec::new())
690 }
691}
692
693fn attachment_uri(attachment_id: &AttachmentId) -> String {
694 format!("lash-attachment://sha256/{attachment_id}")
695}
696
697fn now_epoch_ms() -> u64 {
698 <crate::SystemClock as crate::Clock>::timestamp_ms(&crate::SystemClock)
699}
700
701pub(crate) struct PersistenceManifestAdapter(pub Arc<dyn crate::RuntimePersistence>);
706
707impl AttachmentManifest for PersistenceManifestAdapter {
708 fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
709 AttachmentManifest::record_intent(&*self.0, intent)
710 }
711
712 fn commit_refs(
713 &self,
714 session_id: &str,
715 attachment_ids: &[AttachmentId],
716 ) -> Result<(), crate::StoreError> {
717 AttachmentManifest::commit_refs(&*self.0, session_id, attachment_ids)
718 }
719
720 fn list_uncommitted(
721 &self,
722 older_than_epoch_ms: u64,
723 ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
724 AttachmentManifest::list_uncommitted(&*self.0, older_than_epoch_ms)
725 }
726
727 fn forget(
728 &self,
729 session_id: &str,
730 attachment_id: &AttachmentId,
731 ) -> Result<(), crate::StoreError> {
732 AttachmentManifest::forget(&*self.0, session_id, attachment_id)
733 }
734
735 fn holds_ref(
736 &self,
737 session_id: &str,
738 attachment_id: &AttachmentId,
739 ) -> Result<bool, crate::StoreError> {
740 AttachmentManifest::holds_ref(&*self.0, session_id, attachment_id)
741 }
742
743 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
744 AttachmentManifest::list_all_refs(&*self.0)
745 }
746}
747
748fn stored_meta(bytes: &[u8], meta: AttachmentCreateMeta) -> AttachmentMeta {
749 AttachmentMeta::new(
750 content_id(bytes),
751 meta.media_type,
752 bytes.len() as u64,
753 meta.width,
754 meta.height,
755 meta.label,
756 )
757}
758
759pub async fn resolve_llm_request_attachments(
760 mut request: crate::llm::types::LlmRequest,
761 store: &SessionAttachmentStore,
762) -> Result<crate::llm::types::LlmRequest, AttachmentStoreError> {
763 for attachment in &mut request.attachments {
764 let Some(reference) = attachment.reference.as_ref() else {
765 continue;
766 };
767 if !attachment.data.is_empty() {
768 continue;
769 }
770 let stored = store.get(&reference.id).await?;
771 attachment.mime = reference.canonical_mime().to_string();
772 attachment.data = stored.bytes;
773 }
774 Ok(request)
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use lash_sansio::{ImageMediaType, MediaType};
781
782 #[derive(Default)]
783 struct RecordingManifest {
784 entries: Mutex<HashMap<(String, AttachmentId), crate::AttachmentManifestEntry>>,
785 }
786
787 impl AttachmentManifest for RecordingManifest {
788 fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
789 let key = (intent.session_id.clone(), intent.attachment_id.clone());
790 self.entries
791 .lock()
792 .expect("lock entries")
793 .entry(key)
794 .or_insert(crate::AttachmentManifestEntry {
795 attachment_id: intent.attachment_id,
796 session_id: intent.session_id,
797 canonical_uri: intent.canonical_uri,
798 intent_at_epoch_ms: intent.intent_at_epoch_ms,
799 committed_at_epoch_ms: None,
800 });
801 Ok(())
802 }
803
804 fn commit_refs(
805 &self,
806 session_id: &str,
807 attachment_ids: &[AttachmentId],
808 ) -> Result<(), crate::StoreError> {
809 let mut entries = self.entries.lock().expect("lock entries");
810 for attachment_id in attachment_ids {
811 if let Some(entry) =
812 entries.get_mut(&(session_id.to_string(), attachment_id.clone()))
813 {
814 entry.committed_at_epoch_ms.get_or_insert(1);
815 }
816 }
817 Ok(())
818 }
819
820 fn list_uncommitted(
821 &self,
822 older_than_epoch_ms: u64,
823 ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
824 Ok(self
825 .entries
826 .lock()
827 .expect("lock entries")
828 .values()
829 .filter(|entry| {
830 entry.committed_at_epoch_ms.is_none()
831 && entry.intent_at_epoch_ms <= older_than_epoch_ms
832 })
833 .cloned()
834 .collect())
835 }
836
837 fn forget(
838 &self,
839 session_id: &str,
840 attachment_id: &AttachmentId,
841 ) -> Result<(), crate::StoreError> {
842 self.entries
843 .lock()
844 .expect("lock entries")
845 .remove(&(session_id.to_string(), attachment_id.clone()));
846 Ok(())
847 }
848
849 fn holds_ref(
850 &self,
851 session_id: &str,
852 attachment_id: &AttachmentId,
853 ) -> Result<bool, crate::StoreError> {
854 Ok(self
855 .entries
856 .lock()
857 .expect("lock entries")
858 .contains_key(&(session_id.to_string(), attachment_id.clone())))
859 }
860
861 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
862 Ok(self
863 .entries
864 .lock()
865 .expect("lock entries")
866 .values()
867 .map(|entry| entry.attachment_id.clone())
868 .collect())
869 }
870 }
871
872 struct RecordingRootSet {
875 manifests: Vec<Arc<RecordingManifest>>,
876 }
877
878 #[async_trait::async_trait]
879 impl AttachmentRootSet for RecordingRootSet {
880 async fn live_attachment_refs(
881 &self,
882 intent_grace_cutoff_epoch_ms: u64,
883 ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
884 let mut refs = BTreeSet::new();
885 for manifest in &self.manifests {
886 for aged in manifest.list_uncommitted(intent_grace_cutoff_epoch_ms)? {
890 manifest.forget(&aged.session_id, &aged.attachment_id)?;
891 }
892 refs.extend(manifest.list_all_refs()?);
893 }
894 Ok(refs)
895 }
896 }
897
898 fn meta() -> AttachmentCreateMeta {
899 AttachmentCreateMeta::new(
900 MediaType::Image(ImageMediaType::Png),
901 Some(1),
902 Some(1),
903 Some("pixel".to_string()),
904 )
905 }
906
907 #[tokio::test]
908 async fn memory_store_dedupes_by_bytes() {
909 let store = InMemoryAttachmentStore::new();
910 let a = store.put(vec![1, 2, 3], meta()).await.expect("put a");
911 let b = store.put(vec![1, 2, 3], meta()).await.expect("put b");
912 assert_eq!(a.id, b.id);
913 assert_eq!(a.byte_len, 3);
914 assert_eq!(store.get(&a.id).await.expect("get").bytes, vec![1, 2, 3]);
915 }
916
917 #[tokio::test]
918 async fn memory_store_assigns_identity_and_byte_len_from_bytes() {
919 let store = InMemoryAttachmentStore::new();
920 let reference = store.put(vec![4, 5, 6, 7], meta()).await.expect("put");
921
922 assert_eq!(reference.id, content_id(&[4, 5, 6, 7]));
923 assert_eq!(reference.byte_len, 4);
924 }
925
926 #[tokio::test]
927 async fn memory_store_lists_stored_blobs() {
928 let store = InMemoryAttachmentStore::new();
929 let a = store.put(vec![1], meta()).await.expect("put a");
930 let b = store.put(vec![2], meta()).await.expect("put b");
931 let listed: BTreeSet<AttachmentId> = store
932 .list()
933 .await
934 .expect("list")
935 .into_iter()
936 .map(|blob| blob.id)
937 .collect();
938 assert!(listed.contains(&a.id));
939 assert!(listed.contains(&b.id));
940 assert_eq!(listed.len(), 2);
941 }
942
943 #[tokio::test]
944 async fn facade_get_is_gated_by_manifest_ownership() {
945 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
946 let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
947 let session_a = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-a");
948 let session_b = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-b");
949
950 let reference = session_a.put(vec![7, 7, 7], meta()).await.expect("put a");
951 assert_eq!(
953 session_a.get(&reference.id).await.expect("a reads").bytes,
954 vec![7, 7, 7]
955 );
956 assert!(matches!(
958 session_b.get(&reference.id).await,
959 Err(AttachmentStoreError::NotFound(_))
960 ));
961 }
962
963 #[tokio::test]
964 async fn facade_delete_drops_ref_but_keeps_backend_bytes() {
965 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
966 let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
967 let session = SessionAttachmentStore::new(backend.clone(), manifest, "session-1");
968
969 let reference = session.put(vec![9, 9], meta()).await.expect("put");
970 session.delete(&reference.id).await.expect("delete ref");
971
972 assert!(matches!(
974 session.get(&reference.id).await,
975 Err(AttachmentStoreError::NotFound(_))
976 ));
977 assert_eq!(
979 backend
980 .get(&reference.id)
981 .await
982 .expect("bytes remain")
983 .bytes,
984 vec![9, 9]
985 );
986 }
987
988 #[tokio::test]
989 async fn shared_bytes_survive_until_all_refs_released_then_gc_collects() {
990 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
991 let manifest_a = Arc::new(RecordingManifest::default());
992 let manifest_b = Arc::new(RecordingManifest::default());
993 let session_a = SessionAttachmentStore::new(
994 backend.clone(),
995 manifest_a.clone() as Arc<dyn AttachmentManifest>,
996 "session-a",
997 );
998 let session_b = SessionAttachmentStore::new(
999 backend.clone(),
1000 manifest_b.clone() as Arc<dyn AttachmentManifest>,
1001 "session-b",
1002 );
1003
1004 let ref_a = session_a.put(vec![5, 5, 5], meta()).await.expect("put a");
1008 let ref_b = session_b.put(vec![5, 5, 5], meta()).await.expect("put b");
1009 assert_eq!(ref_a.id, ref_b.id);
1010 manifest_a
1011 .commit_refs("session-a", std::slice::from_ref(&ref_a.id))
1012 .expect("commit a");
1013 manifest_b
1014 .commit_refs("session-b", std::slice::from_ref(&ref_b.id))
1015 .expect("commit b");
1016 assert_eq!(backend.list().await.expect("list").len(), 1);
1017
1018 let root_set = RecordingRootSet {
1019 manifests: vec![manifest_a.clone(), manifest_b.clone()],
1020 };
1021
1022 session_a.delete(&ref_a.id).await.expect("a releases");
1024 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1025 .await
1026 .expect("sweep with b holding a ref");
1027 assert_eq!(report.reclaimed_count, 0, "b still references the blob");
1028 assert_eq!(
1029 backend.get(&ref_b.id).await.expect("blob alive").bytes,
1030 vec![5, 5, 5]
1031 );
1032
1033 session_b.delete(&ref_b.id).await.expect("b releases");
1035 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1036 .await
1037 .expect("sweep with no refs");
1038 assert_eq!(report.reclaimed_count, 1);
1039 assert!(matches!(
1040 backend.get(&ref_b.id).await,
1041 Err(AttachmentStoreError::NotFound(_))
1042 ));
1043 }
1044
1045 #[tokio::test]
1046 async fn gc_spares_fresh_in_flight_intents_as_refs() {
1047 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1048 let manifest = Arc::new(RecordingManifest::default());
1049 let session = SessionAttachmentStore::new(
1050 backend.clone(),
1051 manifest.clone() as Arc<dyn AttachmentManifest>,
1052 "session-1",
1053 );
1054
1055 let reference = session.put(vec![3, 1, 4], meta()).await.expect("put");
1058 let root_set = RecordingRootSet {
1059 manifests: vec![manifest.clone()],
1060 };
1061 const GRACE_MS: u64 = 60 * 60 * 1000;
1062 let report = reclaim_unreferenced_attachments(&root_set, &*backend, GRACE_MS)
1063 .await
1064 .expect("sweep");
1065 assert_eq!(report.reclaimed_count, 0, "a fresh intent is a live ref");
1066 assert_eq!(
1067 backend.get(&reference.id).await.expect("kept").bytes,
1068 vec![3, 1, 4]
1069 );
1070 }
1071
1072 #[tokio::test]
1076 async fn gc_collects_aged_uncommitted_intent_orphan() {
1077 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1078 let manifest = Arc::new(RecordingManifest::default());
1079 let session = SessionAttachmentStore::new(
1080 backend.clone(),
1081 manifest.clone() as Arc<dyn AttachmentManifest>,
1082 "session-1",
1083 );
1084
1085 let orphan = session
1088 .put(vec![9, 9, 9], meta())
1089 .await
1090 .expect("put orphan");
1091 let root_set = RecordingRootSet {
1092 manifests: vec![manifest.clone()],
1093 };
1094 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1095 .await
1096 .expect("sweep");
1097 assert_eq!(
1098 report.reclaimed_count, 1,
1099 "an aged, never-committed intent is a collectable orphan"
1100 );
1101 assert!(matches!(
1102 backend.get(&orphan.id).await,
1103 Err(AttachmentStoreError::NotFound(_))
1104 ));
1105 assert!(manifest.list_all_refs().expect("refs").is_empty());
1107 }
1108
1109 #[tokio::test]
1115 async fn gc_delete_recheck_spares_blob_refreshed_after_snapshot() {
1116 struct StaleSnapshotStore {
1117 id: AttachmentId,
1118 list_mtime: u64,
1119 head_mtime: u64,
1120 deleted: Mutex<bool>,
1121 }
1122
1123 #[async_trait::async_trait]
1124 impl AttachmentStore for StaleSnapshotStore {
1125 async fn put(
1126 &self,
1127 _bytes: Vec<u8>,
1128 _meta: AttachmentCreateMeta,
1129 ) -> Result<AttachmentRef, AttachmentStoreError> {
1130 unreachable!("test does not put through this store")
1131 }
1132 async fn get(
1133 &self,
1134 id: &AttachmentId,
1135 ) -> Result<StoredAttachment, AttachmentStoreError> {
1136 Err(AttachmentStoreError::NotFound(id.clone()))
1137 }
1138 async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1139 *self.deleted.lock().expect("lock") = true;
1140 Ok(())
1141 }
1142 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1143 Ok(vec![StoredBlobRef {
1145 id: self.id.clone(),
1146 last_modified_epoch_ms: Some(self.list_mtime),
1147 }])
1148 }
1149 async fn head(
1150 &self,
1151 id: &AttachmentId,
1152 ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1153 Ok((id == &self.id).then(|| StoredBlobRef {
1155 id: id.clone(),
1156 last_modified_epoch_ms: Some(self.head_mtime),
1157 }))
1158 }
1159 }
1160
1161 let now = now_epoch_ms();
1162 const GRACE_MS: u64 = 60 * 60 * 1000;
1163 let backend = StaleSnapshotStore {
1164 id: AttachmentId::new("recheck"),
1165 list_mtime: now.saturating_sub(GRACE_MS * 2),
1167 head_mtime: now,
1169 deleted: Mutex::new(false),
1170 };
1171 let root_set = RecordingRootSet { manifests: vec![] };
1173 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1174 .await
1175 .expect("sweep");
1176 assert_eq!(
1177 report.reclaimed_count, 0,
1178 "the delete-time re-check must spare a blob refreshed after the snapshot"
1179 );
1180 assert!(
1181 !*backend.deleted.lock().expect("lock"),
1182 "the freshly-refreshed blob must not be deleted"
1183 );
1184 }
1185
1186 struct StaleHeadStore {
1194 id: AttachmentId,
1195 mtime: u64,
1196 deleted: Mutex<bool>,
1197 }
1198
1199 #[async_trait::async_trait]
1200 impl AttachmentStore for StaleHeadStore {
1201 async fn put(
1202 &self,
1203 _bytes: Vec<u8>,
1204 _meta: AttachmentCreateMeta,
1205 ) -> Result<AttachmentRef, AttachmentStoreError> {
1206 unreachable!("test does not put through this store")
1207 }
1208 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
1209 Err(AttachmentStoreError::NotFound(id.clone()))
1210 }
1211 async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1212 *self.deleted.lock().expect("lock") = true;
1213 Ok(())
1214 }
1215 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1216 Ok(vec![StoredBlobRef {
1217 id: self.id.clone(),
1218 last_modified_epoch_ms: Some(self.mtime),
1219 }])
1220 }
1221 async fn head(
1222 &self,
1223 id: &AttachmentId,
1224 ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1225 Ok((id == &self.id).then(|| StoredBlobRef {
1228 id: id.clone(),
1229 last_modified_epoch_ms: Some(self.mtime),
1230 }))
1231 }
1232 }
1233
1234 struct ScriptedRootSet {
1238 answers: Mutex<std::collections::VecDeque<bool>>,
1239 }
1240
1241 #[async_trait::async_trait]
1242 impl AttachmentRootSet for ScriptedRootSet {
1243 async fn live_attachment_refs(
1244 &self,
1245 _intent_grace_cutoff_epoch_ms: u64,
1246 ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
1247 Ok(BTreeSet::new())
1248 }
1249
1250 async fn has_live_attachment_ref(
1251 &self,
1252 _id: &AttachmentId,
1253 _intent_grace_cutoff_epoch_ms: u64,
1254 ) -> Result<bool, crate::StoreError> {
1255 Ok(self
1256 .answers
1257 .lock()
1258 .expect("lock answers")
1259 .pop_front()
1260 .unwrap_or(false))
1261 }
1262 }
1263
1264 #[tokio::test]
1265 async fn gc_pre_delete_root_recheck_spares_reappeared_ref() {
1266 let now = now_epoch_ms();
1267 const GRACE_MS: u64 = 60 * 60 * 1000;
1268 let backend = StaleHeadStore {
1269 id: AttachmentId::new("reappeared"),
1270 mtime: now.saturating_sub(GRACE_MS * 2),
1273 deleted: Mutex::new(false),
1274 };
1275 let root_set = ScriptedRootSet {
1277 answers: Mutex::new([true].into_iter().collect()),
1278 };
1279 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1280 .await
1281 .expect("sweep");
1282 assert_eq!(
1283 report.reclaimed_count, 0,
1284 "pre-delete root re-check must spare the blob"
1285 );
1286 assert!(report.deleted_while_referenced.is_empty());
1287 assert!(
1288 !*backend.deleted.lock().expect("lock"),
1289 "a blob re-referenced before delete must not be deleted"
1290 );
1291 }
1292
1293 #[tokio::test]
1294 async fn gc_post_delete_root_recheck_alarms_on_window_ref() {
1295 let now = now_epoch_ms();
1296 const GRACE_MS: u64 = 60 * 60 * 1000;
1297 let id = AttachmentId::new("window-ref");
1298 let backend = StaleHeadStore {
1299 id: id.clone(),
1300 mtime: now.saturating_sub(GRACE_MS * 2),
1301 deleted: Mutex::new(false),
1302 };
1303 let root_set = ScriptedRootSet {
1306 answers: Mutex::new([false, true].into_iter().collect()),
1307 };
1308 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1309 .await
1310 .expect("sweep");
1311 assert_eq!(report.reclaimed_count, 1, "the blob is deleted");
1312 assert!(*backend.deleted.lock().expect("lock"), "delete happened");
1313 assert_eq!(
1314 report.deleted_while_referenced,
1315 vec![id],
1316 "a ref appearing in the delete window is recorded for the operator alarm"
1317 );
1318 }
1319
1320 #[tokio::test]
1321 async fn session_facade_tracks_successful_puts_until_commit_mark() {
1322 let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
1323 let store = SessionAttachmentStore::new(
1324 Arc::new(InMemoryAttachmentStore::new()),
1325 manifest,
1326 "session-1",
1327 );
1328
1329 let reference = store.put(vec![8, 9, 10], meta()).await.expect("put");
1330 assert_eq!(
1331 store.pending_manifest_commit_ids(),
1332 vec![reference.id.clone()]
1333 );
1334
1335 store.mark_manifest_committed(&[AttachmentId::new("other")]);
1336 assert_eq!(
1337 store.pending_manifest_commit_ids(),
1338 vec![reference.id.clone()]
1339 );
1340
1341 store.mark_manifest_committed(std::slice::from_ref(&reference.id));
1342 assert!(store.pending_manifest_commit_ids().is_empty());
1343 }
1344
1345 #[tokio::test]
1346 async fn ephemeral_facade_passes_reads_through_without_a_guard() {
1347 let store = SessionAttachmentStore::in_memory();
1348 let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
1349 assert_eq!(
1350 store.get(&reference.id).await.expect("get").bytes,
1351 vec![1, 2, 3]
1352 );
1353 }
1354
1355 #[test]
1356 fn persistence_manifest_adapter_forwards_holds_ref() {
1357 let runtime: Arc<dyn crate::RuntimePersistence> =
1358 Arc::new(crate::InMemorySessionStore::new());
1359 let adapter = PersistenceManifestAdapter(runtime);
1360 let attachment_id = AttachmentId::new("adapter-forwarding");
1361 let intent = AttachmentIntent {
1362 attachment_id: attachment_id.clone(),
1363 session_id: "adapter-session".to_string(),
1364 canonical_uri: attachment_uri(&attachment_id),
1365 intent_at_epoch_ms: 10,
1366 };
1367 adapter.record_intent(intent).expect("record intent");
1368 assert!(
1369 adapter
1370 .holds_ref("adapter-session", &attachment_id)
1371 .expect("holds ref")
1372 );
1373 assert!(
1374 !adapter
1375 .holds_ref("other-session", &attachment_id)
1376 .expect("no ref for other session")
1377 );
1378 assert_eq!(
1379 adapter.list_all_refs().expect("list all refs"),
1380 vec![attachment_id]
1381 );
1382 }
1383}