Skip to main content

gunnar_core/
txlog_tee.rs

1//! **The tee: every durable mutation goes through the transaction log
2//! BEFORE it reaches the store.** `.nornir/twin-transaction-log.md` §"Before,
3//! not after".
4//!
5//! Three decorators, one per store the primary mutates: [`LoggedGit`] around
6//! the engine's `GitServe`, [`LoggedControlStore`] around the control store,
7//! [`LoggedSecretStore`] around the secret store. (The LFS tee lives in the
8//! server, which is the crate that sees `gunnar-lfs`.) Each one appends the
9//! frame — durable — and only then calls the store. A log that cannot be
10//! written is an error the caller sees, so a primary never mutates what its
11//! twin will never hear of.
12//!
13//! Reads pass straight through. Nothing here changes a signature; the server
14//! wraps at the one place each store is constructed.
15//!
16//! # CAS is recorded, not resolved
17//!
18//! `put_refs_cas`, `update_ref` and `commit_doc` carry the claim the caller
19//! made (`old` / `expected`). The frame carries the same claim; the twin makes
20//! it against its own copy and gets the same answer the primary got, because
21//! it applies exactly the primary's sequence from the same state. A lost CAS
22//! on the primary is therefore a frame the twin replays to the same `false`.
23
24use std::sync::{Arc, Mutex};
25
26use gunnar_store::git_storage_trait::{
27    Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackProgress, PackStats, ReachSet,
28    RefCas, RefRow, RefUpdate, Stored, TxId,
29};
30
31use crate::control::{BlobId, ControlStore, DocVersion};
32use crate::txlog::{Kind, Log, Meta, RefEdit};
33
34fn hex(bytes: &[u8]) -> String {
35    hex::encode(bytes)
36}
37
38fn blob_hex(id: &BlobId) -> String {
39    hex(id.as_bytes())
40}
41
42// ── git ─────────────────────────────────────────────────────────────────────
43
44/// A `GitServe` whose every write is a frame first.
45pub struct LoggedGit {
46    inner: Arc<dyn GitServe>,
47    log: Arc<Log>,
48    repo: String,
49    hash: String,
50}
51
52impl LoggedGit {
53    /// Wrap `inner` for repository `repo` (`namespace/name`) with hash kind
54    /// `hash` (`sha1` / `sha256`).
55    pub fn new(
56        inner: Arc<dyn GitServe>,
57        log: Arc<Log>,
58        repo: impl Into<String>,
59        hash: impl Into<String>,
60    ) -> Self {
61        Self {
62            inner,
63            log,
64            repo: repo.into(),
65            hash: hash.into(),
66        }
67    }
68
69    /// The wrapped store.
70    pub fn inner(&self) -> &Arc<dyn GitServe> {
71        &self.inner
72    }
73
74    fn log_pack(&self, bytes: &[u8]) -> anyhow::Result<u64> {
75        let meta = Meta::Pack {
76            repo: self.repo.clone(),
77            hash: self.hash.clone(),
78        };
79        Ok(self.log.append(Kind::Pack, &meta, bytes)?)
80    }
81
82    fn log_refs(&self, atomic: bool, edits: Vec<RefEdit>) -> anyhow::Result<u64> {
83        let meta = Meta::Refs {
84            repo: self.repo.clone(),
85            atomic,
86            edits,
87        };
88        Ok(self.log.append(Kind::Refs, &meta, &[])?)
89    }
90}
91
92/// A plain `put_refs` update as the log records it: no claim about the old
93/// value.
94pub fn edit_of_update(u: &RefUpdate) -> RefEdit {
95    RefEdit {
96        name: u.name.clone(),
97        old: None,
98        new: u.target.clone(),
99        peeled: u.peeled.clone(),
100        symref: u.symref_target.clone(),
101    }
102}
103
104/// A CAS edit as the log records it: the claim travels with it.
105pub fn edit_of_cas(c: &RefCas<'_>) -> RefEdit {
106    RefEdit {
107        name: c.name.clone(),
108        old: Some(c.old.map(hex)),
109        new: c.new.map(hex),
110        peeled: c.peeled.as_deref().map(hex),
111        symref: None,
112    }
113}
114
115impl GitOps for LoggedGit {
116    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
117        let _m = self.log.mutation();
118        self.log_pack(pack)?;
119        let _m = self.log.mutation();
120        self.log_refs(false, refs.iter().map(edit_of_update).collect())?;
121        self.inner.put(pack, refs)
122    }
123    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
124        let _m = self.log.mutation();
125        self.log_pack(bytes)?;
126        self.inner.put_pack(bytes)
127    }
128    fn put_pack_observed(&self, bytes: &[u8], progress: &dyn PackProgress) -> anyhow::Result<TxId> {
129        let _m = self.log.mutation();
130        self.log_pack(bytes)?;
131        self.inner.put_pack_observed(bytes, progress)
132    }
133    fn publishes_pack_progress(&self) -> bool {
134        self.inner.publishes_pack_progress()
135    }
136    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
137        let _m = self.log.mutation();
138        self.log_refs(false, updates.iter().map(edit_of_update).collect())?;
139        self.inner.put_refs(updates)
140    }
141    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
142        self.inner.get(oid)
143    }
144    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
145        self.inner.has(oid)
146    }
147    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
148        self.inner.size(oid)
149    }
150    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
151        self.inner.extents(oids)
152    }
153    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
154        self.inner.refs()
155    }
156    fn update_ref(
157        &self,
158        name: &str,
159        old: Option<Oid<'_>>,
160        new: Option<Oid<'_>>,
161    ) -> anyhow::Result<TxId> {
162        let _m = self.log.mutation();
163        self.log_refs(
164            true,
165            vec![RefEdit {
166                name: name.to_string(),
167                old: Some(old.map(hex)),
168                new: new.map(hex),
169                peeled: None,
170                symref: None,
171            }],
172        )?;
173        self.inner.update_ref(name, old, new)
174    }
175    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
176        let _m = self.log.mutation();
177        self.log_refs(true, edits.iter().map(edit_of_cas).collect())?;
178        self.inner.put_refs_cas(edits)
179    }
180    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
181        self.inner.reachable(want, have)
182    }
183    fn gc(&self) -> anyhow::Result<GcReport> {
184        // A repack is not a transaction: the twin grows its own packs from the
185        // ones as pushed and may gc on its own clock.
186        self.inner.gc()
187    }
188}
189
190impl GitServe for LoggedGit {
191    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
192        self.inner.read(oid)
193    }
194    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
195        self.inner.header(oid)
196    }
197    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
198        self.inner.sizes(oids)
199    }
200    fn index_now(&self) -> anyhow::Result<usize> {
201        self.inner.index_now()
202    }
203    fn head(&self) -> anyhow::Result<Option<RefRow>> {
204        self.inner.head()
205    }
206    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
207        let _m = self.log.mutation();
208        self.log_refs(
209            false,
210            vec![RefEdit {
211                name: "HEAD".to_string(),
212                old: None,
213                new: None,
214                peeled: None,
215                symref: Some(target.to_string()),
216            }],
217        )?;
218        self.inner.set_head(target)
219    }
220    fn set_head_projected(&self, target: &str) -> anyhow::Result<TxId> {
221        let _m = self.log.mutation();
222        self.log_refs(
223            false,
224            vec![RefEdit {
225                name: "HEAD".to_string(),
226                old: None,
227                new: None,
228                peeled: None,
229                symref: Some(target.to_string()),
230            }],
231        )?;
232        self.inner.set_head_projected(target)
233    }
234    fn emit_pack(
235        &self,
236        objects: &[Oid<'_>],
237        have: &[Oid<'_>],
238        caps: &Caps,
239        out: &mut dyn std::io::Write,
240    ) -> anyhow::Result<PackStats> {
241        self.inner.emit_pack(objects, have, caps, out)
242    }
243    fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
244        self.inner.select(want, have)
245    }
246}
247
248// ── control store ───────────────────────────────────────────────────────────
249
250/// A `ControlStore` whose `put_blob` and `commit_doc` are frames first.
251pub struct LoggedControlStore {
252    inner: Arc<dyn ControlStore>,
253    log: Arc<Log>,
254}
255
256impl LoggedControlStore {
257    /// Wrap.
258    pub fn new(inner: Arc<dyn ControlStore>, log: Arc<Log>) -> Self {
259        Self { inner, log }
260    }
261    /// The wrapped store.
262    pub fn inner(&self) -> &Arc<dyn ControlStore> {
263        &self.inner
264    }
265}
266
267impl ControlStore for LoggedControlStore {
268    /// **A shard blob is framed and stored WITHOUT a flush of its own** (perf twin-apply-fsync,
269    /// 2026-10-06). A content-addressed blob is part of no state until a document points at it,
270    /// and the pointer move is ALWAYS a later frame: `commit_doc` appends its `ControlDoc` frame
271    /// durably — that `fdatasync` covers every blob frame staged before it in the same file — and
272    /// only then moves the pointer with a durable redb commit, which persists every blob row
273    /// committed before it. So the log is still written before every state anybody can see, and
274    /// a crash in between leaves at most an unreferenced blob nothing reaches. Measured
275    /// (`tests/main_doc_write_io.rs`): one document change was five frames with ten flushes
276    /// (five `fdatasync`s, five redb fsyncs); it is one of each.
277    fn put_blob(&self, bytes: &[u8]) -> crate::Result<BlobId> {
278        let id = BlobId::of(bytes);
279        let meta = Meta::ControlBlob { id: blob_hex(&id) };
280        let _m = self.log.mutation();
281        self.log
282            .stage(Kind::ControlBlob, &meta, bytes)
283            .map_err(log_err)?;
284        self.inner.put_blob_unsynced(bytes)
285    }
286    fn get_blob(&self, id: &BlobId) -> crate::Result<Option<Vec<u8>>> {
287        self.inner.get_blob(id)
288    }
289    fn read_doc(&self, name: &str) -> crate::Result<Option<BlobId>> {
290        self.inner.read_doc(name)
291    }
292    fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> crate::Result<bool> {
293        let meta = Meta::ControlDoc {
294            name: name.to_string(),
295            expected: expected.as_ref().map(blob_hex),
296            new: blob_hex(&new),
297        };
298        let _m = self.log.mutation();
299        self.log
300            .append(Kind::ControlDoc, &meta, &[])
301            .map_err(log_err)?;
302        self.inner.commit_doc(name, expected, new)
303    }
304    fn history(&self, name: &str) -> crate::Result<Vec<DocVersion>> {
305        self.inner.history(name)
306    }
307}
308
309fn log_err(e: crate::txlog::Error) -> crate::Error {
310    match e {
311        // ★ grow-login-503: the log is RELEASED only while the data set is grown (the
312        // runtime growth gave its files up for the unmount, `Log::release`, and takes them
313        // back after the remount). A control write then is refused OFF THE STRIPE — the
314        // control plane answers UNAVAILABLE ("retry") — never a control-store failure
315        // (INTERNAL), which a console turned into "you are not authenticated" for a person
316        // signing in during a disk grow (cov-E's round-3 KVM mockup, 2026-10-06).
317        crate::txlog::Error::Released(_) => crate::Error::OffStripe {
318            why: "the transaction log is released for the remount".into(),
319        },
320        // A box being restored (or a twin): its log follows; a write is "try again", never a
321        // control-store failure (r5.1: "would not enrol gunnar-twin: … Internal" while main restored).
322        crate::txlog::Error::Follower => crate::Error::LogFollows,
323        e => crate::Error::ControlStore(format!("transaction log: {e}")),
324    }
325}
326
327// ── secrets ─────────────────────────────────────────────────────────────────
328
329/// A `SecretStore` whose `put` and `delete` are frames first. The blob is
330/// the sealed bytes exactly as the store holds them.
331pub struct LoggedSecretStore<S> {
332    inner: S,
333    log: Arc<Log>,
334    /// Serialises `get_or_create` through this wrapper, so two creators of
335    /// the same key never log two different values for one row.
336    create: Mutex<()>,
337}
338
339impl<S: gunnar_store::SecretStore> LoggedSecretStore<S> {
340    /// Wrap.
341    pub fn new(inner: S, log: Arc<Log>) -> Self {
342        Self {
343            inner,
344            log,
345            create: Mutex::new(()),
346        }
347    }
348    /// The wrapped store.
349    pub fn inner(&self) -> &S {
350        &self.inner
351    }
352    fn log_secret(&self, key: &str, value: &[u8], deleted: bool) -> gunnar_store::Result<()> {
353        let meta = Meta::Secret {
354            key: key.to_string(),
355            deleted,
356        };
357        self.log
358            .append(Kind::Secret, &meta, value)
359            .map(|_| ())
360            .map_err(|e| gunnar_store::Error::Io(std::io::Error::other(e.to_string())))
361    }
362}
363
364impl<S: gunnar_store::SecretStore> gunnar_store::SecretStore for LoggedSecretStore<S> {
365    fn get(&self, key: &str) -> gunnar_store::Result<Option<Vec<u8>>> {
366        self.inner.get(key)
367    }
368    fn put(&self, key: &str, value: &[u8]) -> gunnar_store::Result<()> {
369        let _m = self.log.mutation();
370        self.log_secret(key, value, false)?;
371        self.inner.put(key, value)
372    }
373    fn delete(&self, key: &str) -> gunnar_store::Result<()> {
374        let _m = self.log.mutation();
375        self.log_secret(key, &[], true)?;
376        self.inner.delete(key)
377    }
378    fn get_or_create(
379        &self,
380        key: &str,
381        mk: &mut dyn FnMut() -> gunnar_store::Result<Vec<u8>>,
382    ) -> gunnar_store::Result<Vec<u8>> {
383        let _one = self.create.lock().expect("secret create");
384        if let Some(v) = self.inner.get(key)? {
385            return Ok(v);
386        }
387        let value = mk()?;
388        self.put(key, &value)?;
389        Ok(value)
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::txlog::Entry;
397    use gunnar_store::SecretStore as _;
398
399    fn log() -> (tempfile::TempDir, Arc<Log>) {
400        let dir = tempfile::tempdir().unwrap();
401        let log = Arc::new(Log::open(dir.path().join("txlog")).unwrap());
402        (dir, log)
403    }
404
405    fn entries(log: &Log) -> Vec<Entry> {
406        log.range(log.first_seq(), log.head_seq()).unwrap()
407    }
408
409    /// ★ grow-login-503 (cov-E's r3 KVM mockup, 2026-10-06): while main remounted after a
410    /// growth, the kit's passkey login met `HTTP 401: internal error: the control store
411    /// failed: transaction log: txlog: released …`. The log is RELEASED only while the data
412    /// set is grown — a moment, not a fault — so a control write then is refused OFF THE
413    /// STRIPE (UNAVAILABLE at every door: "retry"), never a control-store failure
414    /// (INTERNAL, which the console turned into "you are not authenticated").
415    #[test]
416    fn a_control_write_while_the_log_is_released_is_off_the_stripe_never_a_store_failure() {
417        let (_d, log) = log();
418        let store = LoggedControlStore::new(
419            Arc::new(crate::control::RedbControlStore::in_memory().unwrap()),
420            Arc::clone(&log),
421        );
422        log.release().unwrap();
423        let refused = store.put_blob(b"{\"principals\":[]}").unwrap_err();
424        assert!(
425            matches!(refused, crate::Error::OffStripe { .. }),
426            "{refused:?}"
427        );
428        let said = refused.to_string();
429        assert!(
430            said.contains("the data set") && said.contains("remount"),
431            "{said}"
432        );
433        log.reacquire().unwrap();
434        let id = store
435            .put_blob(b"{\"principals\":[]}")
436            .expect("after the remount the write goes through");
437        assert!(store.commit_doc("refs/gunnar/auth", None, id).unwrap());
438    }
439
440    #[test]
441    fn a_control_write_is_a_blob_frame_then_a_doc_frame_before_the_store_sees_it() {
442        let (_d, log) = log();
443        let store = LoggedControlStore::new(
444            Arc::new(crate::control::RedbControlStore::in_memory().unwrap()),
445            Arc::clone(&log),
446        );
447        let id = store.put_blob(b"{\"principals\":[]}").unwrap();
448        assert!(store.commit_doc("refs/gunnar/auth", None, id).unwrap());
449        // A lost CAS is logged with the claim, and the store answers false.
450        let id2 = store.put_blob(b"{\"principals\":[1]}").unwrap();
451        assert!(!store.commit_doc("refs/gunnar/auth", None, id2).unwrap());
452        let e = entries(&log);
453        assert_eq!(
454            e.iter().map(|e| e.kind).collect::<Vec<_>>(),
455            vec![3, 4, 3, 4]
456        );
457        match e[1].meta().unwrap() {
458            Meta::ControlDoc {
459                name,
460                expected,
461                new,
462            } => {
463                assert_eq!(name, "refs/gunnar/auth");
464                assert_eq!(expected, None);
465                assert_eq!(new, blob_hex(&id));
466            }
467            other => panic!("{other:?}"),
468        }
469        assert_eq!(e[0].blob, b"{\"principals\":[]}");
470    }
471
472    /// **A shard blob rides on the document that publishes it** (perf, 2026-10-06): framed but
473    /// not yet durable (nobody reads it, the head does not count it) until the `ControlDoc`
474    /// frame's fsync makes both durable together — and a reopen from the disk finds both, in order.
475    #[test]
476    fn a_blob_frame_is_durable_with_the_document_frame_that_publishes_it() {
477        let (d, log) = log();
478        let store = LoggedControlStore::new(
479            Arc::new(crate::control::RedbControlStore::in_memory().unwrap()),
480            Arc::clone(&log),
481        );
482        let syncs = log.frame_syncs();
483        let id = store.put_blob(b"{\"principals\":[]}").unwrap();
484        assert_eq!(
485            (log.head_seq(), log.unsynced()),
486            (0, 1),
487            "the blob frame waits for its document"
488        );
489        assert!(store.commit_doc("refs/gunnar/auth", None, id).unwrap());
490        assert_eq!((log.head_seq(), log.unsynced()), (2, 0), "durable together");
491        assert_eq!(
492            log.frame_syncs() - syncs,
493            1,
494            "one fsync for the blob and its document"
495        );
496        drop(store);
497        drop(log);
498        let reopened = Log::open(d.path().join("txlog")).unwrap();
499        let kinds: Vec<u16> = (1..=reopened.head_seq())
500            .map(|s| reopened.read(s).unwrap().kind)
501            .collect();
502        assert_eq!(
503            kinds,
504            vec![Kind::ControlBlob as u16, Kind::ControlDoc as u16]
505        );
506    }
507
508    #[test]
509    fn a_secret_put_and_delete_are_frames_and_get_or_create_logs_once() {
510        let (_d, log) = log();
511        let store =
512            LoggedSecretStore::new(gunnar_store::mem::MemSecretStore::new(), Arc::clone(&log));
513        store.put("host_key", b"sealed").unwrap();
514        let v = store
515            .get_or_create("host_key", &mut || Ok(b"other".to_vec()))
516            .unwrap();
517        assert_eq!(v, b"sealed");
518        let c = store
519            .get_or_create("issuer", &mut || Ok(b"minted".to_vec()))
520            .unwrap();
521        assert_eq!(c, b"minted");
522        store.delete("host_key").unwrap();
523        let e = entries(&log);
524        assert_eq!(e.len(), 3);
525        assert_eq!(e[0].blob, b"sealed");
526        assert_eq!(e[1].blob, b"minted");
527        match e[2].meta().unwrap() {
528            Meta::Secret { key, deleted } => {
529                assert_eq!(key, "host_key");
530                assert!(deleted);
531            }
532            other => panic!("{other:?}"),
533        }
534        assert_eq!(store.get("host_key").unwrap(), None);
535    }
536}