Skip to main content

gonzalo_core/
store.rs

1//! The generic storage substrate trait and write-outcome types.
2
3use crate::{ContentHash, Identity, Record, RecordKey, Result, Revision};
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use std::time::{Duration, SystemTime};
7
8/// A detected concurrent-edit conflict: the caller's write expected
9/// `expected` to be the current revision, but the store holds `current`.
10/// Surfaced, never silently resolved.
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Conflict {
13    pub key: RecordKey,
14    pub expected: Option<Revision>,
15    pub current: Record,
16}
17
18/// The outcome of a conditional write. `Conflict` is a normal, recoverable
19/// result — not an error.
20#[derive(Clone, Debug, PartialEq, Eq)]
21#[must_use = "a PutResult may be a Conflict that must be handled, never silently dropped"]
22pub enum PutResult {
23    Committed(Revision),
24    Conflict(Box<Conflict>),
25}
26
27/// The outcome of a conditional delete. Like a `Conflict` from `put`, a
28/// `Conflict` here is a normal, recoverable result — not an error.
29#[derive(Clone, Debug, PartialEq, Eq)]
30#[must_use = "a DeleteResult may be a Conflict that must be handled, never silently dropped"]
31pub enum DeleteResult {
32    /// The key is now absent: the record was removed, or there was nothing to
33    /// remove (`expected == None`, or an `expected` revision that was already
34    /// gone). Idempotent.
35    Deleted,
36    /// `expected` was supplied but the store's current revision differs; the
37    /// record was left untouched and `current` holds the live record.
38    Conflict(Box<Conflict>),
39}
40
41/// A pluggable storage substrate over generic records.
42#[async_trait]
43pub trait Store: Send + Sync {
44    /// Fetch a record by key. Returns `None` for an absent key **and** for a
45    /// tombstoned one: a delete is invisible on this surface, and only
46    /// [`get_raw`](Store::get_raw) shows the tombstone. See ADR 0021.
47    async fn get(&self, key: &RecordKey) -> Result<Option<Record>>;
48
49    /// Conditionally write `record`. `expected` is the revision the caller
50    /// believes is current (`None` means "expect no existing record"). If the
51    /// store's current revision differs, returns `PutResult::Conflict`.
52    ///
53    /// A tombstoned key counts as absent (ADR 0021, spec §8.5):
54    /// - `expected == None` **recreates** the key, with one exception: a
55    ///   *manifest* tombstone (`deleted_kind` of `GraphManifest` or
56    ///   `VectorManifest`) holds a restore window, so a create over it is
57    ///   refused with `Err(CoreError::Invalid)` (ADR 0030). Restore it with
58    ///   `gonzalo undelete`, or discard it with `gonzalo purge`; once `collect`
59    ///   removes the tombstone the key is free again. Otherwise the store
60    ///   re-stamps `record.revision` to continue the chain past the tombstone,
61    ///   so the caller must read the real revision back from
62    ///   `PutResult::Committed` rather than reuse the one it built.
63    /// - Any `Some(_)` — including the tombstone's own revision, which
64    ///   consumers never learn — is `Err(CoreError::NotFound)`.
65    ///
66    /// A create over a manifest tombstone is therefore a second error case
67    /// (`Err(CoreError::Invalid)`, which the daemon answers 400 /
68    /// `InvalidArgument`), alongside the following one.
69    ///
70    /// A `record` whose `kind` is `RecordKind::Tombstone` is always rejected
71    /// with an error, on every `current` state: deletes go through
72    /// [`delete_as`](Store::delete_as), and replication writes tombstones
73    /// through [`put_raw`](Store::put_raw).
74    async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult>;
75
76    /// List keys matching `prefix`, excluding tombstoned keys. See
77    /// [`list_raw`](Store::list_raw) for a listing that includes them.
78    async fn list(&self, prefix: &crate::KeyPrefix) -> Result<Vec<RecordKey>>;
79
80    /// Conditionally delete the record at `key`. `expected` is the revision the
81    /// caller believes is current: `None` deletes unconditionally; `Some(rev)`
82    /// deletes only if the current revision matches, else
83    /// `DeleteResult::Conflict`. Deleting an absent key is a no-op `Deleted`.
84    /// `Some(author)` records who deleted (stores that write tombstones stamp
85    /// it on the tombstone's `meta.author`). See ADR 0018 and ADR 0021.
86    async fn delete_as(
87        &self,
88        key: &RecordKey,
89        expected: Option<Revision>,
90        author: Option<Identity>,
91    ) -> Result<DeleteResult>;
92
93    /// [`delete_as`](Store::delete_as) without an author. Provided; stores
94    /// implement `delete_as`.
95    async fn delete(&self, key: &RecordKey, expected: Option<Revision>) -> Result<DeleteResult> {
96        self.delete_as(key, expected, None).await
97    }
98
99    /// Like [`get`](Store::get), but also returns tombstones. For replication
100    /// (sync, pull, collection) only: consumers must use `get`. A store must
101    /// never implement this by calling `get`, which would hide the deletions
102    /// replication exists to carry. See ADR 0021.
103    async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>>;
104
105    /// Like [`list`](Store::list), but also includes tombstoned keys. For
106    /// replication only. See ADR 0021.
107    async fn list_raw(&self, prefix: &crate::KeyPrefix) -> Result<Vec<RecordKey>>;
108
109    /// Replication write: store `record` exactly as given, never re-stamping.
110    /// A create (`expected == None`) that finds anything stored, tombstones
111    /// included, is a `Conflict` carrying it. Sync and pull use this; consumers
112    /// must use `put`. A store must never implement this by calling `put`,
113    /// whose recreation rule would resurrect records mid-sync. See ADR 0021.
114    async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult>;
115
116    /// Physically remove the record at `key` only if its current revision is
117    /// `expected`; otherwise `DeleteResult::Conflict`. Absent is an idempotent
118    /// `Deleted`. The only physical removal in the system: used by tombstone
119    /// collection, which must pass the tombstone's revision so a record
120    /// recreated in the meantime survives. See ADR 0021.
121    async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult>;
122}
123
124/// One stored blob and when it was last written.
125///
126/// The timestamp is Unix milliseconds rather than a [`SystemTime`] because this
127/// type crosses the daemon's HTTP and gRPC surfaces, where milliseconds are the
128/// wire form; keeping one type avoids a near-identical DTO in the client and the
129/// server. Use [`from_system_time`](Self::from_system_time) and
130/// [`age`](Self::age) rather than reading the field arithmetically.
131#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub struct BlobEntry {
133    pub hash: ContentHash,
134    pub modified_unix_ms: i64,
135}
136
137impl BlobEntry {
138    pub fn from_system_time(hash: ContentHash, modified: SystemTime) -> Self {
139        let modified_unix_ms = match modified.duration_since(std::time::UNIX_EPOCH) {
140            // Saturate toward the future: a wrapped value would read as very old,
141            // the one direction that deletes data.
142            Ok(d) => i64::try_from(d.as_millis()).unwrap_or(i64::MAX),
143            // Before the epoch: a bogus or wildly-wrong mtime. Represent it
144            // faithfully as a negative rather than clamping, so an ordinary
145            // negative reads as very old (collectable). In the saturating
146            // sub-case (`-i64::MAX`) `age()`'s `checked_sub` overflows and
147            // returns `None`, so that blob is KEPT.
148            Err(e) => -i64::try_from(e.duration().as_millis()).unwrap_or(i64::MAX),
149        };
150        Self {
151            hash,
152            modified_unix_ms,
153        }
154    }
155
156    /// How old this blob is at `now`, or `None` when it is dated in the future.
157    ///
158    /// Callers treat `None` as "too young to sweep": a blob dated ahead of the
159    /// GC host's clock must not be deleted on the strength of a clock
160    /// disagreement.
161    pub fn age(&self, now: SystemTime) -> Option<Duration> {
162        let now_ms = match now.duration_since(std::time::UNIX_EPOCH) {
163            // Unlike `from_system_time`, saturating here reads as very OLD: a
164            // `now` of `i64::MAX` makes every blob look ancient and collectable.
165            // That is a documented exception (ADR 0028), unreachable with a sane
166            // clock, and deliberate; do not change the arithmetic.
167            Ok(d) => i64::try_from(d.as_millis()).unwrap_or(i64::MAX),
168            Err(e) => -i64::try_from(e.duration().as_millis()).unwrap_or(i64::MAX),
169        };
170        let age_ms = now_ms.checked_sub(self.modified_unix_ms)?;
171        if age_ms < 0 {
172            return None;
173        }
174        Some(Duration::from_millis(age_ms as u64))
175    }
176}
177
178/// A content-addressed blob store for out-of-line record bodies
179/// ([`Body::Blob`]). Content is keyed by its [`ContentHash`], so byte-identical
180/// bodies — e.g. code-graph slices shared across worktrees (ADR 0012) — are
181/// stored once. Writes are **write-if-absent**: storing content that already
182/// exists is an idempotent no-op, never a conflict (same hash ⇒ same bytes).
183///
184/// [`Body::Blob`]: crate::Body::Blob
185#[async_trait]
186pub trait BlobStore: Send + Sync {
187    /// Store `content` addressed by its hash, write-if-absent, and return the
188    /// hash. Idempotent: storing identical content again is a no-op.
189    async fn put_blob(&self, content: &[u8]) -> Result<ContentHash>;
190
191    /// Fetch blob content by hash, or `None` if absent.
192    async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>>;
193
194    /// Every stored blob with the time it was last written. Order is
195    /// unspecified, and a hash may repeat — a caller that cares resolves a
196    /// duplicate to its newest timestamp. Used by GC to enumerate candidates
197    /// and decide which are old enough to sweep (ADR 0024, 0028).
198    async fn list_blobs(&self) -> Result<Vec<BlobEntry>>;
199
200    /// Whether `hash` is stored.
201    ///
202    /// Defaulted so this is not a breaking addition. The default fetches the
203    /// blob and throws the bytes away; substrates override it with a cheap
204    /// existence check. A writer uses this after committing to confirm the
205    /// blobs it referenced are still present (ADR 0028).
206    async fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
207        Ok(self.get_blob(hash).await?.is_some())
208    }
209
210    /// Delete the blob addressed by `hash`. Deleting an absent blob is an
211    /// idempotent no-op — GC may race another sweeper or a re-put.
212    async fn delete_blob(&self, hash: &ContentHash) -> Result<()>;
213}
214
215#[cfg(test)]
216mod tests {
217    #[test]
218    fn a_saturated_pre_epoch_timestamp_has_no_age_and_so_is_kept() {
219        // `from_system_time` saturates a pre-epoch mtime to `-i64::MAX`, which a
220        // `SystemTime` cannot portably express (it would need to be ~292 million
221        // years before the epoch), so the struct is built directly. `age()`'s
222        // `checked_sub` overflows and returns `None`, which the sweep treats as
223        // too young: the blob is kept, not collected.
224        let e = super::BlobEntry {
225            hash: super::ContentHash("h".into()),
226            modified_unix_ms: -i64::MAX,
227        };
228        assert_eq!(e.age(std::time::SystemTime::now()), None);
229    }
230
231    use super::*;
232    use std::time::{Duration, UNIX_EPOCH};
233
234    fn at(ms: u64) -> SystemTime {
235        UNIX_EPOCH + Duration::from_millis(ms)
236    }
237
238    #[test]
239    fn age_is_the_gap_between_modified_and_now() {
240        let e = BlobEntry::from_system_time(ContentHash("h".into()), at(1_000));
241        assert_eq!(e.age(at(4_000)), Some(Duration::from_millis(3_000)));
242    }
243
244    #[test]
245    fn age_of_a_blob_dated_in_the_future_is_none() {
246        // The GC host's clock behind the store's. `None` means "too young", so
247        // the sweep keeps the blob — ambiguity errs toward keeping data.
248        let e = BlobEntry::from_system_time(ContentHash("h".into()), at(9_000));
249        assert_eq!(e.age(at(1_000)), None);
250    }
251
252    #[test]
253    fn age_at_exactly_now_is_zero_not_none() {
254        let e = BlobEntry::from_system_time(ContentHash("h".into()), at(5_000));
255        assert_eq!(e.age(at(5_000)), Some(Duration::ZERO));
256    }
257
258    #[test]
259    fn a_pre_epoch_timestamp_round_trips_as_a_negative_and_still_ages() {
260        let before_epoch = UNIX_EPOCH - Duration::from_millis(500);
261        let e = BlobEntry::from_system_time(ContentHash("h".into()), before_epoch);
262        assert!(e.modified_unix_ms < 0);
263        assert_eq!(e.age(at(500)), Some(Duration::from_millis(1_000)));
264    }
265}