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/// 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}