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}