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}