Skip to main content

lash_core/store/
attachment_manifest.rs

1//! Attachment write-ahead manifest: the synchronous attachment-tracking surface
2//! required from every [`SessionCommitStore`](super::SessionCommitStore).
3//!
4//! Split from `store/mod.rs` to keep both modules under the file-size budget;
5//! the public paths (`crate::store::AttachmentManifest`, `AttachmentIntent`,
6//! `AttachmentManifestEntry`, and the `impl_noop_attachment_manifest!` macro)
7//! are preserved by re-export.
8
9use super::StoreError;
10
11/// A pending attachment write recorded *before* the bytes hit the
12/// [`AttachmentStore`](crate::AttachmentStore) backend.
13///
14/// The runtime calls [`AttachmentManifest::record_intent`] from the
15/// [`SessionAttachmentStore`](crate::SessionAttachmentStore)
16/// wrapper before each `put`, so the manifest is a durable record that
17/// "some bytes are about to land at this URI." When the turn that
18/// references the attachment commits successfully via
19/// [`SessionCommitStore::commit_runtime_state`](super::SessionCommitStore::commit_runtime_state),
20/// the same transaction
21/// stamps `committed_at_epoch_ms`. Periodic GC sweeps manifest rows
22/// whose intent has aged past a host-chosen threshold without ever
23/// being committed and deletes the corresponding bytes — that's how we
24/// reconcile orphaned files left behind by crashes between `put` and
25/// the next turn commit.
26#[derive(Clone, Debug)]
27pub struct AttachmentIntent {
28    pub attachment_id: crate::AttachmentId,
29    pub session_id: String,
30    /// Canonical, stable identity for the session-owned physical object.
31    /// Backends may map this identity onto their own path/key representation.
32    pub canonical_uri: String,
33    pub intent_at_epoch_ms: u64,
34}
35
36#[derive(Clone, Debug)]
37pub struct AttachmentManifestEntry {
38    pub attachment_id: crate::AttachmentId,
39    pub session_id: String,
40    pub canonical_uri: String,
41    pub intent_at_epoch_ms: u64,
42    pub committed_at_epoch_ms: Option<u64>,
43}
44
45/// The synchronous attachment-manifest surface required from every
46/// [`SessionCommitStore`](super::SessionCommitStore). Used by
47/// [`SessionAttachmentStore`](crate::SessionAttachmentStore)
48/// to record intent rows before `put` and by GC sweeps to reconcile
49/// orphans. See the [`AttachmentIntent`] doc comment for the full
50/// crash-safety story.
51///
52/// Backends with no attachment story (in-memory tests, mock stores)
53/// paste no-op impls via [`impl_noop_attachment_manifest!`] and
54/// participate transparently — `record_intent` is a no-op, the
55/// scoped wrapper still works, and GC sweeps return empty.
56pub trait AttachmentManifest: Send + Sync {
57    fn record_intent(&self, intent: AttachmentIntent) -> Result<(), StoreError>;
58
59    /// Mark a set of attachment ids as committed (i.e. now referenced
60    /// by a durable session-graph commit). Backends that store
61    /// commits and manifest in the same database stamp this inside
62    /// the commit transaction; the trait-level method is the
63    /// out-of-band entry point for hosts that want to commit an id
64    /// outside the normal turn-commit flow.
65    ///
66    /// Commit is an *update in place* of an existing intent row, never an
67    /// insert: it stamps `committed_at_epoch_ms` on rows that already exist for
68    /// `(session_id, attachment_id)` and no-ops on ids with no row. This is
69    /// deliberate and sound in both edge cases:
70    ///
71    /// * An id with no row in *this* session (e.g. an attachment carried in from
72    ///   conversation history or a parent session) is already rooted by the
73    ///   session that recorded its intent — this session needs no row of its own.
74    /// * An id whose intent was *reconciled away* by GC (the crash-orphan sweep
75    ///   forgot an uncommitted intent aged past the grace window) also no-ops:
76    ///   because commit never re-inserts, it cannot resurrect a committed ref to
77    ///   bytes GC may already have collected. The read side surfaces the missing
78    ///   bytes as `NotFound` rather than through a dangling root. This case can
79    ///   only arise when a turn outlives the GC grace period, which the host must
80    ///   prevent by setting the grace larger than the longest expected turn (the
81    ///   same invariant the removed per-session sweep assumed).
82    fn commit_refs(
83        &self,
84        session_id: &str,
85        attachment_ids: &[crate::AttachmentId],
86    ) -> Result<(), StoreError>;
87
88    /// Return manifest entries whose intent has aged past
89    /// `older_than_epoch_ms` without ever being committed. Hosts run
90    /// this periodically to find orphans left by crashes between
91    /// `record_intent` and the next turn commit.
92    fn list_uncommitted(
93        &self,
94        older_than_epoch_ms: u64,
95    ) -> Result<Vec<AttachmentManifestEntry>, StoreError>;
96
97    /// Atomically forget every *uncommitted* intent whose `intent_at_epoch_ms`
98    /// is at or before `intent_grace_cutoff_epoch_ms` — the crash-orphan
99    /// reconciliation GC runs before it snapshots the root set.
100    ///
101    /// This MUST be a single conditional operation, not a `list_uncommitted`
102    /// read followed by per-row `forget` calls. A concurrent `record_intent` for
103    /// the same `(session, attachment)` refreshes the intent's timestamp; a
104    /// read-then-forget can delete that freshly-refreshed *live* intent in the
105    /// window between the read and the forget, dropping the blob's only root and
106    /// letting GC collect bytes a session is about to commit. Expressing the age
107    /// predicate inside one delete closes that race: a refresh that bumps
108    /// `intent_at` past the cutoff no longer matches, so the intent survives.
109    ///
110    /// The default implementation is the racy read-then-forget; every backend
111    /// whose `record_intent` can run concurrently with reconciliation overrides
112    /// it with a genuinely atomic conditional delete.
113    fn forget_aged_uncommitted_intents(
114        &self,
115        intent_grace_cutoff_epoch_ms: u64,
116    ) -> Result<(), StoreError> {
117        for aged in self.list_uncommitted(intent_grace_cutoff_epoch_ms)? {
118            self.forget(&aged.session_id, &aged.attachment_id)?;
119        }
120        Ok(())
121    }
122
123    /// Whether this manifest currently holds a *GC-live* ref for `attachment_id`
124    /// — a committed ref, or an uncommitted intent younger than
125    /// `intent_grace_cutoff_epoch_ms` (`intent_at_epoch_ms >` cutoff). Aged
126    /// uncommitted intents (crash orphans) do NOT count.
127    ///
128    /// This is the single-id counterpart to [`Self::list_all_refs`], used by the
129    /// GC lever's delete-time root re-check to spare (and, post-delete, to alarm
130    /// on) a blob that was re-referenced in the narrow window between the freshness
131    /// re-check and the delete. Per-session-database backends answer by checking
132    /// only until the first hit rather than materializing the whole root set.
133    ///
134    /// The default is conservative — it treats *any* manifest row for the id as
135    /// live (sparing more than strictly necessary, never deleting a referenced
136    /// blob). Backends override it with the precise cutoff-aware predicate.
137    fn has_live_ref_for_id(
138        &self,
139        attachment_id: &crate::AttachmentId,
140        intent_grace_cutoff_epoch_ms: u64,
141    ) -> Result<bool, StoreError> {
142        let _ = intent_grace_cutoff_epoch_ms;
143        Ok(self
144            .list_all_refs()?
145            .iter()
146            .any(|ref_id| ref_id == attachment_id))
147    }
148
149    /// Remove one session's manifest row. Called by the session facade when a
150    /// turn releases an attachment, and by `delete_session` when a whole
151    /// session's refs are dropped. The bytes themselves die later via GC once
152    /// no session references them.
153    fn forget(
154        &self,
155        session_id: &str,
156        attachment_id: &crate::AttachmentId,
157    ) -> Result<(), StoreError>;
158
159    /// Whether this manifest holds a live ref — an intent *or* a commit — for
160    /// `(session_id, attachment_id)`.
161    ///
162    /// The session-boundary guard: the
163    /// [`SessionAttachmentStore`](crate::SessionAttachmentStore) facade calls
164    /// it before every backend `get`, so a turn in session A can never resolve
165    /// session B's content-addressed blob by guessing its hash. Backends with
166    /// no attachment story answer `true` (they impose no guard).
167    fn holds_ref(
168        &self,
169        session_id: &str,
170        attachment_id: &crate::AttachmentId,
171    ) -> Result<bool, StoreError>;
172
173    /// Every live attachment ref (intent or committed) this manifest instance
174    /// can see, deduplicated. For a global manifest table (Postgres) this spans
175    /// all sessions; for a per-session database (SQLite) it is that session's
176    /// refs, which the factory-level
177    /// [`AttachmentRootSet`](crate::AttachmentRootSet) unions across every
178    /// session database at sweep time. Feeds mark-and-sweep GC.
179    fn list_all_refs(&self) -> Result<Vec<crate::AttachmentId>, StoreError>;
180}
181
182/// Mixin macro for [`SessionCommitStore`](super::SessionCommitStore) implementors
183/// that have no attachment-write story (mock backends, in-memory test stores,
184/// runtime-perf harnesses). Pastes no-op impls of every
185/// [`AttachmentManifest`] method.
186#[macro_export]
187macro_rules! impl_noop_attachment_manifest {
188    ($ty:ty) => {
189        impl $crate::AttachmentManifest for $ty {
190            fn record_intent(
191                &self,
192                _intent: $crate::AttachmentIntent,
193            ) -> ::std::result::Result<(), $crate::StoreError> {
194                Ok(())
195            }
196
197            fn commit_refs(
198                &self,
199                _session_id: &str,
200                _attachment_ids: &[$crate::AttachmentId],
201            ) -> ::std::result::Result<(), $crate::StoreError> {
202                Ok(())
203            }
204
205            fn list_uncommitted(
206                &self,
207                _older_than_epoch_ms: u64,
208            ) -> ::std::result::Result<Vec<$crate::AttachmentManifestEntry>, $crate::StoreError>
209            {
210                Ok(Vec::new())
211            }
212
213            fn forget(
214                &self,
215                _session_id: &str,
216                _attachment_id: &$crate::AttachmentId,
217            ) -> ::std::result::Result<(), $crate::StoreError> {
218                Ok(())
219            }
220
221            fn holds_ref(
222                &self,
223                _session_id: &str,
224                _attachment_id: &$crate::AttachmentId,
225            ) -> ::std::result::Result<bool, $crate::StoreError> {
226                Ok(true)
227            }
228
229            fn list_all_refs(
230                &self,
231            ) -> ::std::result::Result<Vec<$crate::AttachmentId>, $crate::StoreError> {
232                Ok(Vec::new())
233            }
234        }
235    };
236}