gunnar_core/arrow_log.rs
1//! Secrets as an **Arrow IPC log**: there is no database.
2//!
3//! `ArrowLogSecretStore` is the live half of this module: it stores SSH host keys
4//! and bootstrap material in `secrets.arrow`, with append durability and immediate
5//! compaction of superseded values so rotated secrets do not remain on disk.
6//!
7//! The old Arrow reference store has been retired. Reference storage now lives in
8//! the znippy ref log; this module keeps only the shared Arrow framing/durability
9//! machinery and the secret store. The reference schema constants remain because
10//! `crate::history` can still read/seal legacy Arrow reflog history.
11
12use std::collections::BTreeMap;
13use std::fs::OpenOptions;
14use std::io::{Read, Seek, SeekFrom, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
17use std::sync::{Arc, Mutex, RwLock};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use arrow_array::builder::{BinaryBuilder, Int64Builder, StringBuilder};
21use arrow_array::{Array, BinaryArray, RecordBatch, StringArray};
22use arrow_ipc::reader::StreamReader;
23use arrow_ipc::writer::{
24 write_message, DictionaryTracker, IpcDataGenerator, IpcWriteContext, IpcWriteOptions,
25};
26use arrow_schema::{DataType, Field, Schema, SchemaRef};
27
28use gunnar_store::{Error, Result, SecretStore};
29
30/// The Arrow IPC stream holding this repository's reference log.
31pub const REFS_LOG: &str = "refs.arrow";
32/// The Arrow IPC stream holding this repository's secrets.
33pub const SECRETS_LOG: &str = "secrets.arrow";
34/// The `O_EXCL` lock file that refuses a second writer.
35pub const LOCK_FILE: &str = "gunnar-store.lock";
36/// Where compaction puts the reflog history it takes out of the live log.
37///
38/// One complete, self-contained Arrow IPC stream per compaction, named so that
39/// a lexical sort is chronological. Never read at open.
40pub const REFS_ARCHIVE_DIR: &str = "refs-archive";
41
42/// The actor recorded on the snapshot row a compaction writes.
43///
44/// It is not a ref move, and a reflog reader must be able to tell the two apart:
45/// a snapshot row has a null `old_oid` *and* a null `old_symref` for a ref that
46/// certainly had a previous value.
47pub const COMPACTION_ACTOR: &str = "gunnar:compaction";
48
49/// The actor recorded on a log row when none was supplied.
50const DEFAULT_ACTOR: &str = "gunnar";
51
52/// Every Arrow IPC message begins with this, followed by a 4-byte length.
53///
54/// Named here rather than imported because `arrow-ipc` keeps it private. It is a
55/// constant of the *format*, not of the crate, and the tests below read real
56/// bytes produced by `arrow-ipc` to prove the two agree.
57const CONTINUATION_MARKER: [u8; 4] = [0xff; 4];
58
59pub(crate) const COL_REFNAME: &str = "refname";
60pub(crate) const COL_OLD_OID: &str = "old_oid";
61pub(crate) const COL_NEW_OID: &str = "new_oid";
62pub(crate) const COL_OLD_SYMREF: &str = "old_symref";
63pub(crate) const COL_NEW_SYMREF: &str = "new_symref";
64pub(crate) const COL_ACTOR: &str = "actor";
65pub(crate) const COL_AT: &str = "at_unix_ms";
66const COL_KEY: &str = "key";
67const COL_VALUE: &str = "value";
68
69// ───────────────────────────── compaction policy ─────────────────────────────
70
71/// When the secret log folds itself automatically.
72///
73/// `reflog_window` remains in the public constructor for compatibility with old
74/// callers; secrets always compact to the current values only.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct CompactionPolicy {
77 /// Compact once the live log holds this many framed batches.
78 ///
79 /// Checked on the secret write path. `0` disables threshold-based automatic
80 /// compaction; replacements are still compacted immediately.
81 pub after_batches: usize,
82 /// Ignored by the secret store; retained for API compatibility with callers
83 /// that still construct the old reference-log policy shape.
84 pub reflog_window: usize,
85}
86
87impl CompactionPolicy {
88 /// A policy with the given trigger and window.
89 pub fn new(after_batches: usize, reflog_window: usize) -> Self {
90 CompactionPolicy {
91 after_batches,
92 reflog_window,
93 }
94 }
95
96 /// Never compact automatically: the pre-compaction behaviour, kept because
97 /// it is what the growth guard measures against.
98 pub fn never() -> Self {
99 CompactionPolicy {
100 after_batches: 0,
101 reflog_window: 0,
102 }
103 }
104
105 fn should_compact(&self, batches: usize) -> bool {
106 self.after_batches > 0 && batches >= self.after_batches
107 }
108}
109
110impl Default for CompactionPolicy {
111 /// 1024 secret writes between threshold compactions.
112 fn default() -> Self {
113 CompactionPolicy {
114 after_batches: 1024,
115 reflog_window: 256,
116 }
117 }
118}
119
120// ───────────────────────────── schemas ───────────────────────────────────────
121
122/// The reference log's schema for a repository of `hash_kind`.
123///
124/// The oid width is what pins the hash kind on disk: reopening a sha256 log as
125/// sha1 finds a `FixedSizeBinary(32)` where it wanted `FixedSizeBinary(20)` and
126/// fails with [`Error::HashKindMismatch`] rather than mixing two repositories.
127pub(crate) fn ref_schema(hash_kind: gix_hash::Kind) -> SchemaRef {
128 let width = hash_kind.len_in_bytes() as i32;
129 Arc::new(Schema::new(vec![
130 Field::new(COL_REFNAME, DataType::Utf8, false),
131 Field::new(COL_OLD_OID, DataType::FixedSizeBinary(width), true),
132 Field::new(COL_NEW_OID, DataType::FixedSizeBinary(width), true),
133 Field::new(COL_OLD_SYMREF, DataType::Utf8, true),
134 Field::new(COL_NEW_SYMREF, DataType::Utf8, true),
135 Field::new(COL_ACTOR, DataType::Utf8, false),
136 Field::new(COL_AT, DataType::Int64, false),
137 ]))
138}
139
140/// The secret log's schema. A null `value` is a delete.
141fn secret_schema() -> SchemaRef {
142 Arc::new(Schema::new(vec![
143 Field::new(COL_KEY, DataType::Utf8, false),
144 Field::new(COL_VALUE, DataType::Binary, true),
145 Field::new(COL_ACTOR, DataType::Utf8, false),
146 Field::new(COL_AT, DataType::Int64, false),
147 ]))
148}
149
150fn hash_kind_of_width(width: i32) -> Option<gix_hash::Kind> {
151 match width {
152 20 => Some(gix_hash::Kind::Sha1),
153 32 => Some(gix_hash::Kind::Sha256),
154 _ => None,
155 }
156}
157
158/// Reject a stored schema that is not the one we are about to append to.
159///
160/// A differing oid width is reported as [`Error::HashKindMismatch`] rather than
161/// as a generic schema error, because that is the case an operator can act on.
162pub(crate) fn check_schema(stored: &Schema, expected: &Schema) -> Result<()> {
163 if stored.fields() == expected.fields() {
164 return Ok(());
165 }
166 let width_of =
167 |schema: &Schema, name: &str| match schema.field_with_name(name).map(|f| f.data_type()) {
168 Ok(DataType::FixedSizeBinary(width)) => Some(*width),
169 _ => None,
170 };
171 if let (Some(found), Some(want)) = (
172 width_of(stored, COL_NEW_OID),
173 width_of(expected, COL_NEW_OID),
174 ) {
175 if found != want {
176 if let (Some(stored_kind), Some(want_kind)) =
177 (hash_kind_of_width(found), hash_kind_of_width(want))
178 {
179 return Err(Error::HashKindMismatch {
180 stored: stored_kind,
181 requested: want_kind,
182 });
183 }
184 }
185 }
186 Err(Error::backend(format!(
187 "the log on disk has schema {stored:?}, which is not the schema this store writes ({expected:?})"
188 )))
189}
190
191// ───────────────────────────── framing ───────────────────────────────────────
192
193/// How many bytes of `log` are complete, framed Arrow IPC messages.
194///
195/// This is the torn-write recovery point of the Arrow-IPC ref log and the only thing between
196/// a half-written push and a log that reads as empty forever. Everything from
197/// the returned offset onwards is a message that was never finished, and is
198/// **truncated away** at open, not skipped, because an append after garbage is
199/// unreachable by any reader.
200///
201/// A zero metadata length is the stream's end-of-stream marker. This backend
202/// never writes one (it would make every later append unreachable), but a
203/// compacted or externally-produced log may carry one, so the scan stops
204/// *before* it and a subsequent append overwrites it.
205pub(crate) fn valid_prefix_len(log: &[u8]) -> usize {
206 let mut pos = 0usize;
207 while let Some(total) = message_len(&log[pos..]) {
208 pos += total;
209 }
210 pos
211}
212
213/// The on-disk length of the **one** complete framed message `rest` starts
214/// with, or `None` when it does not start with one.
215///
216/// Split out of [`valid_prefix_len`] rather than copied, because
217/// [`crate::history::live_mark`] needs the length of the *first* message — the
218/// schema — and a second walk of the same framing would be a second definition
219/// of what a complete message is (one implementation). The `while let` above is the whole of
220/// `valid_prefix_len` now, so a change here changes both.
221pub(crate) fn message_len(rest: &[u8]) -> Option<usize> {
222 if rest.len() < 8 || rest[..4] != CONTINUATION_MARKER {
223 return None;
224 }
225 let meta_len = i32::from_le_bytes([rest[4], rest[5], rest[6], rest[7]]);
226 if meta_len <= 0 {
227 return None;
228 }
229 let meta_len = meta_len as usize;
230 if rest.len() < 8 + meta_len {
231 return None;
232 }
233 // Full flatbuffer verification: random bytes that happen to start with a
234 // continuation marker do not get past this.
235 let message = arrow_ipc::root_as_message(&rest[8..8 + meta_len]).ok()?;
236 let body_len = usize::try_from(message.bodyLength()).ok()?;
237 // `write_message` refuses to emit a body that is not already a multiple
238 // of the write alignment, so the recorded body length is exactly what is
239 // on disk. There is no trailing pad to account for.
240 let total = 8usize.checked_add(meta_len)?.checked_add(body_len)?;
241 if rest.len() < total {
242 return None;
243 }
244 Some(total)
245}
246
247// ───────────────────────────── backing store ─────────────────────────────────
248
249/// Where a log's bytes actually live.
250///
251/// The file variant is the normal one. The memory variant is the no-OS shape: no
252/// filesystem, no `libc`, the log is just bytes. It runs the **same**
253/// encode/frame/replay code, so the conformance run against it is evidence about
254/// this backend rather than about a second implementation.
255enum Backing {
256 File { file: std::fs::File, path: PathBuf },
257 Memory { bytes: Arc<Mutex<Vec<u8>>> },
258}
259
260fn poisoned(what: &'static str) -> Error {
261 Error::backend(format!(
262 "the {what} lock was poisoned by a panicking writer"
263 ))
264}
265
266/// The scratch file a compaction writes before the rename. Named beside the
267/// live log so the rename cannot cross a filesystem boundary and stop being
268/// atomic.
269fn compacting_path(path: &Path) -> PathBuf {
270 let name = path
271 .file_name()
272 .map(|n| n.to_string_lossy().into_owned())
273 .unwrap_or_else(|| "log".to_owned());
274 path.with_file_name(format!("{name}.compacting"))
275}
276
277/// A rename is only durable once the *directory entry* is on the platter.
278pub(crate) fn sync_parent_dir(path: &Path) -> Result<()> {
279 let parent = match path.parent() {
280 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
281 _ => PathBuf::from("."),
282 };
283 std::fs::File::open(parent)?.sync_all()?;
284 Ok(())
285}
286
287/// How every message in this log is framed.
288///
289/// **8-byte buffer alignment, not `arrow-ipc`'s default 64.** This is the single
290/// largest lever on the size of the log, and the log's size *is* its open cost:
291/// the whole live stream is read and replayed at every open.
292///
293/// A ref transaction is a **one-row** batch of seven columns. Arrow IPC pads
294/// every buffer up to the write alignment, so those rows — 32 bytes of oid and a
295/// short name — are surrounded by padding that is an order of magnitude larger
296/// than the data. At 64 bytes the padding is paid on each of the batch's
297/// buffers plus the metadata; at 8 bytes, which is the Arrow IPC format's own
298/// stated minimum, it is paid eight times more cheaply.
299///
300/// MEASURED 2026-08-04, 4000 single-ref transactions against a
301/// never-compacting log on md1 (xfs, RAID0 NVMe), **four interleaved A/B pairs**
302/// from two binaries differing only in this function, loadavg 2.6–7.0
303/// throughout. Medians:
304///
305/// | alignment | bytes/push | live log at 4001 pushes | durable pushes/s | µs/push | reopen |
306/// |---|---|---|---|---|---|
307/// | 64 (arrow's default) | 1 536.1 | 6 145 984 B | 15 615 | 64.05 | 20.50 ms |
308/// | **8** | **680.1** | **2 721 128 B** | **17 450** | **57.31** | **16.72 ms** |
309/// | | **2.258× smaller** | 2.258× | **1.118×** | 1.118× | **1.226× faster** |
310///
311/// Every one of the four pairs favoured 8 on every metric; the byte figures are
312/// exact and load-independent.
313///
314/// **The throughput win is a consequence of the size win, not a separate
315/// effect.** `fsync`'s share of the wall clock is 0.830–0.838 *in both arms* —
316/// the sync did not get relatively cheaper, there were simply 2.26× fewer bytes
317/// for it to force through the XFS log. That also explains why the one thing
318/// tried before this — `fsync` → `fdatasync` — measured **no change at all**
319/// (16 252 → 16 449 pushes/s, inside the run-to-run spread): on XFS an append
320/// commits the size update through the log either way, so the timestamp
321/// metadata `fdatasync` skips was never what was being paid for. Recorded so it
322/// is not tried a third time.
323///
324/// # Why this is safe to change
325///
326/// 64 is a *recommendation* in the Arrow IPC specification, for the benefit of
327/// readers that want to hand a buffer straight to a SIMD kernel; 8 is the
328/// specification's requirement. Every message on disk carries its own
329/// `bodyLength`, so a stream written at 64 and appended to at 8 stays readable
330/// message by message and **no migration is needed** — which
331/// `an_existing_64_byte_aligned_log_is_still_read_after_the_alignment_changed`
332/// asserts against real bytes rather than by argument.
333///
334/// What is given up is the guarantee that a mapped body's buffers land on
335/// 64-byte boundaries. Nothing in gunnar maps this log: it is read with
336/// `StreamReader` over a `Cursor`, which copies into freshly-allocated,
337/// suitably-aligned Arrow buffers regardless. An external reader (the module
338/// docs promise DuckDB can read this file) is served by the format guarantee,
339/// not by the recommendation.
340pub(crate) fn write_options() -> IpcWriteOptions {
341 // 8 is one of the four values `IpcWriteOptions::try_new` accepts, so this
342 // cannot fail; `expect` rather than a fallback to the default, because a
343 // silent fallback to 64 would make this whole function unobservable.
344 IpcWriteOptions::try_new(8, false, arrow_ipc::MetadataVersion::V5)
345 .expect("8 is a legal Arrow IPC write alignment")
346}
347
348/// Encode `batches` as a complete, self-contained Arrow IPC stream.
349///
350/// Used both for the rewritten live log and for an archive segment. A fresh
351/// generator and dictionary tracker each time, because this is a **new** stream:
352/// carrying the old stream's tracker over would emit deltas against dictionaries
353/// the new stream never wrote.
354pub(crate) fn encode_stream(
355 schema: &SchemaRef,
356 batches: &[RecordBatch],
357 options: &IpcWriteOptions,
358) -> Result<Vec<u8>> {
359 let generator = IpcDataGenerator::default();
360 let mut tracker = DictionaryTracker::new(false);
361 let mut out = Vec::new();
362 let encoded = generator.schema_to_bytes_with_dictionary_tracker(schema, &mut tracker, options);
363 write_message(&mut out, encoded, options).map_err(arrow_err)?;
364 for batch in batches {
365 let (dictionaries, message) = generator
366 .encode(
367 batch,
368 &mut tracker,
369 options,
370 &mut IpcWriteContext::default(),
371 )
372 .map_err(arrow_err)?;
373 for dictionary in dictionaries {
374 write_message(&mut out, dictionary, options).map_err(arrow_err)?;
375 }
376 write_message(&mut out, message, options).map_err(arrow_err)?;
377 }
378 Ok(out)
379}
380
381impl Backing {
382 fn read_all(&self) -> Result<Vec<u8>> {
383 match self {
384 Backing::File { file, .. } => {
385 let mut handle = file;
386 handle.seek(SeekFrom::Start(0))?;
387 let mut out = Vec::new();
388 handle.read_to_end(&mut out)?;
389 Ok(out)
390 }
391 Backing::Memory { bytes } => {
392 Ok(bytes.lock().map_err(|_| poisoned("in-memory log"))?.clone())
393 }
394 }
395 }
396
397 fn truncate_to(&self, len: u64) -> Result<()> {
398 match self {
399 Backing::File { file, .. } => Ok(file.set_len(len)?),
400 Backing::Memory { bytes } => {
401 bytes
402 .lock()
403 .map_err(|_| poisoned("in-memory log"))?
404 .truncate(len as usize);
405 Ok(())
406 }
407 }
408 }
409
410 fn append(&self, buf: &[u8]) -> Result<()> {
411 match self {
412 Backing::File { file, .. } => {
413 let mut handle = file;
414 handle.seek(SeekFrom::End(0))?;
415 handle.write_all(buf)?;
416 handle.flush()?;
417 Ok(())
418 }
419 Backing::Memory { bytes } => {
420 bytes
421 .lock()
422 .map_err(|_| poisoned("in-memory log"))?
423 .extend_from_slice(buf);
424 Ok(())
425 }
426 }
427 }
428
429 /// Make everything written so far durable.
430 ///
431 /// For the memory backing there is no volatile/durable distinction, so this
432 /// is a no-op that still counts. See the note on
433 /// [`ArrowLogStore::in_memory`] about which claims that shape can support.
434 fn sync(&self) -> Result<()> {
435 match self {
436 Backing::File { file, .. } => Ok(file.sync_all()?),
437 Backing::Memory { .. } => Ok(()),
438 }
439 }
440
441 /// Replace every byte of this log with `bytes`, atomically.
442 ///
443 /// **Never mutates in place**: a new file is written beside the
444 /// live one, fsynced, and *then* renamed over it, and the rename is followed
445 /// by an fsync of the directory so the new name is durable too. A crash
446 /// before the rename leaves the old log complete and serving; a crash after
447 /// it leaves the new one complete. There is no third outcome, which is why
448 /// an interrupted compaction is simply retried.
449 fn replace_all(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
450 self.stage(bytes, fsync)?;
451 self.swap(bytes, fsync)
452 }
453
454 /// Write the replacement beside the live log and make it durable, without
455 /// touching the live log. This is the half a crash can interrupt.
456 ///
457 /// Split out from [`Backing::swap`] so the crash test can stop here and
458 /// assert what an interrupted compaction leaves behind, using the
459 /// production staging code, not a re-implementation of it.
460 fn stage(&self, bytes: &[u8], fsync: bool) -> Result<()> {
461 let Backing::File { path, .. } = self else {
462 return Ok(());
463 };
464 let temporary = compacting_path(path);
465 let mut fresh = OpenOptions::new()
466 .write(true)
467 .create(true)
468 .truncate(true)
469 .open(&temporary)?;
470 fresh.write_all(bytes)?;
471 fresh.flush()?;
472 if fsync {
473 fresh.sync_all()?;
474 }
475 Ok(())
476 }
477
478 /// Put the staged replacement in place. A rename, which is atomic on a real
479 /// filesystem, followed by an fsync of the directory entry.
480 fn swap(&mut self, bytes: &[u8], fsync: bool) -> Result<()> {
481 match self {
482 Backing::File { file, path } => {
483 std::fs::rename(compacting_path(path), &*path)?;
484 if fsync {
485 sync_parent_dir(path)?;
486 }
487 // The old descriptor now points at an unlinked inode.
488 *file = OpenOptions::new()
489 .read(true)
490 .write(true)
491 .create(true)
492 .truncate(false)
493 .open(&*path)?;
494 Ok(())
495 }
496 Backing::Memory { bytes: cell } => {
497 let mut held = cell.lock().map_err(|_| poisoned("in-memory log"))?;
498 held.clear();
499 held.extend_from_slice(bytes);
500 Ok(())
501 }
502 }
503 }
504}
505
506impl std::fmt::Debug for Backing {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 match self {
509 Backing::File { path, .. } => f.debug_struct("File").field("path", path).finish(),
510 Backing::Memory { .. } => f.write_str("Memory"),
511 }
512 }
513}
514
515// ───────────────────────────── the log ───────────────────────────────────────
516
517/// What the durability tests read.
518///
519/// `written` counts every byte handed to the backing store; `durable` counts the
520/// bytes covered by a *completed* sync. A transaction that returns `Ok` with
521/// `durable < written` has acknowledged a push that a power cut would lose.
522///
523/// `written_seq` and `durable_seq` are the same two facts counted in *commits*
524/// rather than bytes, and they exist because [`SyncGate`] needs a watermark that
525/// **never goes backwards**. A byte offset does: a compaction rewrites the log
526/// shorter, so a waiter holding a pre-compaction byte target would wait for an
527/// offset the file will never reach again. A commit count only ever rises.
528#[derive(Debug, Default)]
529struct Durability {
530 written: AtomicU64,
531 durable: AtomicU64,
532 /// Commits handed to the kernel.
533 written_seq: AtomicU64,
534 /// Commits covered by a *completed* sync.
535 durable_seq: AtomicU64,
536 syncs: AtomicUsize,
537 /// Time spent inside `fsync`: the ceiling a ref transaction pays, made visible.
538 fsync_nanos: AtomicU64,
539}
540
541/// The descriptor a durability barrier is issued on, held **apart from the log's
542/// own** so that one thread can `fsync` while another appends. This is group
543/// commit, and it is the whole reason the two are separated.
544///
545/// # Why the fleet was paying N syncs for N pushes
546///
547/// A ref transaction is `lock · validate · encode · write · fsync · unlock`, and
548/// the `fsync` is 83% of it (MEASURED 2026-08-04, md1). With the sync
549/// inside the writer mutex, thirty-two concurrent pushers queue thirty-two
550/// syncs end to end and the log's total throughput does not move:
551///
552/// | concurrent writers | total durable pushes/s | aggregate lock wait |
553/// |---|---|---|
554/// | 1 | 13 038 | 0.000 s |
555/// | 8 | 14 505 | 2.224 s |
556/// | 32 | 14 907 | **9.934 s** against 0.550 s of wall clock |
557///
558/// Flat throughput and lock wait rising linearly with the writer count is the
559/// signature of a serialised barrier, not of a busy disk.
560///
561/// # What it costs now
562///
563/// **Five interleaved A/B pairs** from two binaries differing only in this
564/// change, md1, loadavg matched within each pair (11–26 across the set).
565/// Medians of total durable transactions per second:
566///
567/// | concurrent writers | before | after | |
568/// |---|---|---|---|
569/// | 1 | 14 387 | 14 836 | 1.03× — nothing to group, and nothing lost |
570/// | 2 | 15 901 | 15 964 | 1.00× |
571/// | 4 | 15 324 | 26 355 | **1.72×** |
572/// | 8 | 15 950 | 37 213 | **2.33×** |
573/// | 16 | 15 700 | 40 648 | **2.59×** |
574/// | 32 | 15 070 | 42 117 | **2.79×** |
575///
576/// Aggregate lock wait at 32 writers went **8.5–10.5 s → 0.27–0.66 s**, about
577/// 19×, which is the mechanism showing through: the pushers stopped queueing
578/// behind one another's `fsync`.
579///
580/// The single-writer row is the one that had to not move, and it did not. Group
581/// commit is a **concurrency** lever; it makes a lone pusher neither faster nor
582/// slower, and a change that had quietly taxed the serial path would show here.
583/// The 32-writer column is the widest-spread one (21 236 to 50 873 across the
584/// five runs) because the box was carrying loadavg 22–26 while it ran; the
585/// median is quoted rather than the best, and the *shape* — flat before, rising
586/// after — is what the claim rests on.
587///
588/// # What this does instead
589///
590/// The descriptor here is a `dup` of the log's — the same open file
591/// description and the same inode, so an `fsync` issued on it flushes bytes
592/// another thread's `write` put there through the other descriptor. A committer
593/// therefore:
594///
595/// 1. appends its frame under the writer mutex and takes a commit sequence;
596/// 2. **releases the writer mutex**, so the next pusher can already be writing;
597/// 3. asks this gate to make that sequence durable.
598///
599/// Step 3 is leader/follower. Whoever gets here first reads `written_seq`
600/// *before* syncing and publishes it as `durable_seq` after — so the one sync it
601/// performs is credited with every commit that had already reached the kernel
602/// when it started, which is exactly the set that `fsync` is guaranteed to
603/// cover. Everyone whose sequence is at or below that watermark returns without
604/// syncing at all. N pushers cost the number of *rounds*, not N.
605///
606/// # The one property that changed, stated plainly
607///
608/// The acknowledgement barrier is unchanged: `transaction` still does not return
609/// `Ok` until this gate has made that transaction's bytes durable, and
610/// `a_transaction_is_durable_before_it_returns_ok` still asserts it against the
611/// file's own length.
612///
613/// What did change is **reader visibility**. The in-memory ref map is published
614/// under the writer mutex, which is now released before the sync, so for the
615/// duration of one barrier a concurrent *reader* can observe a ref move whose
616/// bytes are not yet on the platter. Nobody has been promised anything — the
617/// pusher has not been acknowledged — but a fetch served inside that window and
618/// followed by a power cut would have handed out a ref the log no longer names.
619/// This is the ordinary read-uncommitted trade a group-committing log makes, and
620/// it is written down here rather than discovered later.
621struct SyncGate {
622 /// `None` for the in-memory backing, where there is no volatile/durable
623 /// distinction to enforce and the gate is a counter.
624 file: Mutex<Option<std::fs::File>>,
625}
626
627impl SyncGate {
628 fn detached() -> Self {
629 SyncGate {
630 file: Mutex::new(None),
631 }
632 }
633
634 fn on(backing: &Backing) -> Result<Self> {
635 Ok(SyncGate {
636 file: Mutex::new(match backing {
637 Backing::File { file, .. } => Some(file.try_clone()?),
638 Backing::Memory { .. } => None,
639 }),
640 })
641 }
642
643 /// Point the gate at the log's *current* descriptor.
644 ///
645 /// A compaction renames a replacement over the live log and reopens it, so
646 /// the descriptor this gate holds would otherwise keep referring to the old,
647 /// now-unlinked inode and every later `fsync` would flush a file nothing
648 /// reads. Called from [`Log::rewrite`], under the writer mutex.
649 fn rebind(&self, backing: &Backing) -> Result<()> {
650 let mut held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
651 *held = match backing {
652 Backing::File { file, .. } => Some(file.try_clone()?),
653 Backing::Memory { .. } => None,
654 };
655 Ok(())
656 }
657
658 /// Return once commit `target` is durable, doing as little work as the rest
659 /// of the fleet leaves to do.
660 fn barrier(&self, durability: &Durability, target: u64, fsync: bool) -> Result<()> {
661 if !fsync {
662 return Ok(());
663 }
664 // The common case under load: a leader already covered us and we never
665 // touch the mutex at all.
666 if durability.durable_seq.load(Ordering::SeqCst) >= target {
667 return Ok(());
668 }
669 let held = self.file.lock().map_err(|_| poisoned("log sync gate"))?;
670 // Re-checked after queueing, because the leader we queued behind was
671 // very likely syncing on our behalf. This is the line that turns N
672 // syncs into one.
673 if durability.durable_seq.load(Ordering::SeqCst) >= target {
674 return Ok(());
675 }
676 let Some(file) = held.as_ref() else {
677 return Ok(());
678 };
679 // Read BEFORE the sync, published AFTER it. An `fsync` is only
680 // guaranteed to cover writes that completed before it started, and
681 // `written`/`written_seq` are bumped *after* the bytes reach the kernel,
682 // so this watermark can never over-claim.
683 let covers_seq = durability.written_seq.load(Ordering::SeqCst);
684 let covers_bytes = durability.written.load(Ordering::SeqCst);
685 let started = std::time::Instant::now();
686 file.sync_all()?;
687 durability
688 .fsync_nanos
689 .fetch_add(started.elapsed().as_nanos() as u64, Ordering::SeqCst);
690 durability.syncs.fetch_add(1, Ordering::SeqCst);
691 durability.durable.fetch_max(covers_bytes, Ordering::SeqCst);
692 durability
693 .durable_seq
694 .fetch_max(covers_seq, Ordering::SeqCst);
695 Ok(())
696 }
697}
698
699/// One Arrow IPC stream, opened for append.
700struct Log {
701 backing: Backing,
702 schema: SchemaRef,
703 options: IpcWriteOptions,
704 generator: IpcDataGenerator,
705 tracker: DictionaryTracker,
706 durability: Arc<Durability>,
707 /// The descriptor durability barriers are issued on. Shared with the store,
708 /// which reaches it *without* the writer mutex — that separation is the
709 /// group commit; see [`SyncGate`].
710 gate: Arc<SyncGate>,
711 /// Framed record batches currently in the live stream. This is what the
712 /// compaction trigger reads, and what an open would have to replay.
713 batches: usize,
714 /// Set to `false` only by the deliberately-broken store the durability
715 /// guard is asserted against.
716 fsync: bool,
717}
718
719impl std::fmt::Debug for Log {
720 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721 f.debug_struct("Log")
722 .field("backing", &self.backing)
723 .field("durability", &self.durability)
724 .finish()
725 }
726}
727
728impl Log {
729 /// Open `backing` as a stream of `schema`, recovering any torn tail, and
730 /// return every batch already in it.
731 fn open(backing: Backing, schema: SchemaRef, fsync: bool) -> Result<(Self, Vec<RecordBatch>)> {
732 let bytes = backing.read_all()?;
733 let valid = valid_prefix_len(&bytes);
734 if valid < bytes.len() {
735 tracing::warn!(
736 discarded = bytes.len() - valid,
737 kept = valid,
738 "the log ends in an incomplete Arrow IPC message; truncating to the last \
739 complete one so the next append is reachable"
740 );
741 backing.truncate_to(valid as u64)?;
742 backing.sync()?;
743 }
744
745 let gate = Arc::new(if fsync {
746 SyncGate::on(&backing)?
747 } else {
748 // The deliberately-broken store the durability guard is asserted
749 // against. A gate with no descriptor cannot sync, which is the
750 // point: `the_durability_guard_catches_a_missing_fsync` must still
751 // be able to produce a log that acknowledges before the platter.
752 SyncGate::detached()
753 });
754 let mut log = Log {
755 backing,
756 schema,
757 options: write_options(),
758 generator: IpcDataGenerator::default(),
759 tracker: DictionaryTracker::new(false),
760 durability: Arc::new(Durability::default()),
761 gate,
762 batches: 0,
763 fsync,
764 };
765 log.durability.written.store(valid as u64, Ordering::SeqCst);
766 log.durability.durable.store(valid as u64, Ordering::SeqCst);
767
768 let batches = if valid == 0 {
769 log.write_schema()?;
770 Vec::new()
771 } else {
772 let expected = log.schema.clone();
773 let reader = StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None)
774 .map_err(arrow_err)?;
775 check_schema(reader.schema().as_ref(), expected.as_ref())?;
776 reader
777 .collect::<std::result::Result<Vec<_>, _>>()
778 .map_err(arrow_err)?
779 };
780 log.batches = batches.len();
781 Ok((log, batches))
782 }
783
784 /// Replace the whole stream with `batches`, atomically.
785 ///
786 /// The old stream keeps serving until the rename lands; see
787 /// [`Backing::replace_all`].
788 fn rewrite(&mut self, batches: &[RecordBatch]) -> Result<()> {
789 let bytes = encode_stream(&self.schema, batches, &self.options)?;
790 self.backing.replace_all(&bytes, self.fsync)?;
791 // The live log is now a DIFFERENT INODE: `replace_all` renamed a
792 // replacement over it and reopened. The gate still holds a descriptor
793 // on the old, now-unlinked one, and every later barrier would flush a
794 // file nothing reads — a silent loss of durability that no test of the
795 // *contents* could see. Rebind before anything can sync again.
796 self.gate.rebind(&self.backing)?;
797 let len = bytes.len() as u64;
798 self.durability.written.store(len, Ordering::SeqCst);
799 if self.fsync {
800 self.durability.syncs.fetch_add(1, Ordering::SeqCst);
801 self.durability.durable.store(len, Ordering::SeqCst);
802 // Everything staged before this point is durable by construction:
803 // `replace_all` fsynced a complete replacement that contains it.
804 // Publishing the watermark releases any waiter whose sequence the
805 // shortened file will never reach as a byte offset again — which is
806 // exactly why the watermark counts commits and not bytes.
807 let staged = self.durability.written_seq.load(Ordering::SeqCst);
808 self.durability
809 .durable_seq
810 .fetch_max(staged, Ordering::SeqCst);
811 } else {
812 self.durability.durable.store(0, Ordering::SeqCst);
813 }
814 // A rewritten log is a NEW stream. Its schema message was emitted by a
815 // fresh generator, so the appending one has to be fresh too or the next
816 // append encodes against state this stream never wrote.
817 self.generator = IpcDataGenerator::default();
818 self.tracker = DictionaryTracker::new(false);
819 self.batches = batches.len();
820 Ok(())
821 }
822
823 fn write_schema(&mut self) -> Result<()> {
824 let encoded = self.generator.schema_to_bytes_with_dictionary_tracker(
825 &self.schema,
826 &mut self.tracker,
827 &self.options,
828 );
829 let mut framed = Vec::new();
830 write_message(&mut framed, encoded, &self.options).map_err(arrow_err)?;
831 self.commit(&framed)
832 }
833
834 /// Append **one** `RecordBatch` as **one** framed IPC message, and return
835 /// the **commit sequence** that must be made durable before the caller may
836 /// acknowledge it.
837 ///
838 /// This does not sync. The caller releases the writer mutex and then calls
839 /// [`Log::barrier`] with what this returned — which is what lets the next
840 /// pusher encode and write while this one is still on the platter. See
841 /// [`SyncGate`].
842 fn append(&mut self, batch: &RecordBatch) -> Result<u64> {
843 let (dictionaries, message) = self
844 .generator
845 .encode(
846 batch,
847 &mut self.tracker,
848 &self.options,
849 &mut IpcWriteContext::default(),
850 )
851 .map_err(arrow_err)?;
852 let mut framed = Vec::new();
853 // This schema carries no dictionary-encoded column, so this loop is
854 // empty in practice; it is here so that adding one cannot silently drop
855 // the dictionary messages the batch would then depend on.
856 for dictionary in dictionaries {
857 write_message(&mut framed, dictionary, &self.options).map_err(arrow_err)?;
858 }
859 write_message(&mut framed, message, &self.options).map_err(arrow_err)?;
860 let sequence = self.stage(&framed)?;
861 self.batches += 1;
862 Ok(sequence)
863 }
864
865 /// Write `framed` and take a commit sequence for it. **Not durable yet.**
866 ///
867 /// The two counters are bumped *after* the bytes reach the kernel, which is
868 /// what makes [`SyncGate::barrier`]'s read-before-sync watermark honest: a
869 /// sequence that is visible in `written_seq` is a sequence whose bytes an
870 /// `fsync` starting now is guaranteed to cover.
871 fn stage(&self, framed: &[u8]) -> Result<u64> {
872 self.backing.append(framed)?;
873 self.durability
874 .written
875 .fetch_add(framed.len() as u64, Ordering::SeqCst);
876 Ok(self.durability.written_seq.fetch_add(1, Ordering::SeqCst) + 1)
877 }
878
879 /// Make commit `sequence` durable. Callable **without** the writer mutex.
880 fn barrier(&self, sequence: u64) -> Result<()> {
881 self.gate.barrier(&self.durability, sequence, self.fsync)
882 }
883
884 /// Write `framed` and make it durable before returning — the whole
885 /// acknowledgement barrier in one call, for the paths that hold the writer
886 /// mutex throughout anyway (opening a fresh log, sealing a secret) and gain
887 /// nothing from group commit.
888 fn commit(&self, framed: &[u8]) -> Result<()> {
889 let sequence = self.stage(framed)?;
890 self.barrier(sequence)
891 }
892}
893
894// ───────────────────────────── the lock ──────────────────────────────────────
895
896/// The `O_EXCL` lock file that makes "one writer" true across processes.
897///
898/// `flock` would release itself when a crashed process's descriptors close; an
899/// `O_EXCL` file does not, and needs no `unsafe` and no `libc`, which this
900/// crate forbids outright.
901///
902/// # Recovery is at startup, not an operator verb
903///
904/// The trade used to be *"a stale lock after a hard kill, cleared by
905/// [`ArrowLogStore::force_unlock`]"*, and it does not survive the no-OS rule's premise: one
906/// binary as PID 1 in a distroless image, with no shell and nobody to type the
907/// verb. A power cut left every repository the server had open holding a lock
908/// and the appliance booted into a permanent refusal to serve.
909///
910/// So the lock file records **who** holds it and [`acquire`](LockFile::acquire)
911/// reads that back. The owner is `<pid> <start-time>`: the pid alone is not
912/// enough, because pids are recycled and a recycled one reads as "still
913/// running". The start time is field 22 of `/proc/<pid>/stat`, in clock ticks
914/// since boot, which changes on every process — so a pid that has been reused
915/// is detected rather than obeyed. No `unsafe`, no `libc`, no new dependency.
916///
917/// **The rule is: reclaim only what is provably dead.** Alive, or *undecidable*
918/// — no `/proc` on this platform, an unparseable owner line, a lock written by
919/// an older gunnar that recorded no start time — both mean refuse.
920/// [`ArrowLogStore::force_unlock`] stays for exactly those cases. Reclaiming a
921/// lock that is genuinely held lets two writers interleave frames and destroys
922/// the log, which is far worse than an appliance that will not start.
923#[derive(Debug)]
924struct LockFile {
925 path: PathBuf,
926}
927
928/// Who a lock file says holds it.
929#[derive(Debug, Clone, PartialEq, Eq)]
930struct LockOwner {
931 pid: u32,
932 /// `None` for a lock file written before the liveness check, which recorded
933 /// only a pid.
934 /// It is what makes pid reuse undetectable, so it is treated as
935 /// undecidable rather than as "no reuse happened".
936 started: Option<u64>,
937}
938
939impl LockOwner {
940 fn parse(text: &str) -> Option<Self> {
941 let mut fields = text.split_whitespace();
942 let pid = fields.next()?.parse().ok()?;
943 let started = fields.next().and_then(|f| f.parse().ok());
944 Some(LockOwner { pid, started })
945 }
946
947 fn line(&self) -> String {
948 match self.started {
949 Some(started) => format!("{} {}\n", self.pid, started),
950 None => format!("{}\n", self.pid),
951 }
952 }
953
954 fn this_process() -> Self {
955 let pid = std::process::id();
956 LockOwner {
957 pid,
958 started: match proc_start_time(pid) {
959 ProcStartTime::Started(ticks) => Some(ticks),
960 _ => None,
961 },
962 }
963 }
964}
965
966/// What `/proc` says about a pid.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968enum ProcStartTime {
969 /// The process exists and started at this tick.
970 Started(u64),
971 /// There is no such process. The one answer that permits a reclaim.
972 NoSuchProcess,
973 /// No `/proc`, or a `stat` that did not parse. **Not** the same as dead.
974 Unknown,
975}
976
977/// Read field 22 of `/proc/<pid>/stat` — the process's start time in clock
978/// ticks since boot.
979///
980/// Field 2 is `comm`, which is parenthesised and may itself contain spaces and
981/// close-parens, so the split is anchored on the **last** `)` in the line and
982/// not on whitespace. Everything after it starts at field 3, which puts field
983/// 22 at index 19.
984fn proc_start_time(pid: u32) -> ProcStartTime {
985 // If this platform has no `/proc` at all, a missing `/proc/<pid>/stat` says
986 // nothing about the process, and reading it as "dead" would reclaim every
987 // live lock on that platform.
988 if !Path::new("/proc/self/stat").exists() {
989 return ProcStartTime::Unknown;
990 }
991 let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
992 Ok(stat) => stat,
993 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
994 return ProcStartTime::NoSuchProcess
995 }
996 Err(_) => return ProcStartTime::Unknown,
997 };
998 let Some((_, fields)) = stat.rsplit_once(')') else {
999 return ProcStartTime::Unknown;
1000 };
1001 // Field 3, the state. A zombie has been killed and is waiting to be
1002 // collected by its parent: it holds no descriptors and can write nothing,
1003 // so it is gone for the purpose of a lock — and its pid cannot be recycled
1004 // while it sits there, so calling it gone is safe as well as true. Without
1005 // this a crashed appliance whose reaper is slow still refuses to start.
1006 if fields.split_whitespace().next() == Some("Z") {
1007 return ProcStartTime::NoSuchProcess;
1008 }
1009 match fields
1010 .split_whitespace()
1011 .nth(19)
1012 .and_then(|ticks| ticks.parse().ok())
1013 {
1014 Some(ticks) => ProcStartTime::Started(ticks),
1015 None => ProcStartTime::Unknown,
1016 }
1017}
1018
1019/// Is the lock file's owner still running?
1020///
1021/// `false` only when the answer is **certain**. See [`LockFile`].
1022fn owner_is_gone(owner: &LockOwner) -> bool {
1023 match (proc_start_time(owner.pid), owner.started) {
1024 (ProcStartTime::NoSuchProcess, _) => true,
1025 // The pid is live but is not the process that took the lock: it was
1026 // recycled. This is the case a bare `/proc/<pid>` existence check gets
1027 // wrong, and it gets it wrong in the direction of never recovering.
1028 (ProcStartTime::Started(now), Some(then)) => now != then,
1029 // A live pid whose identity cannot be confirmed, or no `/proc`.
1030 (ProcStartTime::Started(_), None) | (ProcStartTime::Unknown, _) => false,
1031 }
1032}
1033
1034impl LockFile {
1035 fn acquire(path: PathBuf) -> Result<Self> {
1036 match Self::create(&path) {
1037 Ok(lock) => Ok(lock),
1038 Err(Error::StoreLocked { .. }) => Self::reclaim_if_stale(path),
1039 Err(err) => Err(err),
1040 }
1041 }
1042
1043 fn create(path: &Path) -> Result<Self> {
1044 match OpenOptions::new().write(true).create_new(true).open(path) {
1045 Ok(mut file) => {
1046 let owner = LockOwner::this_process();
1047 // Not best-effort any more: an owner line that failed to reach
1048 // the disk makes every later recovery undecidable, and a
1049 // silent failure here would only be discovered after a crash.
1050 if let Err(err) = write!(file, "{}", owner.line()).and_then(|()| file.sync_all()) {
1051 tracing::warn!(
1052 path = %path.display(),
1053 %err,
1054 "the store lock could not record its owner; a crash will need \
1055 force_unlock"
1056 );
1057 }
1058 Ok(LockFile {
1059 path: path.to_path_buf(),
1060 })
1061 }
1062 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
1063 Err(Error::StoreLocked {
1064 path: path.to_path_buf(),
1065 })
1066 }
1067 Err(err) => Err(Error::Io(err)),
1068 }
1069 }
1070
1071 fn reclaim_if_stale(path: PathBuf) -> Result<Self> {
1072 let owner = std::fs::read_to_string(&path)
1073 .ok()
1074 .and_then(|text| LockOwner::parse(&text));
1075 let Some(owner) = owner else {
1076 tracing::warn!(
1077 path = %path.display(),
1078 "the store lock names no owner this gunnar can read, so it cannot be \
1079 shown to be stale; refusing"
1080 );
1081 return Err(Error::StoreLocked { path });
1082 };
1083 if !owner_is_gone(&owner) {
1084 return Err(Error::StoreLocked { path });
1085 }
1086 tracing::warn!(
1087 path = %path.display(),
1088 pid = owner.pid,
1089 "the store lock was left by a process that is gone; reclaiming it"
1090 );
1091 match std::fs::remove_file(&path) {
1092 Ok(()) => {}
1093 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1094 Err(err) => return Err(Error::Io(err)),
1095 }
1096 // Deliberately one attempt: if a second starter reclaimed the same
1097 // stale lock a microsecond earlier, it now holds a live lock and this
1098 // one must refuse. Retrying would be a loop that ends in two writers.
1099 Self::create(&path)
1100 }
1101}
1102
1103impl Drop for LockFile {
1104 fn drop(&mut self) {
1105 if let Err(err) = std::fs::remove_file(&self.path) {
1106 tracing::warn!(path = %self.path.display(), %err, "could not release the store lock");
1107 }
1108 }
1109}
1110
1111// ───────────────────────────── the store ─────────────────────────────────────
1112
1113/// The bytes of a store's secret log when there is no filesystem at all.
1114///
1115/// Hand the same handle back to [`ArrowLogStore::open_memory`] to simulate a
1116/// restart: the store is rebuilt by replaying these bytes, exactly as the file
1117/// backing replays a file.
1118#[derive(Clone, Debug, Default)]
1119pub struct MemoryLog {
1120 secrets: Arc<Mutex<Vec<u8>>>,
1121}
1122
1123struct Inner {
1124 hash_kind: gix_hash::Kind,
1125 actor: String,
1126 policy: CompactionPolicy,
1127 secrets_log: Mutex<Log>,
1128 secrets_state: RwLock<BTreeMap<String, Vec<u8>>>,
1129 _lock: Option<LockFile>,
1130}
1131
1132/// One repository's secrets, as an Arrow IPC log.
1133///
1134/// Cheap to clone: every clone is the same store, and the exclusive lock lives
1135/// until the last handle is dropped.
1136#[derive(Clone)]
1137pub struct ArrowLogStore {
1138 inner: Arc<Inner>,
1139}
1140
1141impl std::fmt::Debug for ArrowLogStore {
1142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143 f.debug_struct("ArrowLogStore")
1144 .field("hash_kind", &self.inner.hash_kind)
1145 .field("lock", &self.inner._lock)
1146 .finish()
1147 }
1148}
1149
1150impl ArrowLogStore {
1151 /// Open the logs in `dir`, creating them if absent, and take the exclusive lock.
1152 ///
1153 /// Fails with [`Error::StoreLocked`] if another writer already holds it, and
1154 /// with [`Error::HashKindMismatch`] if the log on disk belongs to a
1155 /// repository of the other hash kind.
1156 pub fn open(dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
1157 Self::open_inner(dir.as_ref(), hash_kind, true, CompactionPolicy::default())
1158 }
1159
1160 /// [`ArrowLogStore::open`] with a chosen compaction policy.
1161 ///
1162 /// The operator lever for threshold-based secret-log compaction. Replaced or
1163 /// deleted secrets are compacted immediately regardless of this policy.
1164 pub fn open_with_policy(
1165 dir: impl AsRef<Path>,
1166 hash_kind: gix_hash::Kind,
1167 policy: CompactionPolicy,
1168 ) -> Result<Self> {
1169 Self::open_inner(dir.as_ref(), hash_kind, true, policy)
1170 }
1171
1172 fn open_inner(
1173 dir: &Path,
1174 hash_kind: gix_hash::Kind,
1175 fsync: bool,
1176 policy: CompactionPolicy,
1177 ) -> Result<Self> {
1178 std::fs::create_dir_all(dir)?;
1179 // The lock comes first: nothing may read a log it is not entitled to
1180 // append to, because the replayed state is what CAS is checked against.
1181 let lock = LockFile::acquire(dir.join(LOCK_FILE))?;
1182 let open_file = |name: &str| -> Result<Backing> {
1183 let path = dir.join(name);
1184 let file = OpenOptions::new()
1185 .read(true)
1186 .write(true)
1187 .create(true)
1188 .truncate(false)
1189 .open(&path)?;
1190 Ok(Backing::File { file, path })
1191 };
1192 Self::assemble(
1193 hash_kind,
1194 open_file(SECRETS_LOG)?,
1195 Some(lock),
1196 fsync,
1197 policy,
1198 )
1199 }
1200
1201 /// A store with no file behind it. This is the no-OS shape, and what the
1202 /// no-filesystem conformance run uses.
1203 ///
1204 /// The framing, encoding and replay code is the same as the file backing's;
1205 /// what this shape cannot speak to is durability, since there is nothing for
1206 /// an fsync to mean. The durability claim is made by the file tests only.
1207 pub fn in_memory(hash_kind: gix_hash::Kind) -> Result<(Self, MemoryLog)> {
1208 let log = MemoryLog::default();
1209 let store = Self::open_memory(&log, hash_kind)?;
1210 Ok((store, log))
1211 }
1212
1213 /// Rebuild a store by replaying `log`, the in-memory restart.
1214 pub fn open_memory(log: &MemoryLog, hash_kind: gix_hash::Kind) -> Result<Self> {
1215 Self::open_memory_with_policy(log, hash_kind, CompactionPolicy::default())
1216 }
1217
1218 /// [`ArrowLogStore::open_memory`] with a chosen compaction policy.
1219 pub fn open_memory_with_policy(
1220 log: &MemoryLog,
1221 hash_kind: gix_hash::Kind,
1222 policy: CompactionPolicy,
1223 ) -> Result<Self> {
1224 Self::assemble(
1225 hash_kind,
1226 Backing::Memory {
1227 bytes: log.secrets.clone(),
1228 },
1229 None,
1230 true,
1231 policy,
1232 )
1233 }
1234
1235 fn assemble(
1236 hash_kind: gix_hash::Kind,
1237 secrets_backing: Backing,
1238 lock: Option<LockFile>,
1239 fsync: bool,
1240 policy: CompactionPolicy,
1241 ) -> Result<Self> {
1242 let (secrets_log, secret_batches) = Log::open(secrets_backing, secret_schema(), fsync)?;
1243 let secrets_batches_on_open = secrets_log.batches;
1244 let store = ArrowLogStore {
1245 inner: Arc::new(Inner {
1246 hash_kind,
1247 actor: DEFAULT_ACTOR.to_owned(),
1248 policy,
1249 secrets_log: Mutex::new(secrets_log),
1250 secrets_state: RwLock::new(replay_secrets(&secret_batches)?),
1251 _lock: lock,
1252 }),
1253 };
1254 if policy.should_compact(secrets_batches_on_open) {
1255 if let Err(err) = store.compact_secrets_now() {
1256 tracing::warn!(%err, "could not fold an oversized secret log at open");
1257 }
1258 }
1259 Ok(store)
1260 }
1261
1262 /// Remove a lock file left behind by a process that was killed.
1263 ///
1264 /// Only ever correct when no gunnar is running against `dir`. The lock is
1265 /// what keeps Arrow stream appends single-writer; breaking one that is
1266 /// genuinely held lets two writers interleave frames and destroy the log.
1267 ///
1268 /// # This is no longer the normal recovery path
1269 ///
1270 /// [`open`](ArrowLogStore::open) reclaims a lock whose owner is **provably**
1271 /// gone, so an appliance with no shell restarts after a power cut on its
1272 /// own. What is left for this verb is the undecidable cases, where breaking
1273 /// the lock is a judgement no program should make unasked: a platform with
1274 /// no `/proc`, a lock file written by a gunnar older than the liveness
1275 /// check, or one
1276 /// whose owner line never reached the disk.
1277 pub fn force_unlock(dir: impl AsRef<Path>) -> Result<()> {
1278 match std::fs::remove_file(dir.as_ref().join(LOCK_FILE)) {
1279 Ok(()) => Ok(()),
1280 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1281 Err(err) => Err(Error::Io(err)),
1282 }
1283 }
1284
1285 /// The hash kind this repository uses.
1286 pub fn hash_kind(&self) -> gix_hash::Kind {
1287 self.inner.hash_kind
1288 }
1289
1290 /// The compaction policy in force.
1291 pub fn compaction_policy(&self) -> CompactionPolicy {
1292 self.inner.policy
1293 }
1294
1295 /// Fold the secret log: current values only, no history, no archive.
1296 fn compact_secrets_now(&self) -> Result<()> {
1297 let mut log = self
1298 .inner
1299 .secrets_log
1300 .lock()
1301 .map_err(|_| poisoned("secret log"))?;
1302 compact_secrets(&self.inner, &mut log)
1303 }
1304
1305 /// A handle implementing [`SecretStore`].
1306 pub fn secrets(&self) -> ArrowLogSecretStore {
1307 ArrowLogSecretStore {
1308 inner: self.inner.clone(),
1309 }
1310 }
1311}
1312
1313// ───────────────────────────── shared helpers ────────────────────────────────
1314
1315fn now_unix_ms() -> i64 {
1316 SystemTime::now()
1317 .duration_since(UNIX_EPOCH)
1318 .map(|d| d.as_millis() as i64)
1319 .unwrap_or(0)
1320}
1321
1322/// An Arrow fault, as a [`gunnar_store::Error`].
1323///
1324/// `gunnar-store` used to carry `impl From<arrow_schema::ArrowError> for Error`
1325/// so a `?` did this implicitly. It does not any more: that `From` was the last
1326/// Arrow in the contract crate, and keeping one impl there would have kept the
1327/// dependency edge this module's move to `gunnar-core` exists to cut (see
1328/// `gunnar_store`'s crate header). One function, called at every site the `?`
1329/// used to cover, rather than a second opinion about what an Arrow fault is.
1330pub(crate) fn arrow_err(err: arrow_schema::ArrowError) -> Error {
1331 Error::backend(format!("arrow: {err}"))
1332}
1333
1334pub(crate) fn column<'a, T: 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
1335 batch
1336 .column_by_name(name)
1337 .and_then(|c| c.as_any().downcast_ref::<T>())
1338 .ok_or_else(|| {
1339 Error::backend(format!(
1340 "the Arrow log has no usable `{name}` column; the file is not a gunnar log"
1341 ))
1342 })
1343}
1344
1345// ───────────────────────────── compaction ────────────────────────────────────
1346
1347/// The same fold for secrets, with **no archive and no window**.
1348///
1349/// Retaining superseded secret values would make a rotated host key immortal,
1350/// which is the opposite of what rotation is for.
1351fn compact_secrets(inner: &Inner, log: &mut Log) -> Result<()> {
1352 let snapshot = {
1353 let state = inner.secrets_state.read().map_err(|_| poisoned("secret"))?;
1354 let mut keys = StringBuilder::new();
1355 let mut values = BinaryBuilder::new();
1356 let mut actors = StringBuilder::new();
1357 let mut ats = Int64Builder::new();
1358 let at = now_unix_ms();
1359 for (key, value) in state.iter() {
1360 keys.append_value(key);
1361 values.append_value(value);
1362 actors.append_value(COMPACTION_ACTOR);
1363 ats.append_value(at);
1364 }
1365 RecordBatch::try_new(
1366 log.schema.clone(),
1367 vec![
1368 Arc::new(keys.finish()),
1369 Arc::new(values.finish()),
1370 Arc::new(actors.finish()),
1371 Arc::new(ats.finish()),
1372 ],
1373 )
1374 .map_err(arrow_err)?
1375 };
1376 log.rewrite(&[snapshot])
1377}
1378
1379// ───────────────────────────── secrets ───────────────────────────────────────
1380
1381/// Secrets in an Arrow IPC log: the SSH host key and the admin bootstrap
1382/// material, on a box with no home directory.
1383#[derive(Clone)]
1384pub struct ArrowLogSecretStore {
1385 inner: Arc<Inner>,
1386}
1387
1388impl std::fmt::Debug for ArrowLogSecretStore {
1389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1390 f.write_str("ArrowLogSecretStore")
1391 }
1392}
1393
1394fn build_secret_batch(
1395 schema: &SchemaRef,
1396 actor: &str,
1397 at: i64,
1398 key: &str,
1399 value: Option<&[u8]>,
1400) -> Result<RecordBatch> {
1401 let mut keys = StringBuilder::new();
1402 let mut values = BinaryBuilder::new();
1403 let mut actors = StringBuilder::new();
1404 let mut ats = Int64Builder::new();
1405 keys.append_value(key);
1406 match value {
1407 Some(bytes) => values.append_value(bytes),
1408 None => values.append_null(),
1409 }
1410 actors.append_value(actor);
1411 ats.append_value(at);
1412 RecordBatch::try_new(
1413 schema.clone(),
1414 vec![
1415 Arc::new(keys.finish()),
1416 Arc::new(values.finish()),
1417 Arc::new(actors.finish()),
1418 Arc::new(ats.finish()),
1419 ],
1420 )
1421 .map_err(arrow_err)
1422}
1423
1424fn replay_secrets(batches: &[RecordBatch]) -> Result<BTreeMap<String, Vec<u8>>> {
1425 let mut state = BTreeMap::new();
1426 for batch in batches {
1427 let keys = column::<StringArray>(batch, COL_KEY)?;
1428 let values = column::<BinaryArray>(batch, COL_VALUE)?;
1429 for row in 0..batch.num_rows() {
1430 let key = keys.value(row).to_owned();
1431 if values.is_null(row) {
1432 state.remove(&key);
1433 } else {
1434 state.insert(key, values.value(row).to_vec());
1435 }
1436 }
1437 }
1438 Ok(state)
1439}
1440
1441impl ArrowLogSecretStore {
1442 fn write(&self, key: &str, value: Option<&[u8]>) -> Result<()> {
1443 let mut log = self
1444 .inner
1445 .secrets_log
1446 .lock()
1447 .map_err(|_| poisoned("secret log"))?;
1448 self.write_locked(&mut log, key, value)
1449 }
1450
1451 fn write_locked(&self, log: &mut Log, key: &str, value: Option<&[u8]>) -> Result<()> {
1452 let batch = build_secret_batch(&log.schema, &self.inner.actor, now_unix_ms(), key, value)?;
1453 // Append AND barrier, both under the writer mutex — deliberately *not*
1454 // the group commit the ref log does.
1455 //
1456 // A secret is written when a host key is minted or rotated, which is
1457 // rare and never concurrent, so there is nothing to group and releasing
1458 // the lock first would buy nothing while widening the window in which a
1459 // rotated secret is still readable. `append` only stages; forgetting
1460 // this line is a secret that is acknowledged and not on the platter,
1461 // which is what `a_secret_is_durable_before_the_write_returns` exists to
1462 // catch.
1463 let sequence = log.append(&batch)?;
1464 log.barrier(sequence)?;
1465 let superseded = {
1466 let mut state = self
1467 .inner
1468 .secrets_state
1469 .write()
1470 .map_err(|_| poisoned("secret"))?;
1471 match value {
1472 Some(bytes) => state.insert(key.to_owned(), bytes.to_vec()).is_some(),
1473 None => state.remove(key).is_some(),
1474 }
1475 };
1476
1477 // A superseded secret is compacted out **now**, not when a batch count
1478 // says so.
1479 //
1480 // This module's own header says "a rotated SSH host key must actually
1481 // stop existing". It did not: the log is append-only and the only
1482 // trigger was `should_compact(log.batches)`, so the previous value sat
1483 // in `secrets.arrow` for up to `after_batches` further writes — on a
1484 // store that sees a handful of writes in its lifetime, that is
1485 // *forever*. Found by `gunnar-core`'s sealing-migration test, which sealed a
1486 // legacy plaintext host key and then found the plaintext still in the
1487 // file.
1488 //
1489 // Refs deliberately keep their history; secrets are the one log where
1490 // the old value is the defect. The fold is a rewrite of a map with a
1491 // handful of entries, so doing it on every replacement costs nothing
1492 // that a threshold was protecting.
1493 if superseded {
1494 compact_secrets(&self.inner, log).map_err(|err| {
1495 Error::backend(format!(
1496 "the new value for the secret {key} was stored, but the PREVIOUS value \
1497 could not be compacted out of the log and is still on disk: {err}"
1498 ))
1499 })?;
1500 } else if self.inner.policy.should_compact(log.batches) {
1501 if let Err(err) = compact_secrets(&self.inner, log) {
1502 tracing::warn!(%err, "could not compact the secret log; it keeps growing");
1503 }
1504 }
1505 Ok(())
1506 }
1507
1508 fn current(&self, key: &str) -> Result<Option<Vec<u8>>> {
1509 Ok(self
1510 .inner
1511 .secrets_state
1512 .read()
1513 .map_err(|_| poisoned("secret"))?
1514 .get(key)
1515 .cloned())
1516 }
1517}
1518
1519impl SecretStore for ArrowLogSecretStore {
1520 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
1521 self.current(key)
1522 }
1523
1524 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
1525 self.write(key, Some(value))
1526 }
1527
1528 fn delete(&self, key: &str) -> Result<()> {
1529 // A tombstone for a key that is not there would grow the log for
1530 // nothing, and "delete what is absent" is explicitly not an error.
1531 if self.current(key)?.is_none() {
1532 return Ok(());
1533 }
1534 self.write(key, None)
1535 }
1536
1537 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
1538 // The writer mutex is taken *before* the existence check, so the loser of
1539 // a race blocks here and then finds the winner's value. `mk` runs once.
1540 let mut log = self
1541 .inner
1542 .secrets_log
1543 .lock()
1544 .map_err(|_| poisoned("secret log"))?;
1545 if let Some(existing) = self.current(key)? {
1546 return Ok(existing);
1547 }
1548 let value = mk()?;
1549 self.write_locked(&mut log, key, Some(&value))?;
1550 Ok(value)
1551 }
1552}
1553
1554// ───────────────────────────── tests ─────────────────────────────────────────
1555
1556#[cfg(test)]
1557mod tests {
1558 use super::*;
1559 use gunnar_store::SecretStoreExt;
1560
1561 const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
1562
1563 fn dir() -> tempfile::TempDir {
1564 tempfile::tempdir().expect("a temporary directory")
1565 }
1566
1567 #[test]
1568 fn the_in_memory_secret_log_replays_across_a_rebuild() {
1569 let (store, log) = ArrowLogStore::in_memory(KIND).expect("in-memory store");
1570 store.secrets().put("k", b"v").expect("put");
1571 drop(store);
1572
1573 let rebuilt = ArrowLogStore::open_memory(&log, KIND).expect("replay");
1574 assert_eq!(
1575 rebuilt.secrets().get("k").expect("get"),
1576 Some(b"v".to_vec())
1577 );
1578 }
1579
1580 #[test]
1581 fn a_secret_delete_is_a_tombstone_that_survives_a_reopen() {
1582 let dir = dir();
1583 {
1584 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1585 store.secrets().put("k", b"v1").expect("put");
1586 store.secrets().delete("k").expect("delete");
1587 }
1588 let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1589 assert!(store.secrets().get("k").expect("get").is_none());
1590 store.secrets().put("k", b"v2").expect("put again");
1591 assert_eq!(store.secrets().get("k").expect("get"), Some(b"v2".to_vec()));
1592 }
1593
1594 #[test]
1595 fn a_rotated_secret_leaves_no_trace_of_the_previous_value_on_disk() {
1596 let dir = dir();
1597 let old = b"OLD-SECRET-VALUE-that-must-not-survive-rotation";
1598 let new = b"NEW-SECRET-VALUE-after-the-rotation";
1599
1600 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1601 store.secrets().put("ssh/host_ed25519", old).expect("put");
1602
1603 let contains = |needle: &[u8]| {
1604 let bytes = std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log");
1605 assert!(!bytes.is_empty(), "the secret log is empty");
1606 bytes.windows(needle.len()).any(|w| w == needle)
1607 };
1608 assert!(contains(old));
1609
1610 store
1611 .secrets()
1612 .put("ssh/host_ed25519", new)
1613 .expect("rotate");
1614 assert!(!contains(old), "the previous secret survived rotation");
1615 assert!(contains(new), "the new value is not in the log");
1616 assert_eq!(
1617 store.secrets().get("ssh/host_ed25519").expect("get"),
1618 Some(new.to_vec())
1619 );
1620
1621 store.secrets().delete("ssh/host_ed25519").expect("delete");
1622 assert!(
1623 !contains(new),
1624 "a deleted secret's value is still in the log"
1625 );
1626 }
1627
1628 #[test]
1629 fn compacting_secrets_does_not_retain_superseded_values() {
1630 let dir = dir();
1631 let store = ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1632 .expect("open");
1633 for generation in 0..12 {
1634 store
1635 .secrets()
1636 .put(
1637 "host-key",
1638 format!("secret-generation-{generation:02}").as_bytes(),
1639 )
1640 .expect("put");
1641 }
1642
1643 let haystack = String::from_utf8_lossy(
1644 &std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log"),
1645 )
1646 .into_owned();
1647 assert!(!haystack.contains("secret-generation-00"));
1648 assert!(haystack.contains("secret-generation-11"));
1649 assert!(!dir.path().join(REFS_ARCHIVE_DIR).exists());
1650 drop(store);
1651
1652 let reopened =
1653 ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1654 .expect("reopen");
1655 assert_eq!(
1656 reopened.secrets().get("host-key").expect("get"),
1657 Some(b"secret-generation-11".to_vec())
1658 );
1659 }
1660
1661 #[test]
1662 fn get_or_create_does_not_remint_across_a_reopen() {
1663 let dir = dir();
1664 let first = {
1665 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1666 store
1667 .secrets()
1668 .get_or_create_with(gunnar_store::SSH_HOST_KEY, || Ok(b"host-key-1".to_vec()))
1669 .expect("mint")
1670 };
1671 let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1672 let second = store
1673 .secrets()
1674 .get_or_create_with(gunnar_store::SSH_HOST_KEY, || {
1675 panic!("the host key was re-minted after a restart")
1676 })
1677 .expect("read back");
1678 assert_eq!(first, second);
1679 }
1680
1681 #[test]
1682 fn a_secret_is_durable_before_the_write_returns() {
1683 let dir = dir();
1684 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1685 store
1686 .secrets()
1687 .put(gunnar_store::SSH_HOST_KEY, b"a-host-key")
1688 .expect("write a secret");
1689
1690 let on_disk = std::fs::metadata(dir.path().join(SECRETS_LOG))
1691 .expect("stat")
1692 .len();
1693 let durability = &store
1694 .inner
1695 .secrets_log
1696 .lock()
1697 .expect("the writer")
1698 .durability;
1699 let durable = durability.durable.load(Ordering::SeqCst);
1700 let written = durability.written.load(Ordering::SeqCst);
1701 assert_eq!(written, on_disk);
1702 assert_eq!(durable, written);
1703 assert!(durability.syncs.load(Ordering::SeqCst) >= 2);
1704 }
1705}