Skip to main content

omgbase_store/
mint.rs

1//! Uniqueness at mint (`spec/store/README.md` §2.1, store 13.5): every id
2//! the store hands out goes through [`Mint`], which draws candidates from the
3//! inner [`IdMinter`] and redraws while a candidate is **in use** — already
4//! issued by this store in this process, or naming a row of the prefix's
5//! table(s). 32⁷ candidates make a random collision certain by a few hundred
6//! thousand blocks (§10: a 3,000-document corpus failed at 360,338), so the
7//! check is what makes the production minter safe; the fixture minters never
8//! collide on a fresh database and pass through unchanged.
9
10use std::collections::HashSet;
11use std::fmt;
12
13use rusqlite::{Connection, params};
14
15use crate::error::{Error, Result};
16use crate::ids::IdMinter;
17
18/// Consecutive rejections after which a mint gives up. The CSPRNG cannot hit
19/// this (32⁷ candidates against at most millions in use); only a minter that
20/// can never produce a fresh id — a broken fixture minter — does, and it must
21/// fail loudly rather than spin forever.
22pub const MINT_GIVE_UP_AFTER: usize = 1_000;
23
24/// §2.1: the table(s) and id column a minted id of each prefix must not
25/// already name a row of. A block id lives on in history after its `blocks`
26/// row is gone, hence `block_changes` and `resurrection_pool`. `col` and `v`
27/// have no table and no check.
28const ID_TABLES: &[(&str, &[(&str, &str)])] = &[
29    ("d", &[("docs", "doc_id")]),
30    (
31        "b",
32        &[
33            ("blocks", "block_id"),
34            ("block_changes", "block_id"),
35            ("resurrection_pool", "block_id"),
36        ],
37    ),
38    ("c", &[("commits", "commit_id")]),
39    ("r", &[("revisions", "rev_id")]),
40    ("x", &[("external_nodes", "node_id")]),
41    ("e", &[("edges", "edge_id")]),
42    ("cp", &[("checkpoints", "id")]),
43    ("rp", &[("repos", "repo_id")]),
44    ("src", &[("sources", "source_id")]),
45];
46
47/// The tables an id with `prefix` is checked against (empty for `col`, `v`
48/// and any prefix the store does not know).
49#[must_use]
50pub fn tables_for(prefix: &str) -> &'static [(&'static str, &'static str)] {
51    ID_TABLES
52        .iter()
53        .find(|(p, _)| *p == prefix)
54        .map_or(&[], |(_, tables)| tables)
55}
56
57/// §2.1: does `id` name a row of the prefix's table(s) on `conn`? The
58/// statements are cached on the connection, so a check is one indexed probe
59/// per table.
60pub fn id_in_use(conn: &Connection, prefix: &str, id: &str) -> Result<bool> {
61    for (table, column) in tables_for(prefix) {
62        let mut stmt = conn.prepare_cached(&format!(
63            "SELECT 1 FROM {table} WHERE {column} = ?1 LIMIT 1"
64        ))?;
65        if stmt.exists(params![id])? {
66            return Ok(true);
67        }
68    }
69    Ok(false)
70}
71
72/// A store's id source: the inner minter and every id it has issued in this
73/// process (the ids of a transaction in flight are not yet rows, and an id
74/// the fixture's repeating minter offers again must be caught before the
75/// tables know it).
76pub(crate) struct IdSource {
77    inner: Box<dyn IdMinter>,
78    issued: HashSet<String>,
79}
80
81impl IdSource {
82    pub(crate) fn new(inner: Box<dyn IdMinter>) -> Self {
83        Self {
84            inner,
85            issued: HashSet::new(),
86        }
87    }
88
89    /// The checking minter over `conn` — the store's connection, or a
90    /// transaction on it (a [`rusqlite::Transaction`] derefs to one).
91    pub(crate) fn at<'a>(&'a mut self, conn: &'a Connection) -> Mint<'a> {
92        Mint {
93            conn,
94            inner: &mut *self.inner,
95            issued: &mut self.issued,
96        }
97    }
98}
99
100/// The checking minter (§2.1): what every writer mints through.
101pub struct Mint<'a> {
102    conn: &'a Connection,
103    inner: &'a mut dyn IdMinter,
104    issued: &'a mut HashSet<String>,
105}
106
107impl fmt::Debug for Mint<'_> {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("Mint")
110            .field("issued", &self.issued.len())
111            .finish_non_exhaustive()
112    }
113}
114
115impl Mint<'_> {
116    /// Mint an id with `prefix`: draw from the inner minter and redraw while
117    /// the candidate is in use. Fails with [`Error::MintExhausted`] after
118    /// [`MINT_GIVE_UP_AFTER`] consecutive rejections.
119    pub fn mint(&mut self, prefix: &str) -> Result<String> {
120        for _ in 0..MINT_GIVE_UP_AFTER {
121            let candidate = self.inner.mint(prefix);
122            if self.issued.contains(&candidate) || id_in_use(self.conn, prefix, &candidate)? {
123                continue;
124            }
125            self.issued.insert(candidate.clone());
126            return Ok(candidate);
127        }
128        Err(Error::MintExhausted {
129            prefix: prefix.to_owned(),
130            rejected: MINT_GIVE_UP_AFTER,
131        })
132    }
133
134    /// A shorter-lived handle on the same minter.
135    pub fn reborrow(&mut self) -> Mint<'_> {
136        Mint {
137            conn: self.conn,
138            inner: &mut *self.inner,
139            issued: &mut *self.issued,
140        }
141    }
142
143    /// This minter behind the kernels' infallible `mint()` for `prefix`
144    /// (see [`Deferred`]).
145    pub fn deferred(&mut self, prefix: &'static str) -> Deferred<'_> {
146        Deferred {
147            mint: self.reborrow(),
148            prefix,
149            error: None,
150        }
151    }
152}
153
154/// A [`Mint`] behind the infallible [`omgbase_reconcile::Minter`] the
155/// reconcile and mutate kernels take: the first failure is kept and an empty
156/// id returned in its place; the caller must call [`Deferred::finish`] once
157/// the kernel returns, which surfaces that failure so the result minted
158/// against it is never written.
159pub struct Deferred<'a> {
160    mint: Mint<'a>,
161    prefix: &'static str,
162    error: Option<Error>,
163}
164
165impl fmt::Debug for Deferred<'_> {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.debug_struct("Deferred")
168            .field("prefix", &self.prefix)
169            .field("error", &self.error)
170            .finish_non_exhaustive()
171    }
172}
173
174impl Deferred<'_> {
175    /// `Ok` when every mint succeeded, else the first failure.
176    pub fn finish(self) -> Result<()> {
177        self.error.map_or(Ok(()), Err)
178    }
179}
180
181impl omgbase_reconcile::Minter for Deferred<'_> {
182    fn mint(&mut self) -> String {
183        if self.error.is_some() {
184            return String::new();
185        }
186        match self.mint.mint(self.prefix) {
187            Ok(id) => id,
188            Err(e) => {
189                self.error = Some(e);
190                String::new()
191            }
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::Store;
200    use crate::ids::{RandomMinter, RepeatingMinter, SequentialMinter, is_valid_id};
201
202    #[test]
203    fn every_prefix_with_a_table_is_checked_and_the_rest_are_not() {
204        for p in ["d", "b", "c", "r", "x", "e", "cp", "rp", "src"] {
205            assert!(!tables_for(p).is_empty(), "{p}");
206        }
207        assert!(tables_for("col").is_empty());
208        assert!(tables_for("v").is_empty());
209        assert_eq!(
210            tables_for("b").len(),
211            3,
212            "blocks, block_changes, resurrection_pool"
213        );
214    }
215
216    #[test]
217    fn every_checked_table_and_column_exists() {
218        let store = Store::open_in_memory().unwrap();
219        for (prefix, _) in ID_TABLES {
220            assert!(!id_in_use(store.conn(), prefix, "zz_0000000").unwrap());
221        }
222    }
223
224    #[test]
225    fn a_row_in_the_table_rejects_the_candidate() {
226        let mut store =
227            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
228        // Planted directly, so the issued set has never seen it.
229        store
230            .conn()
231            .execute(
232                "INSERT INTO repos (repo_id, slug) VALUES ('rp_0', 'planted')",
233                [],
234            )
235            .unwrap();
236        assert!(id_in_use(store.conn(), "rp", "rp_0").unwrap());
237        assert_eq!(
238            store.mint("rp").unwrap(),
239            "rp_1",
240            "rp_0 is in use; the redraw is rp_1"
241        );
242        assert_eq!(store.mint("col").unwrap(), "col_0", "col has no table");
243    }
244
245    #[test]
246    fn a_block_id_in_history_stays_in_use_after_its_row_is_gone() {
247        let mut store =
248            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
249        let repo = store.create_repo("r").unwrap();
250        store
251            .conn()
252            .execute(
253                "INSERT INTO docs (doc_id, repo_id, path) VALUES ('d_9', ?1, 'a.md')",
254                params![repo],
255            )
256            .unwrap();
257        let (commit, _) = store
258            .new_commit(&crate::NewCommit::observed(
259                &repo,
260                "2026-09-26T00:00:00.000Z",
261            ))
262            .unwrap();
263        store
264            .conn()
265            .execute(
266                "INSERT INTO block_changes (block_id, commit_id, kind) VALUES ('b_0', ?1, 'deleted')",
267                params![commit],
268            )
269            .unwrap();
270        store
271            .conn()
272            .execute(
273                "INSERT INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
274                 VALUES ('b_1', ?1, 'd_9', zeroblob(32), zeroblob(32), 'paragraph', ?2, '2026-10-26T00:00:00.000Z')",
275                params![repo, commit],
276            )
277            .unwrap();
278        assert_eq!(
279            store.mint("b").unwrap(),
280            "b_2",
281            "b_0 and b_1 live on in history"
282        );
283    }
284
285    #[test]
286    fn the_issued_set_catches_a_repeat_before_any_row_exists() {
287        let mut store =
288            Store::open_in_memory_with_minter(Box::new(RepeatingMinter::new())).unwrap();
289        assert_eq!(store.mint("b").unwrap(), "b_0");
290        assert_eq!(
291            store.mint("b").unwrap(),
292            "b_1",
293            "the second b_0 offer is rejected"
294        );
295        assert_eq!(store.mint("d").unwrap(), "d_0");
296        assert_eq!(store.create_repo("fixture").unwrap(), "rp_0");
297    }
298
299    #[test]
300    fn the_random_minter_is_checked_and_valid() {
301        let mut store = Store::open_in_memory().unwrap();
302        let ids: HashSet<String> = (0..2000).map(|_| store.mint("b").unwrap()).collect();
303        assert_eq!(ids.len(), 2000);
304        assert!(ids.iter().all(|id| is_valid_id(id, Some("b"))));
305        let _ = RandomMinter;
306    }
307
308    #[test]
309    fn a_minter_that_cannot_produce_a_fresh_id_fails_after_the_bound() {
310        let stuck = |prefix: &str| format!("{prefix}_stuck");
311        let mut store = Store::open_in_memory_with_minter(Box::new(stuck)).unwrap();
312        assert_eq!(store.mint("b").unwrap(), "b_stuck");
313        let err = store.mint("b").unwrap_err();
314        assert!(
315            matches!(&err, Error::MintExhausted { prefix, rejected } if prefix == "b" && *rejected == MINT_GIVE_UP_AFTER),
316            "{err}"
317        );
318        assert!(err.to_string().contains("1000"), "{err}");
319    }
320
321    #[test]
322    fn deferred_keeps_the_first_failure_for_finish() {
323        use omgbase_reconcile::Minter as _;
324        let stuck = |prefix: &str| format!("{prefix}_stuck");
325        let mut store = Store::open_in_memory_with_minter(Box::new(stuck)).unwrap();
326        let mut mint = store.minter();
327        let mut deferred = mint.deferred("b");
328        assert_eq!(deferred.mint(), "b_stuck");
329        assert_eq!(deferred.mint(), "", "the failure is deferred");
330        assert_eq!(deferred.mint(), "", "and nothing more is drawn");
331        assert!(deferred.finish().is_err());
332        let mut ok = store.minter();
333        let mut deferred = ok.deferred("d");
334        assert_eq!(deferred.mint(), "d_stuck");
335        assert!(deferred.finish().is_ok());
336    }
337}