Skip to main content

gunnar_core/
control.rs

1//! **Where the control plane's own state lives** — the catalog, the identity
2//! table, and every document [`crate::doc`] puts behind a name.
3//!
4//! # Why this is not the object store any more (the certificate-identity plan)
5//!
6//! Until 2026-08-09 a control document was a blob in the repository object
7//! store with a reference pointing at it — gunnar's own `ObjectStore` and
8//! `RefStore` traits, both since deleted in favour of `GitOps`: git's own
9//! trick, and defensible while the only durable backend gunnar had *was* a git
10//! directory. It stopped being defensible when the storage engine landed
11//! (the storage restart). That engine takes objects **as packs** — `GitOps::put_pack` is
12//! where the closure check and the durability ordering live — and it refuses a
13//! single-object write by design. So `principal add` and `namespace create`
14//! both died before they reached any of their own logic, with
15//!
16//! ```text
17//!   the znippy-git store takes objects as packs, not one at a time
18//! ```
19//!
20//! The fix is not a pack wrapper around a 200-byte JSON blob. User records were
21//! never git objects for a *reason*; they were git objects because the control
22//! plane predates anywhere better. This is that better place: an embedded
23//! key/value store with real transactions, which is what a compare-and-swap on
24//! a document wanted all along.
25//!
26//! redb adds nothing to the appliance that was not already there — it is the
27//! znippy engine's own tail — and it has no build script beyond a version probe
28//! and no dependencies of its own.
29//!
30//! # What is stored, and in which table
31//!
32//! ```text
33//!   documents          name            → 32-byte blob id      the current version
34//!   blobs              32-byte id      → bytes                content-addressed
35//!   document_history   (name, seq)     → millis ‖ blob id     append-only
36//!   secrets            key             → tag ‖ sealed bytes   crate::secrets
37//! ```
38//!
39//! The fourth table arrived on 2026-08-10 and finished the migration this one
40//! started: the SSH host key and the issuer private key were still in an Arrow
41//! log inside `control.git`, which is what kept `gunnar-server` opening a
42//! repository — and therefore needing a storage engine — in order to boot. It
43//! is a *table* here rather than a document because a secret is not a document:
44//! there is no compare-and-swap on it, no history worth keeping (a superseded
45//! host key is the defect, not the audit trail) and no content addressing to
46//! want. See [`crate::secrets`], which owns everything about its shape.
47//!
48//! **Content addressing is kept**, and not out of nostalgia for git. Three
49//! properties of [`crate::doc`] rest on it and would each have to be rebuilt
50//! without it: a shard that did not change keeps its id, so a document that did
51//! not change hashes to the same bytes and a no-op update is visible as *the
52//! pointer did not move* rather than as a new version; a losing writer's shards
53//! are simply unreferenced rather than needing a rollback; and a reader that
54//! follows a document to its shards can never find a leaf that was rewritten
55//! underneath it.
56//!
57//! # The two things moving off git objects cost (the certificate-identity plan)
58//!
59//! * **Replication.** Metadata used to replicate *as a repository*, by the same
60//!   mechanism as everything else: `control.git` mirrored, and the catalog and
61//!   identity table came with it. It does not any more. `<data-root>/control.redb`
62//!   is a file a standby has to be given some other way, and there is no
63//!   mechanism here that gives it one. **Named, not rebuilt.**
64//! * **History.** A ref update per change was a free audit trail: every previous
65//!   version of the identity table stayed in the object store with the reflog
66//!   ordering them. That is *not* free on a key/value store, so it is explicit:
67//!   [`DOC_HISTORY`] is appended in the same write transaction as the pointer
68//!   move, and [`ControlStore::history`] reads it back. Rebuilt, deliberately —
69//!   an ACL whose changes cannot be listed is an ACL nobody can audit, and the
70//!   whole table is one B-tree append per change.
71//!
72//! Nothing prunes either table. Old document versions and superseded shards
73//! accumulate exactly as loose objects did, which is the same bargain git makes
74//! and the same one a `gunnar control gc` would have to unmake.
75
76use std::path::Path;
77use std::sync::Arc;
78
79use redb::ReadableTable;
80
81use crate::error::{Error, Result};
82
83/// The current version of every named document.
84const DOCUMENTS: redb::TableDefinition<'static, &str, &[u8]> =
85    redb::TableDefinition::new("documents");
86
87/// Content-addressed bytes: documents themselves, and the shard blobs they
88/// point at.
89const BLOBS: redb::TableDefinition<'static, &[u8], &[u8]> = redb::TableDefinition::new("blobs");
90
91/// **The audit trail** (the certificate-identity plan): every version a
92/// document has ever had, in the order it had them, written in the same
93/// transaction as the pointer move.
94///
95/// Keyed `(document name, sequence)` so one document's history is one range
96/// scan and two documents cannot interleave. The value is the commit time in
97/// milliseconds since the epoch, big-endian, followed by the blob id.
98const DOC_HISTORY: redb::TableDefinition<'static, (&str, u64), &[u8]> =
99    redb::TableDefinition::new("document_history");
100
101/// **What this box has applied, by sequence number** — per box, never framed, never in a
102/// snapshot or an export (perf twin-apply-fsync, 2026-10-06). One row today: [`TWIN_APPLIED`].
103const MARKS: redb::TableDefinition<'static, &str, u64> = redb::TableDefinition::new("box_marks");
104
105/// **When each principal last signed in and last changed something** (owner,
106/// 2026-10-21) — principal id → its stamps ([`crate::activity`], JSON). Per box
107/// like [`MARKS`]: never framed, never in a snapshot or an export, and never in
108/// the identity document. Written by the activity flusher only, one transaction
109/// per flush that moved something.
110const PRINCIPAL_ACTIVITY: redb::TableDefinition<'static, &str, &[u8]> =
111    redb::TableDefinition::new("principal_activity");
112
113/// **The last transaction-log entry whose control-plane writes are in this database** — a
114/// twin's applied watermark. Committed in the SAME redb transaction as the writes it vouches
115/// for ([`ControlDb::commit_group`]), so after a crash the database is exactly the state through
116/// the marker, and the entries after it are re-applied in order from there. A twin used to re-apply
117/// only its log's head, which is exact only when every entry is made durable on its own; a
118/// group commit needs the marker.
119pub const TWIN_APPLIED: &str = "twin.applied";
120
121/// **What a write closure decided** ([`ControlDb::write`]): commit what it wrote, or discard
122/// the transaction — which a closure may only do when it wrote NOTHING (a lost compare-and-swap,
123/// a secret that already exists), because inside a group ([`ControlDb::begin_group`]) a discard
124/// cannot take back what the transaction already holds.
125pub(crate) enum Txn<T> {
126    /// Keep the writes.
127    Commit(T),
128    /// Keep the writes WITHOUT an fsync of their own: durable with the next durable commit
129    /// ([`ControlStore::put_blob_unsynced`]).
130    CommitUnsynced(T),
131    /// Nothing was written; nothing to keep.
132    Discard(T),
133}
134
135/// **One redb write transaction shared by a run of writes on ONE thread** (perf
136/// twin-apply-fsync, 2026-10-06): a twin takes a whole group of log entries into it and commits
137/// it once — each B-tree page it touches is written once, and the group costs one fsync.
138struct Group {
139    owner: std::thread::ThreadId,
140    txn: redb::WriteTransaction,
141    /// A write committed into it.
142    dirty: bool,
143    /// A write failed half-way: the group is aborted whole at its end.
144    poisoned: bool,
145}
146
147/// How many bytes a [`BlobId`] is.
148pub const BLOB_ID_LEN: usize = 32;
149
150/// A content address: sha-256 over the stored bytes.
151///
152/// Not a `gix_hash::ObjectId`. The old id was a *git* object id because the
153/// bytes were in a git object database and git had to be able to name them.
154/// Nothing here is git-visible, so the id is the hash and nothing else — no
155/// object kind, no `blob <len>\0` header, and no hash-kind negotiation with a
156/// repository that is not involved.
157#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct BlobId([u8; BLOB_ID_LEN]);
159
160impl BlobId {
161    /// The address of these bytes.
162    pub fn of(bytes: &[u8]) -> Self {
163        use sha2::Digest;
164        let mut hasher = sha2::Sha256::new();
165        hasher.update(bytes);
166        BlobId(hasher.finalize().into())
167    }
168
169    /// The raw address.
170    pub fn as_bytes(&self) -> &[u8; BLOB_ID_LEN] {
171        &self.0
172    }
173
174    /// Parse the hex form. `None` if it is not [`BLOB_ID_LEN`] bytes of hex.
175    pub fn from_hex(text: &str) -> Option<Self> {
176        let mut out = [0u8; BLOB_ID_LEN];
177        hex::decode_to_slice(text, &mut out).ok()?;
178        Some(BlobId(out))
179    }
180
181    /// Rebuild from raw bytes, as they come back out of a table.
182    fn from_slice(bytes: &[u8]) -> Option<Self> {
183        Some(BlobId(bytes.try_into().ok()?))
184    }
185}
186
187impl std::fmt::Display for BlobId {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.write_str(&hex::encode(self.0))
190    }
191}
192
193impl std::fmt::Debug for BlobId {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        write!(f, "BlobId({self})")
196    }
197}
198
199/// One version a document has had, out of [`ControlStore::history`].
200#[derive(Clone, PartialEq, Eq, Debug)]
201pub struct DocVersion {
202    /// Its position in the document's history, from 1.
203    pub seq: u64,
204    /// When it was committed, in milliseconds since the unix epoch.
205    pub at: crate::model::UnixMillis,
206    /// The version itself, still readable through
207    /// [`ControlStore::get_blob`] — nothing prunes the blob table.
208    pub blob: BlobId,
209}
210
211/// **Where a control document is kept.**
212///
213/// A trait rather than the concrete store, for one reason, and it is measure,
214/// never assert:
215/// `tests/control_cost.rs` proves the compare-and-swap by running the same
216/// concurrency test against a store that *ignores the expectation*, in the same
217/// run, and asserting that acknowledged writes are lost. A guard that cannot be
218/// broken is a guard nobody has seen fail. The counting decorator that measures
219/// what a connection costs lives on the same seam.
220pub trait ControlStore: Send + Sync + 'static {
221    /// Store these bytes and return their address. Idempotent: storing the same
222    /// bytes twice is one row and the same id.
223    fn put_blob(&self, bytes: &[u8]) -> Result<BlobId>;
224
225    /// **[`ControlStore::put_blob`] for a blob that only a LATER [`ControlStore::commit_doc`]
226    /// makes part of any state** (perf twin-apply-fsync, 2026-10-06): the store may keep it
227    /// without making it durable on its own, because the pointer move that publishes it is
228    /// durable and persists it — redb's durable commit persists every earlier commit. Until then
229    /// it is an unreferenced, content-addressed row nothing can reach, and a crash that takes it
230    /// takes nothing anybody could see. The default is the durable `put_blob`.
231    ///
232    /// The primary's tee uses it for a document's shards. A replay that re-applies only its
233    /// head entry after a crash (a box being restored) must NOT: its blob frames are durable
234    /// one by one and the document that publishes them may be several frames on.
235    fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
236        self.put_blob(bytes)
237    }
238
239    /// The bytes at this address, or `None` if nothing was ever stored there.
240    fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>>;
241
242    /// Which version `name` is at, or `None` for a document that has never been
243    /// written.
244    ///
245    /// **`None` is a legitimate state and a fault is not.** A read that could
246    /// not distinguish them would report an unreadable identity table as *no
247    /// principals are registered*, which is the failure that opens a server up.
248    fn read_doc(&self, name: &str) -> Result<Option<BlobId>>;
249
250    /// **The compare-and-swap.** Move `name` from `expected` to `new`, and
251    /// append the new version to the audit trail, atomically.
252    ///
253    /// `Ok(false)` means the document was not at `expected` — another writer
254    /// committed in between — and the caller must re-read and re-apply. It is
255    /// deliberately not an error: losing a race is an ordinary outcome of a
256    /// contended document and a caller that retried on `Err` would also retry
257    /// on a broken disk.
258    fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool>;
259
260    /// Every version `name` has had, oldest first (the control plane's audit trail).
261    fn history(&self, name: &str) -> Result<Vec<DocVersion>>;
262}
263
264/// **The RAM copy of the control database**, taken by [`ControlDb::suspend`]
265/// the instant before `control.redb`'s file is closed and held until
266/// [`ControlDb::resume`] reopens it. Every current document pointer, every
267/// blob (current versions, their shards, and the history — the blobs table
268/// whole, because a shard is reached from a document by id and nothing
269/// short of the table says which ids a document reaches), and every sealed
270/// secret row. Reads are answered from it verbatim; writes are refused by
271/// name ([`Error::OffStripe`]).
272#[derive(Debug, Clone)]
273pub struct RamCopy {
274    /// Why the store is off the stripe — the growth's phase, in words.
275    pub why: String,
276    docs: std::collections::BTreeMap<String, BlobId>,
277    blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
278    secrets: std::collections::BTreeMap<String, Vec<u8>>,
279    /// When it was taken, unix ms.
280    pub taken_unix_ms: crate::model::UnixMillis,
281}
282
283impl RamCopy {
284    /// How many bytes the copy holds (blobs and secret rows).
285    pub fn bytes(&self) -> u64 {
286        self.blobs.values().map(|b| b.len() as u64).sum::<u64>()
287            + self.secrets.values().map(|b| b.len() as u64).sum::<u64>()
288    }
289    /// How many documents have a current version.
290    pub fn documents(&self) -> usize {
291        self.docs.len()
292    }
293    /// The current pointer of every document, name → blob id hex — what
294    /// [`ControlDb::resume`] compares the disk against.
295    pub fn pointers(&self) -> Vec<(String, String)> {
296        self.docs.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()
297    }
298    /// A secret's raw row (tag ‖ sealed bytes), for the secret store.
299    pub(crate) fn secret_row(&self, key: &str) -> Option<&[u8]> {
300        self.secrets.get(key).map(|v| v.as_slice())
301    }
302}
303
304/// **The control database's current state, read without taking it off the stripe**: every
305/// document pointer, every blob, every secret row (`None` = a tombstone; values are the SEALED
306/// bytes). What a twin snapshot carries and what a restored box re-seals.
307#[derive(Debug, Clone, Default)]
308pub struct ControlExport {
309    /// Document name → current blob.
310    pub docs: std::collections::BTreeMap<String, BlobId>,
311    /// Every blob, current and historical.
312    pub blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
313    /// Secret key → sealed value, or `None` for a tombstone.
314    pub secrets: std::collections::BTreeMap<String, Option<Vec<u8>>>,
315}
316
317impl ControlDb {
318    /// [`ControlExport`] of the live database (or of the RAM copy while suspended), in one
319    /// read transaction.
320    pub fn export(&self) -> Result<ControlExport> {
321        let raw = self.read(
322            "exporting the control database",
323            |txn| {
324                let mut docs = std::collections::BTreeMap::new();
325                let mut blobs = std::collections::BTreeMap::new();
326                let table = txn.open_table(DOCUMENTS).map_err(|err| faulted("opening the documents table", err))?;
327                for entry in table.iter().map_err(|err| faulted("scanning the documents table", err))? {
328                    let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
329                    if let Some(id) = BlobId::from_slice(v.value()) {
330                        docs.insert(k.value().to_owned(), id);
331                    }
332                }
333                let table = txn.open_table(BLOBS).map_err(|err| faulted("opening the blobs table", err))?;
334                for entry in table.iter().map_err(|err| faulted("scanning the blobs table", err))? {
335                    let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
336                    if let Some(id) = BlobId::from_slice(k.value()) {
337                        blobs.insert(id, v.value().to_vec());
338                    }
339                }
340                Ok((docs, blobs, crate::secrets::RedbSecretStore::rows(txn)?))
341            },
342            |ram| Ok((ram.docs.clone(), ram.blobs.clone(), ram.secrets.clone())),
343        )?;
344        let (docs, blobs, rows) = raw;
345        let mut secrets = std::collections::BTreeMap::new();
346        for (key, row) in rows {
347            let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &row).map_err(|err| Error::ControlStore(err.to_string()))?;
348            secrets.insert(key, value);
349        }
350        Ok(ControlExport { docs, blobs, secrets })
351    }
352}
353
354/// One row of [`ControlDb::export_each`], borrowed from the read transaction (or the RAM copy):
355/// nothing of the database is gathered to hand it over.
356#[derive(Debug, Clone, Copy)]
357pub enum ExportRow<'a> {
358    /// A blob, current or historical.
359    Blob(BlobId, &'a [u8]),
360    /// A document's current pointer.
361    Doc(&'a str, BlobId),
362    /// A secret row: the sealed value, or `None` for a tombstone.
363    Secret(&'a str, Option<&'a [u8]>),
364}
365
366impl ControlDb {
367    /// **[`ControlDb::export`], row by row** — every blob, then every document pointer, then
368    /// every secret row, each handed to `row` as it is read, in one read transaction. What a
369    /// twin snapshot streams: the database never lies in memory whole. `row`'s error stops the
370    /// scan and comes back as `Ok(Err(e))`; the database's own faults are the outer error.
371    pub fn export_each<E>(&self, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
372        let row = std::cell::RefCell::new(row);
373        self.read(
374            "exporting the control database row by row",
375            |txn| export_txn(txn, &mut **row.borrow_mut()),
376            |ram| export_ram(ram, &mut **row.borrow_mut()),
377        )
378    }
379
380    /// **A consistent view of the whole database, taken NOW and read LATER** (R5.1-A,
381    /// 2026-10-07): a redb read transaction (MVCC — it holds the state of this instant while
382    /// writers go on), or the RAM copy while the store is off the stripe. What a snapshot takes
383    /// under the log's freeze, so it can stream the rows AFTER the freeze is gone.
384    ///
385    /// Said plainly: while a view lives, redb keeps the pages it reads and the file stays open —
386    /// a growth that takes the store off the stripe meanwhile cannot unmount until the view goes.
387    pub fn view(&self) -> Result<ControlView> {
388        if let Some(copy) = read_lock(&self.ram).as_ref() {
389            return Ok(ControlView(View::Ram(Box::new(copy.clone()))));
390        }
391        let guard = read_lock(&self.live);
392        let Some(db) = guard.as_ref() else {
393            return Err(self.off_stripe());
394        };
395        let txn = db
396            .begin_read()
397            .map_err(|err| faulted("beginning a view of the control database", err))?;
398        Ok(ControlView(View::Live(Box::new(txn))))
399    }
400}
401
402/// See [`ControlDb::view`].
403pub struct ControlView(View);
404
405enum View {
406    Live(Box<redb::ReadTransaction>),
407    Ram(Box<RamCopy>),
408}
409
410impl ControlView {
411    /// **Every last-seen row at the view's instant** ([`crate::activity`]):
412    /// what a snapshot carries. Empty for a database that never kept one and
413    /// for the RAM copy.
414    pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
415        match &self.0 {
416            View::Live(txn) => activity_rows(txn),
417            View::Ram(_) => Ok(Vec::new()),
418        }
419    }
420
421    /// [`ControlDb::export_each`] over the state the view was taken at.
422    pub fn export_each<E>(&self, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
423        match &self.0 {
424            View::Live(txn) => export_txn(txn, row),
425            View::Ram(ram) => export_ram(ram, row),
426        }
427    }
428}
429
430/// Every row of the last-seen table in `txn`; none when it was never made.
431fn activity_rows(txn: &redb::ReadTransaction) -> Result<Vec<(String, Vec<u8>)>> {
432    let table = match txn.open_table(PRINCIPAL_ACTIVITY) {
433        Ok(t) => t,
434        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
435        Err(err) => return Err(faulted("opening the last-seen table", err)),
436    };
437    let mut rows = Vec::new();
438    for entry in table.iter().map_err(|err| faulted("scanning the last-seen table", err))? {
439        let (k, v) = entry.map_err(|err| faulted("reading a last-seen row", err))?;
440        rows.push((k.value().to_owned(), v.value().to_vec()));
441    }
442    Ok(rows)
443}
444
445fn export_txn<E>(txn: &redb::ReadTransaction, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
446    let table = txn.open_table(BLOBS).map_err(|err| faulted("opening the blobs table", err))?;
447    for entry in table.iter().map_err(|err| faulted("scanning the blobs table", err))? {
448        let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
449        if let Some(id) = BlobId::from_slice(k.value()) {
450            if let Err(e) = row(ExportRow::Blob(id, v.value())) {
451                return Ok(Err(e));
452            }
453        }
454    }
455    let table = txn.open_table(DOCUMENTS).map_err(|err| faulted("opening the documents table", err))?;
456    for entry in table.iter().map_err(|err| faulted("scanning the documents table", err))? {
457        let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
458        if let Some(id) = BlobId::from_slice(v.value()) {
459            if let Err(e) = row(ExportRow::Doc(k.value(), id)) {
460                return Ok(Err(e));
461            }
462        }
463    }
464    for (key, raw) in crate::secrets::RedbSecretStore::rows(txn)? {
465        let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &raw).map_err(|err| Error::ControlStore(err.to_string()))?;
466        if let Err(e) = row(ExportRow::Secret(&key, value.as_deref())) {
467            return Ok(Err(e));
468        }
469    }
470    Ok(Ok(()))
471}
472
473fn export_ram<E>(ram: &RamCopy, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
474    for (id, bytes) in &ram.blobs {
475        if let Err(e) = row(ExportRow::Blob(*id, bytes)) {
476            return Ok(Err(e));
477        }
478    }
479    for (name, id) in &ram.docs {
480        if let Err(e) = row(ExportRow::Doc(name, *id)) {
481            return Ok(Err(e));
482        }
483    }
484    for (key, raw) in &ram.secrets {
485        let value = crate::secrets::RedbSecretStore::decode_row_of(key, raw).map_err(|err| Error::ControlStore(err.to_string()))?;
486        if let Err(e) = row(ExportRow::Secret(key, value.as_deref())) {
487            return Ok(Err(e));
488        }
489    }
490    Ok(Ok(()))
491}
492
493/// The most a RAM copy may weigh before [`ControlDb::suspend`] refuses:
494/// the control database is small by design (documents and shards, no git
495/// objects), and a copy past this is a sign something else is in it.
496pub const RAM_COPY_CEILING_BYTES: u64 = 512 << 20;
497
498/// What [`ControlDb::resume`] found when it compared the disk to the copy.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ResumeReport {
501    /// Documents whose pointer on disk equals the pointer in RAM.
502    pub documents_matched: usize,
503    /// `name: ram <hex> vs disk <hex|absent>` for every document that
504    /// differs — empty is the expected answer, since every write was refused
505    /// while the copy stood in.
506    pub drift: Vec<String>,
507    /// How long the store was off the stripe, ms.
508    pub off_stripe_ms: i64,
509}
510
511/// **The one `redb::Database` under the control plane — and the door the
512/// runtime data-set growth takes it off the stripe through.**
513///
514/// [`RedbControlStore`] and [`RedbSecretStore`] both hold an `Arc` of this
515/// rather than of the database, so that ONE call can close the file under
516/// every handle in the process and one call can reopen it, with nothing
517/// upstream — `AuthTable`, `RepoCatalog`, the SSH `Control`, the gRPC gate
518/// — holding a stale descriptor that would pin the mount. That is the whole
519/// mechanism behind `DATA-SET-GROWTH-FLOW.md` G4/T2: `control.redb` lives
520/// on the stripe, every SSH authentication reads it at call time, and the
521/// stripe has to be unmounted for the members to be grown. So:
522///
523/// * [`ControlDb::suspend`] takes the write side (waiting for in-flight
524///   transactions), copies the tables into a [`RamCopy`], and drops the
525///   database — the file is closed and `umount /data` can succeed;
526/// * while suspended every read is served from the copy, byte for byte, so
527///   `principal_by_fingerprint`, the grant check and the bootstrap state keep
528///   answering; every write is [`Error::OffStripe`];
529/// * [`ControlDb::resume`] reopens the file, compares every document
530///   pointer on disk with the copy (they must agree — no write got through)
531///   and drops the copy.
532///
533/// An in-memory database (tests, `--dry-run`) has no file to close: it is
534/// kept, and `suspend` still switches every reader onto the copy and every
535/// writer onto the refusal, so the semantics are testable without a disk.
536pub struct ControlDb {
537    path: Option<std::path::PathBuf>,
538    live: std::sync::RwLock<Option<redb::Database>>,
539    ram: std::sync::RwLock<Option<RamCopy>>,
540    /// The group transaction open on one thread, if any ([`ControlDb::begin_group`]).
541    group: std::sync::Mutex<Option<Group>>,
542    /// Durable commits (each one fsync) since open ([`ControlDb::durable_commits`]).
543    durable_commits: std::sync::atomic::AtomicU64,
544}
545
546impl std::fmt::Debug for ControlDb {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        f.debug_struct("ControlDb")
549            .field("path", &self.path)
550            .field("suspended", &self.is_suspended())
551            .finish()
552    }
553}
554
555fn read_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
556    lock.read().unwrap_or_else(|poison| poison.into_inner())
557}
558
559fn write_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
560    lock.write().unwrap_or_else(|poison| poison.into_inner())
561}
562
563impl ControlDb {
564    fn new(path: Option<std::path::PathBuf>, db: redb::Database) -> Self {
565        ControlDb {
566            path,
567            live: std::sync::RwLock::new(Some(db)),
568            ram: std::sync::RwLock::new(None),
569            group: std::sync::Mutex::new(None),
570            durable_commits: std::sync::atomic::AtomicU64::new(0),
571        }
572    }
573
574    /// A handle over an in-memory database somebody else prepared — the
575    /// secret store's own tests build one without a control store.
576    #[cfg(test)]
577    pub(crate) fn in_memory_for_tests(db: redb::Database) -> Self {
578        Self::new(None, db)
579    }
580
581    /// Is the store off the stripe right now?
582    pub fn is_suspended(&self) -> bool {
583        read_lock(&self.ram).is_some()
584    }
585
586    /// The RAM copy, while suspended.
587    pub fn ram_copy(&self) -> Option<RamCopy> {
588        read_lock(&self.ram).clone()
589    }
590
591    /// The refusal every write meets while suspended.
592    fn off_stripe(&self) -> Error {
593        Error::OffStripe {
594            why: read_lock(&self.ram)
595                .as_ref()
596                .map(|r| r.why.clone())
597                .unwrap_or_else(|| "suspended".to_owned()),
598        }
599    }
600
601    /// Run `f` over a read transaction on the live database, or, while
602    /// suspended, `ram` over the copy. The read guard is held for the
603    /// transaction's whole life, which is what makes `suspend` wait for it.
604    pub(crate) fn read<T>(
605        &self,
606        what: &str,
607        live: impl FnOnce(&redb::ReadTransaction) -> Result<T>,
608        ram: impl FnOnce(&RamCopy) -> Result<T>,
609    ) -> Result<T> {
610        if let Some(copy) = read_lock(&self.ram).as_ref() {
611            return ram(copy);
612        }
613        let guard = read_lock(&self.live);
614        let Some(db) = guard.as_ref() else {
615            return Err(self.off_stripe());
616        };
617        let txn = db
618            .begin_read()
619            .map_err(|err| faulted(&format!("beginning {what}"), err))?;
620        live(&txn)
621    }
622
623    /// Run `f` over a write transaction on the live database; refused while
624    /// suspended. `f` says whether to keep what it wrote ([`Txn`]); the commit is
625    /// made here — or, on the thread that holds a group ([`ControlDb::begin_group`]),
626    /// left to the group's one commit.
627    pub(crate) fn write<T>(
628        &self,
629        what: &str,
630        f: impl FnOnce(&redb::WriteTransaction) -> Result<Txn<T>>,
631    ) -> Result<T> {
632        if self.is_suspended() {
633            return Err(self.off_stripe());
634        }
635        {
636            let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
637            if let Some(g) = group.as_mut().filter(|g| g.owner == std::thread::current().id()) {
638                return match f(&g.txn) {
639                    Ok(Txn::Commit(t) | Txn::CommitUnsynced(t)) => {
640                        g.dirty = true;
641                        Ok(t)
642                    }
643                    Ok(Txn::Discard(t)) => Ok(t),
644                    Err(err) => {
645                        g.poisoned = true;
646                        Err(err)
647                    }
648                };
649            }
650        }
651        let guard = read_lock(&self.live);
652        let Some(db) = guard.as_ref() else {
653            return Err(self.off_stripe());
654        };
655        let mut txn = db
656            .begin_write()
657            .map_err(|err| faulted(&format!("beginning {what}"), err))?;
658        match f(&txn)? {
659            Txn::CommitUnsynced(t) => {
660                txn.set_durability(redb::Durability::None);
661                txn.commit()
662                    .map_err(|err| faulted(&format!("committing {what}"), err))?;
663                Ok(t)
664            }
665            Txn::Commit(t) => {
666                txn.commit()
667                    .map_err(|err| faulted(&format!("committing {what}"), err))?;
668                self.durable_commits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
669                Ok(t)
670            }
671            // Dropped, not committed: redb rolls it back.
672            Txn::Discard(t) => Ok(t),
673        }
674    }
675
676    /// **Open a group on this thread** (perf twin-apply-fsync, 2026-10-06): every write this
677    /// thread makes through the stores over this database goes into ONE transaction until
678    /// [`ControlDb::commit_group`] or [`ControlDb::abort_group`]. Writes from other threads wait
679    /// for it (redb takes one writer at a time); reads see the state before it. A twin's
680    /// follower holds one for the control-plane entries of each group it takes.
681    pub fn begin_group(&self) -> Result<()> {
682        if self.is_suspended() {
683            return Err(self.off_stripe());
684        }
685        let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
686        if group.is_some() {
687            return Err(Error::ControlStore("a group transaction is already open on this control store".to_owned()));
688        }
689        let guard = read_lock(&self.live);
690        let Some(db) = guard.as_ref() else {
691            return Err(self.off_stripe());
692        };
693        let txn = db
694            .begin_write()
695            .map_err(|err| faulted("beginning a group of writes", err))?;
696        *group = Some(Group { owner: std::thread::current().id(), txn, dirty: false, poisoned: false });
697        Ok(())
698    }
699
700    /// **End the group with ONE durable commit, the marker `key` = `seq` inside it** — when it
701    /// wrote anything, or `mark` asks for the marker anyway. A group that wrote nothing and is not
702    /// asked to mark is dropped (no I/O). A group a write failed in is aborted whole and the
703    /// failure answered: none of it is kept. Answers whether it committed.
704    pub fn commit_group(&self, key: &str, seq: u64, mark: bool) -> Result<bool> {
705        let g = self.take_group()?;
706        if g.poisoned {
707            drop(g.txn);
708            return Err(Error::ControlStore("a write inside the group failed; the whole group was rolled back".to_owned()));
709        }
710        if !g.dirty && !mark {
711            return Ok(false);
712        }
713        {
714            let mut marks = g
715                .txn
716                .open_table(MARKS)
717                .map_err(|err| faulted("opening the marks table", err))?;
718            marks
719                .insert(key, seq)
720                .map_err(|err| faulted("moving a marker", err))?;
721        }
722        g.txn
723            .commit()
724            .map_err(|err| faulted("committing a group of writes", err))?;
725        self.durable_commits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
726        Ok(true)
727    }
728
729    /// **How many durable commits (one fsync each) this database made** since it was opened —
730    /// what the I/O tests hold to a bound.
731    pub fn durable_commits(&self) -> u64 {
732        self.durable_commits.load(std::sync::atomic::Ordering::Relaxed)
733    }
734
735    /// Drop the group on this thread, keeping none of it.
736    pub fn abort_group(&self) {
737        let _ = self.take_group();
738    }
739
740    fn take_group(&self) -> Result<Group> {
741        let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
742        match group.take() {
743            Some(g) if g.owner == std::thread::current().id() => Ok(g),
744            Some(g) => {
745                *group = Some(g);
746                Err(Error::ControlStore("the open group transaction belongs to another thread".to_owned()))
747            }
748            None => Err(Error::ControlStore("no group transaction is open".to_owned())),
749        }
750    }
751
752    /// **Move the marker `key` to `seq`, durably** — inside this thread's group when one is open
753    /// (made durable with it), else in a commit of its own (one fsync, and every earlier
754    /// non-durable commit with it).
755    pub fn set_mark(&self, key: &str, seq: u64) -> Result<()> {
756        self.write("a marker", |txn| {
757            let mut marks = txn
758                .open_table(MARKS)
759                .map_err(|err| faulted("opening the marks table", err))?;
760            marks
761                .insert(key, seq)
762                .map_err(|err| faulted("moving a marker", err))?;
763            Ok(Txn::Commit(()))
764        })
765    }
766
767    /// The marker `key`, `None` when it was never set (a box from before markers, or a fresh one).
768    pub fn mark(&self, key: &str) -> Result<Option<u64>> {
769        self.read(
770            "a marker read",
771            |txn| {
772                let table = match txn.open_table(MARKS) {
773                    Ok(t) => t,
774                    Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
775                    Err(err) => return Err(faulted("opening the marks table", err)),
776                };
777                let found = table
778                    .get(key)
779                    .map_err(|err| faulted("reading a marker", err))?;
780                Ok(found.map(|v| v.value()))
781            },
782            |_ram| Ok(None),
783        )
784    }
785
786    /// **Every principal's last-seen row** ([`crate::activity`]): principal id →
787    /// its encoded stamps. Empty for a database that never kept one, and while
788    /// the store is off the stripe (the RAM copy carries no such table).
789    pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
790        self.read("a last-seen read", activity_rows, |_ram| Ok(Vec::new()))
791    }
792
793    /// **Write last-seen rows in ONE transaction** (one durable commit for the
794    /// whole batch), and the marker `mark` = 1 in the same transaction when one
795    /// is named. Refused while the store is off the stripe; the caller keeps
796    /// the rows and tries again.
797    pub fn put_principal_activity(&self, rows: &[(String, Vec<u8>)], mark: Option<&str>) -> Result<()> {
798        self.write("last-seen rows", |txn| {
799            {
800                let mut table = txn
801                    .open_table(PRINCIPAL_ACTIVITY)
802                    .map_err(|err| faulted("opening the last-seen table", err))?;
803                for (id, value) in rows {
804                    table
805                        .insert(id.as_str(), value.as_slice())
806                        .map_err(|err| faulted("writing a last-seen row", err))?;
807                }
808            }
809            if let Some(key) = mark {
810                let mut marks = txn
811                    .open_table(MARKS)
812                    .map_err(|err| faulted("opening the marks table", err))?;
813                marks
814                    .insert(key, 1u64)
815                    .map_err(|err| faulted("moving a marker", err))?;
816            }
817            Ok(Txn::Commit(()))
818        })
819    }
820
821    /// **Take the store off the stripe.** Copies every table into RAM, then
822    /// closes the file (an in-memory database is kept). Refuses when already
823    /// suspended, and when the copy would weigh more than
824    /// [`RAM_COPY_CEILING_BYTES`] — nothing is changed in that case.
825    pub fn suspend(&self, why: &str) -> Result<RamCopy> {
826        if self.group.lock().unwrap_or_else(|p| p.into_inner()).is_some() {
827            return Err(Error::ControlStore(
828                "a group of writes is open on the control store; it is suspended after the group ends".to_owned(),
829            ));
830        }
831        let mut live = write_lock(&self.live);
832        if self.is_suspended() {
833            return Err(Error::ControlStore(
834                "the control store is already suspended".to_owned(),
835            ));
836        }
837        let Some(db) = live.as_ref() else {
838            return Err(Error::ControlStore(
839                "the control store has no database to suspend".to_owned(),
840            ));
841        };
842        let txn = db
843            .begin_read()
844            .map_err(|err| faulted("beginning the suspend snapshot", err))?;
845        let mut copy = RamCopy {
846            why: why.to_owned(),
847            docs: Default::default(),
848            blobs: Default::default(),
849            secrets: Default::default(),
850            taken_unix_ms: crate::model::now_ms(),
851        };
852        {
853            let table = txn
854                .open_table(DOCUMENTS)
855                .map_err(|err| faulted("opening the documents table", err))?;
856            for entry in table
857                .iter()
858                .map_err(|err| faulted("scanning the documents table", err))?
859            {
860                let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
861                if let Some(id) = BlobId::from_slice(v.value()) {
862                    copy.docs.insert(k.value().to_owned(), id);
863                }
864            }
865            let table = txn
866                .open_table(BLOBS)
867                .map_err(|err| faulted("opening the blobs table", err))?;
868            for entry in table
869                .iter()
870                .map_err(|err| faulted("scanning the blobs table", err))?
871            {
872                let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
873                if let Some(id) = BlobId::from_slice(k.value()) {
874                    copy.blobs.insert(id, v.value().to_vec());
875                }
876            }
877            copy.secrets = crate::secrets::RedbSecretStore::rows(&txn)?;
878        }
879        drop(txn);
880        let bytes = copy.bytes();
881        if bytes > RAM_COPY_CEILING_BYTES {
882            return Err(Error::ControlStore(format!(
883                "the control database would take {bytes} bytes of RAM to hold off the stripe, \
884                 over the {RAM_COPY_CEILING_BYTES}-byte ceiling; not suspending"
885            )));
886        }
887        // The copy is installed BEFORE the file is closed, so no reader ever
888        // meets a `None` database: the `ram` lock is taken after `live`'s
889        // write lock, which every reader takes (shared) first — no reader is
890        // inside the database at this point.
891        *write_lock(&self.ram) = Some(copy.clone());
892        if self.path.is_some() {
893            // Dropping the database closes the file: redb has no explicit
894            // close, and its `Drop` is what releases the descriptor and the
895            // lock. Every handle upstream is an `Arc<ControlDb>`, so nothing
896            // else holds the file.
897            *live = None;
898        }
899        Ok(copy)
900    }
901
902    /// **Put the store back on the stripe.** Reopens the file (the mount
903    /// must be back; a missing file is an error, never a fresh database),
904    /// compares every document pointer on disk with the copy, and drops the
905    /// copy. Refuses when not suspended.
906    pub fn resume(&self) -> Result<ResumeReport> {
907        let mut live = write_lock(&self.live);
908        let Some(copy) = read_lock(&self.ram).clone() else {
909            return Err(Error::ControlStore(
910                "the control store is not suspended".to_owned(),
911            ));
912        };
913        if live.is_none() {
914            let path = self.path.as_ref().ok_or_else(|| {
915                Error::ControlStore("no path to reopen the control store from".to_owned())
916            })?;
917            let db = redb::Database::open(path).map_err(|err| {
918                faulted(&format!("reopening the control store {}", path.display()), err)
919            })?;
920            *live = Some(db);
921        }
922        let db = live.as_ref().expect("installed above");
923        let txn = db
924            .begin_read()
925            .map_err(|err| faulted("beginning the resume check", err))?;
926        let table = txn
927            .open_table(DOCUMENTS)
928            .map_err(|err| faulted("opening the documents table", err))?;
929        let mut matched = 0;
930        let mut drift = Vec::new();
931        for (name, ram_id) in &copy.docs {
932            let disk = table
933                .get(name.as_str())
934                .map_err(|err| faulted("reading a document pointer", err))?
935                .and_then(|v| BlobId::from_slice(v.value()));
936            match disk {
937                Some(id) if id == *ram_id => matched += 1,
938                other => drift.push(format!(
939                    "{name}: ram {ram_id} vs disk {}",
940                    other.map(|i| i.to_string()).unwrap_or_else(|| "absent".to_owned())
941                )),
942            }
943        }
944        for entry in table
945            .iter()
946            .map_err(|err| faulted("scanning the documents table", err))?
947        {
948            let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
949            if !copy.docs.contains_key(k.value()) {
950                drift.push(format!(
951                    "{}: ram absent vs disk {}",
952                    k.value(),
953                    BlobId::from_slice(v.value())
954                        .map(|i| i.to_string())
955                        .unwrap_or_else(|| "?".to_owned())
956                ));
957            }
958        }
959        drop(table);
960        drop(txn);
961        *write_lock(&self.ram) = None;
962        Ok(ResumeReport {
963            documents_matched: matched,
964            drift,
965            off_stripe_ms: crate::model::now_ms() - copy.taken_unix_ms,
966        })
967    }
968}
969
970/// The redb-backed [`ControlStore`]: one file, three tables.
971///
972/// Cheap to clone; every clone is the same database. redb serialises write
973/// transactions internally, so two threads committing at once is a wait and
974/// never a lock error — which is why [`ControlStore::commit_doc`] has one
975/// failure mode (the expectation did not hold) where the reference store had
976/// two (that, and *somebody else holds the lock*).
977#[derive(Clone)]
978pub struct RedbControlStore {
979    db: Arc<ControlDb>,
980}
981
982impl std::fmt::Debug for RedbControlStore {
983    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
984        f.write_str("RedbControlStore")
985    }
986}
987
988fn faulted(what: &str, err: impl std::fmt::Display) -> Error {
989    Error::ControlStore(format!("{what}: {err}"))
990}
991
992impl RedbControlStore {
993    /// Open — creating if absent — the control database at `path`.
994    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
995        let path = path.as_ref();
996        let db = redb::Database::create(path).map_err(|err| {
997            faulted(
998                &format!("opening the control store {}", path.display()),
999                err,
1000            )
1001        })?;
1002        Self::prepared(Some(path.to_path_buf()), db)
1003    }
1004
1005    /// **The handle the runtime growth suspends and resumes.** The same
1006    /// `Arc` the secret store holds.
1007    pub fn control_db(&self) -> Arc<ControlDb> {
1008        Arc::clone(&self.db)
1009    }
1010
1011    /// A control store with no file under it, for tests and for a `--dry-run`
1012    /// that must not touch the data path.
1013    pub fn in_memory() -> Result<Self> {
1014        let db = redb::Database::builder()
1015            .create_with_backend(redb::backends::InMemoryBackend::new())
1016            .map_err(|err| faulted("opening an in-memory control store", err))?;
1017        Self::prepared(None, db)
1018    }
1019
1020    /// Create every table up front.
1021    ///
1022    /// redb refuses to *read* a table that has never been written, so a store
1023    /// whose tables were created lazily would answer a first read with a fault
1024    /// rather than with "empty" — the exact confusion
1025    /// [`ControlStore::read_doc`] exists to prevent.
1026    fn prepared(path: Option<std::path::PathBuf>, db: redb::Database) -> Result<Self> {
1027        let txn = db
1028            .begin_write()
1029            .map_err(|err| faulted("beginning the control store's first transaction", err))?;
1030        {
1031            txn.open_table(DOCUMENTS)
1032                .map_err(|err| faulted("creating the documents table", err))?;
1033            txn.open_table(BLOBS)
1034                .map_err(|err| faulted("creating the blobs table", err))?;
1035            txn.open_table(DOC_HISTORY)
1036                .map_err(|err| faulted("creating the document history table", err))?;
1037            txn.open_table(MARKS)
1038                .map_err(|err| faulted("creating the marks table", err))?;
1039        }
1040        txn.commit()
1041            .map_err(|err| faulted("committing the control store's first transaction", err))?;
1042        crate::secrets::RedbSecretStore::prepare(&db)?;
1043        Ok(RedbControlStore { db: Arc::new(ControlDb::new(path, db)) })
1044    }
1045
1046    /// **The secret store in this same database** (2026-08-10).
1047    ///
1048    /// Handed out from here rather than opened separately, and that is the
1049    /// whole design: redb takes an exclusive file lock, so a second
1050    /// `redb::Database` over `control.redb` would fail. One file, one lock, one
1051    /// handle — and the secrets ride the transaction machinery the control
1052    /// plane already has instead of a second durability story beside it.
1053    ///
1054    /// It is **not** on [`ControlStore`]. That trait is about documents —
1055    /// `put_blob` / `commit_doc` / `history` — and its test decorators (the
1056    /// CAS-ignoring store `tests/control_cost.rs` proves the retry loop with,
1057    /// and the counting decorator) exist to break exactly those. A secret has
1058    /// none of that shape, so adding it to the trait would make every decorator
1059    /// carry a method it has no opinion about.
1060    pub fn secrets(&self) -> crate::secrets::RedbSecretStore {
1061        crate::secrets::RedbSecretStore::new(Arc::clone(&self.db))
1062    }
1063
1064    /// The next sequence number for `name`, read inside the caller's write
1065    /// transaction so two committers cannot pick the same one.
1066    fn next_seq(
1067        table: &impl redb::ReadableTable<(&'static str, u64), &'static [u8]>,
1068        name: &str,
1069    ) -> Result<u64> {
1070        let mut range = table
1071            .range((name, 0u64)..=(name, u64::MAX))
1072            .map_err(|err| faulted("reading a document's history", err))?;
1073        match range.next_back() {
1074            None => Ok(1),
1075            Some(entry) => {
1076                let (key, _) = entry.map_err(|err| faulted("reading a history row", err))?;
1077                Ok(key.value().1 + 1)
1078            }
1079        }
1080    }
1081}
1082
1083impl ControlStore for RedbControlStore {
1084    fn put_blob(&self, bytes: &[u8]) -> Result<BlobId> {
1085        let id = BlobId::of(bytes);
1086        self.db.write("a blob write", |txn| {
1087            {
1088                let mut table = txn
1089                    .open_table(BLOBS)
1090                    .map_err(|err| faulted("opening the blobs table", err))?;
1091                table
1092                    .insert(id.0.as_slice(), bytes)
1093                    .map_err(|err| faulted("storing a control blob", err))?;
1094            }
1095            Ok(Txn::Commit(id))
1096        })
1097    }
1098
1099    fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
1100        let id = BlobId::of(bytes);
1101        self.db.write("a blob write", |txn| {
1102            {
1103                let mut table = txn
1104                    .open_table(BLOBS)
1105                    .map_err(|err| faulted("opening the blobs table", err))?;
1106                table
1107                    .insert(id.0.as_slice(), bytes)
1108                    .map_err(|err| faulted("storing a control blob", err))?;
1109            }
1110            Ok(Txn::CommitUnsynced(id))
1111        })
1112    }
1113
1114    fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>> {
1115        self.db.read(
1116            "a blob read",
1117            |txn| {
1118                let table = txn
1119                    .open_table(BLOBS)
1120                    .map_err(|err| faulted("opening the blobs table", err))?;
1121                let found = table
1122                    .get(id.0.as_slice())
1123                    .map_err(|err| faulted("reading a control blob", err))?;
1124                Ok(found.map(|value| value.value().to_vec()))
1125            },
1126            |ram| Ok(ram.blobs.get(id).cloned()),
1127        )
1128    }
1129
1130    fn read_doc(&self, name: &str) -> Result<Option<BlobId>> {
1131        self.db.read(
1132            "a document read",
1133            |txn| {
1134                let table = txn
1135                    .open_table(DOCUMENTS)
1136                    .map_err(|err| faulted("opening the documents table", err))?;
1137                let found = table
1138                    .get(name)
1139                    .map_err(|err| faulted("reading a control document pointer", err))?;
1140                match found {
1141                    None => Ok(None),
1142                    Some(value) => BlobId::from_slice(value.value())
1143                        .map(Some)
1144                        .ok_or_else(|| Error::CorruptDocument {
1145                            ref_name: name.to_owned(),
1146                            reason: format!(
1147                                "its stored pointer is {} bytes, not the {BLOB_ID_LEN} of a blob id",
1148                                value.value().len()
1149                            ),
1150                        }),
1151                }
1152            },
1153            |ram| Ok(ram.docs.get(name).copied()),
1154        )
1155    }
1156
1157    fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool> {
1158        self.db.write("a control document commit", |txn| {
1159            {
1160                let mut documents = txn
1161                    .open_table(DOCUMENTS)
1162                    .map_err(|err| faulted("opening the documents table", err))?;
1163                let current = documents
1164                    .get(name)
1165                    .map_err(|err| faulted("reading a control document pointer", err))?
1166                    .and_then(|value| BlobId::from_slice(value.value()));
1167                if current != expected {
1168                    // Not an error: the caller re-reads and re-applies. Nothing
1169                    // was written yet, and the transaction is discarded.
1170                    return Ok(Txn::Discard(false));
1171                }
1172                documents
1173                    .insert(name, new.0.as_slice())
1174                    .map_err(|err| faulted("moving a control document pointer", err))?;
1175
1176                // The control plane's audit trail, in the SAME transaction as
1177                // the pointer move. Two transactions could be interrupted
1178                // between them, and a history that can disagree with the
1179                // document it describes is worse than no history at all.
1180                let mut history = txn
1181                    .open_table(DOC_HISTORY)
1182                    .map_err(|err| faulted("opening the document history table", err))?;
1183                let seq = Self::next_seq(&history, name)?;
1184                let mut row = Vec::with_capacity(8 + BLOB_ID_LEN);
1185                row.extend_from_slice(&crate::model::now_ms().to_be_bytes());
1186                row.extend_from_slice(&new.0);
1187                history
1188                    .insert((name, seq), row.as_slice())
1189                    .map_err(|err| faulted("appending to the document history", err))?;
1190            }
1191            Ok(Txn::Commit(true))
1192        })
1193    }
1194
1195    fn history(&self, name: &str) -> Result<Vec<DocVersion>> {
1196        self.db.read(
1197            "a history read",
1198            |txn| {
1199                let table = txn
1200                    .open_table(DOC_HISTORY)
1201                    .map_err(|err| faulted("opening the document history table", err))?;
1202                let range = table
1203                    .range((name, 0u64)..=(name, u64::MAX))
1204                    .map_err(|err| faulted("scanning a document's history", err))?;
1205                let mut out = Vec::new();
1206                for entry in range {
1207                    let (key, value) = entry.map_err(|err| faulted("reading a history row", err))?;
1208                    let bytes = value.value();
1209                    if bytes.len() != 8 + BLOB_ID_LEN {
1210                        return Err(Error::CorruptDocument {
1211                            ref_name: name.to_owned(),
1212                            reason: format!(
1213                                "a history row is {} bytes, not {}",
1214                                bytes.len(),
1215                                8 + BLOB_ID_LEN
1216                            ),
1217                        });
1218                    }
1219                    let mut millis = [0u8; 8];
1220                    millis.copy_from_slice(&bytes[..8]);
1221                    out.push(DocVersion {
1222                        seq: key.value().1,
1223                        at: i64::from_be_bytes(millis),
1224                        blob: BlobId::from_slice(&bytes[8..]).expect("checked length"),
1225                    });
1226                }
1227                Ok(out)
1228            },
1229            // The trail is on the stripe and nothing wrote while it was off:
1230            // an audit read during the window is refused rather than answered
1231            // empty, which would read as "this document has no history".
1232            |ram| {
1233                Err(Error::OffStripe {
1234                    why: format!("{}; the audit trail is not held in RAM", ram.why),
1235                })
1236            },
1237        )
1238    }
1239}
1240
1241/// A control store, ready to be handed to [`crate::AuthTable`] and
1242/// [`crate::RepoCatalog`].
1243pub type SharedControlStore = Arc<dyn ControlStore>;
1244
1245/// The redb store at `path`, as a [`SharedControlStore`].
1246pub fn open_control_store(path: impl AsRef<Path>) -> Result<SharedControlStore> {
1247    Ok(Arc::new(RedbControlStore::open(path)?))
1248}
1249
1250/// **The whole control plane at `path`: the documents and the secrets, over one
1251/// database handle.**
1252///
1253/// The pair, not two calls, because the two halves must share the `redb`
1254/// handle: opening the file twice is an exclusive-lock failure, and this is the
1255/// only place that fact has to be known. `ServerStores::open` is the caller.
1256pub fn open_control(
1257    path: impl AsRef<Path>,
1258) -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
1259    let (store, secrets, _) = open_control_with_db(path)?;
1260    Ok((store, secrets))
1261}
1262
1263/// [`open_control`], and the [`ControlDb`] handle both halves share — what
1264/// the runtime data-set growth suspends and resumes (`gunnar-server`'s
1265/// `grow`). The handle is the same object either store reaches; it is
1266/// returned separately only because the store is type-erased on the way out.
1267pub fn open_control_with_db(
1268    path: impl AsRef<Path>,
1269) -> Result<(SharedControlStore, crate::secrets::RedbSecretStore, Arc<ControlDb>)> {
1270    let store = RedbControlStore::open(path)?;
1271    let secrets = store.secrets();
1272    let db = store.control_db();
1273    Ok((Arc::new(store), secrets, db))
1274}
1275
1276/// An in-memory control store, as a [`SharedControlStore`].
1277pub fn memory_control_store() -> Result<SharedControlStore> {
1278    Ok(Arc::new(RedbControlStore::in_memory()?))
1279}
1280
1281/// [`open_control`] with no file under it, for tests and for a `--dry-run` that
1282/// must not touch the data path.
1283pub fn memory_control() -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
1284    let store = RedbControlStore::in_memory()?;
1285    let secrets = store.secrets();
1286    Ok((Arc::new(store), secrets))
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292
1293    #[test]
1294    fn a_blob_id_is_the_hash_of_the_bytes_and_round_trips_through_hex() {
1295        let id = BlobId::of(b"the identity table");
1296        assert_eq!(BlobId::from_hex(&id.to_string()), Some(id));
1297        assert_ne!(id, BlobId::of(b"the identity tabl3"));
1298        assert_eq!(BlobId::from_hex("not hex"), None);
1299        assert_eq!(BlobId::from_hex(""), None);
1300    }
1301
1302    #[test]
1303    fn an_unwritten_document_reads_as_absent_and_a_written_one_reads_back() {
1304        let store = RedbControlStore::in_memory().expect("open");
1305        assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), None);
1306        let id = store.put_blob(b"{}").expect("put");
1307        assert_eq!(
1308            store.get_blob(&id).expect("get").as_deref(),
1309            Some(&b"{}"[..])
1310        );
1311        assert!(store
1312            .commit_doc("refs/gunnar/auth", None, id)
1313            .expect("commit"));
1314        assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
1315    }
1316
1317    /// **T2 — the store off the stripe** (`DATA-SET-GROWTH-FLOW.md` G4):
1318    /// while suspended every read answers from the RAM copy byte for byte,
1319    /// every write is refused by name with `OffStripe`, the audit trail is
1320    /// refused rather than answered empty, and a resume finds no drift and
1321    /// takes writes again. Suspending twice and resuming an unsuspended
1322    /// store are both refusals.
1323    #[test]
1324    fn a_suspended_store_reads_from_ram_refuses_writes_and_resumes_without_drift() {
1325        use gunnar_store::SecretStore as _;
1326        let store = RedbControlStore::in_memory().expect("open");
1327        let secrets = store.secrets();
1328        let db = store.control_db();
1329        let id = store.put_blob(b"{\"principals\":1}").expect("put");
1330        assert!(store.commit_doc("refs/gunnar/auth", None, id).expect("commit"));
1331        secrets.put("host-key", b"sealed bytes").expect("put secret");
1332        assert!(!db.is_suspended());
1333
1334        let copy = db.suspend("draining for a growth").expect("suspend");
1335        assert!(db.is_suspended());
1336        assert_eq!(copy.documents(), 1);
1337        assert!(copy.bytes() > 0);
1338        assert_eq!(copy.why, "draining for a growth");
1339        // Reads: served from the copy.
1340        assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
1341        assert_eq!(store.get_blob(&id).expect("get").as_deref(), Some(&b"{\"principals\":1}"[..]));
1342        assert_eq!(store.read_doc("never-written").expect("read"), None);
1343        assert_eq!(secrets.get("host-key").expect("get").as_deref(), Some(&b"sealed bytes"[..]));
1344        assert_eq!(secrets.get("absent").expect("get"), None);
1345        assert_eq!(
1346            secrets.get_or_create("host-key", &mut || panic!("must not mint")).expect("held"),
1347            b"sealed bytes"
1348        );
1349        // Writes: refused, by name, with the phase in the words.
1350        let refused = store.put_blob(b"new").unwrap_err();
1351        assert!(matches!(refused, Error::OffStripe { .. }), "{refused:?}");
1352        assert!(refused.to_string().contains("draining for a growth"), "{refused}");
1353        assert!(refused.to_string().contains("retry"), "{refused}");
1354        let other = BlobId::of(b"other");
1355        assert!(matches!(store.commit_doc("refs/gunnar/auth", Some(id), other).unwrap_err(), Error::OffStripe { .. }));
1356        assert!(matches!(store.history("refs/gunnar/auth").unwrap_err(), Error::OffStripe { .. }));
1357        let sec = secrets.put("host-key", b"x").unwrap_err().to_string();
1358        assert!(sec.contains("off the stripe"), "{sec}");
1359        let mint = secrets.get_or_create("fresh", &mut || Ok(b"minted".to_vec())).unwrap_err().to_string();
1360        assert!(mint.contains("will not be minted"), "{mint}");
1361        assert!(db.suspend("again").is_err(), "a second suspend is a refusal");
1362
1363        let report = db.resume().expect("resume");
1364        assert!(!db.is_suspended());
1365        assert_eq!(report.documents_matched, 1);
1366        assert!(report.drift.is_empty(), "{:?}", report.drift);
1367        assert!(db.resume().is_err(), "resuming an unsuspended store is a refusal");
1368        // Back on the stripe: writes land, the trail is readable again.
1369        assert!(store.commit_doc("refs/gunnar/auth", Some(id), other).expect("commit"));
1370        assert_eq!(store.history("refs/gunnar/auth").expect("history").len(), 2);
1371        assert_eq!(secrets.get_or_create("fresh", &mut || Ok(b"minted".to_vec())).expect("mint"), b"minted");
1372    }
1373
1374    /// **The file really closes.** On the stripe the whole point of the
1375    /// suspend is that `umount /data` can succeed, which needs the descriptor
1376    /// gone: `/proc/self/fd` names the file before, not during, and again
1377    /// after. Linux only, like the mount it exists for.
1378    #[cfg(target_os = "linux")]
1379    #[test]
1380    fn a_suspended_file_store_holds_no_descriptor_on_its_file() {
1381        let dir = tempfile::tempdir().expect("tempdir");
1382        let path = dir.path().join("control.redb");
1383        let store = RedbControlStore::open(&path).expect("open");
1384        let db = store.control_db();
1385        let id = store.put_blob(b"doc").expect("put");
1386        assert!(store.commit_doc("d", None, id).expect("commit"));
1387        let holds = |p: &Path| -> bool {
1388            std::fs::read_dir("/proc/self/fd")
1389                .expect("procfs")
1390                .flatten()
1391                .filter_map(|e| std::fs::read_link(e.path()).ok())
1392                .any(|t| t == p)
1393        };
1394        assert!(holds(&path), "the open store holds its file");
1395        db.suspend("unmounting").expect("suspend");
1396        assert!(!holds(&path), "a suspended store holds NO descriptor on its file");
1397        assert_eq!(store.read_doc("d").expect("read from ram"), Some(id));
1398        db.resume().expect("resume");
1399        assert!(holds(&path), "a resumed store holds its file again");
1400        assert_eq!(store.read_doc("d").expect("read from disk"), Some(id));
1401    }
1402
1403    /// The compare-and-swap, seen to refuse. Without this the retry loop in
1404    /// `doc.rs` is decoration: a `commit_doc` that always returned `true` would
1405    /// satisfy every other test in this module.
1406    #[test]
1407    fn a_commit_against_the_wrong_expectation_is_refused_and_changes_nothing() {
1408        let store = RedbControlStore::in_memory().expect("open");
1409        let first = store.put_blob(b"one").expect("put");
1410        let second = store.put_blob(b"two").expect("put");
1411        assert!(store.commit_doc("doc", None, first).expect("commit"));
1412
1413        // Somebody else already moved it: this writer's expectation is stale.
1414        assert!(
1415            !store.commit_doc("doc", None, second).expect("commit"),
1416            "a commit that expected an absent document was accepted against a written one"
1417        );
1418        assert_eq!(
1419            store.read_doc("doc").expect("read"),
1420            Some(first),
1421            "a refused commit still moved the pointer"
1422        );
1423        assert_eq!(
1424            store.history("doc").expect("history").len(),
1425            1,
1426            "a refused commit still appended to the audit trail"
1427        );
1428
1429        // …and with the right expectation it lands.
1430        assert!(store
1431            .commit_doc("doc", Some(first), second)
1432            .expect("commit"));
1433        assert_eq!(store.read_doc("doc").expect("read"), Some(second));
1434    }
1435
1436    /// The control plane's audit trail: every version, in order, and it is per-document.
1437    #[test]
1438    fn the_history_is_append_only_and_one_document_does_not_see_anothers() {
1439        let store = RedbControlStore::in_memory().expect("open");
1440        let mut previous = None;
1441        let mut expected = Vec::new();
1442        for text in [b"a".as_slice(), b"bb", b"ccc"] {
1443            let id = store.put_blob(text).expect("put");
1444            assert!(store.commit_doc("auth", previous, id).expect("commit"));
1445            previous = Some(id);
1446            expected.push(id);
1447        }
1448        let other = store.put_blob(b"catalog").expect("put");
1449        assert!(store.commit_doc("catalog", None, other).expect("commit"));
1450
1451        let history = store.history("auth").expect("history");
1452        assert_eq!(
1453            history.iter().map(|v| v.blob).collect::<Vec<_>>(),
1454            expected,
1455            "the audit trail must list every version of the document, oldest first"
1456        );
1457        assert_eq!(
1458            history.iter().map(|v| v.seq).collect::<Vec<_>>(),
1459            vec![1, 2, 3]
1460        );
1461        assert_eq!(
1462            store.history("catalog").expect("history").len(),
1463            1,
1464            "one document's history picked up another document's versions"
1465        );
1466        assert_eq!(store.history("never-written").expect("history").len(), 0);
1467
1468        // Every historical version is still readable, which is what makes this
1469        // an audit trail rather than a list of hashes.
1470        assert_eq!(
1471            store.get_blob(&history[0].blob).expect("get").as_deref(),
1472            Some(&b"a"[..])
1473        );
1474    }
1475}