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