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 ipc_guard(&bytes[..valid])?;
774 let reader = StreamReader::try_new(std::io::Cursor::new(&bytes[..valid]), None)
775 .map_err(arrow_err)?;
776 check_schema(reader.schema().as_ref(), expected.as_ref())?;
777 reader
778 .collect::<std::result::Result<Vec<_>, _>>()
779 .map_err(arrow_err)?
780 };
781 log.batches = batches.len();
782 Ok((log, batches))
783 }
784
785 /// Replace the whole stream with `batches`, atomically.
786 ///
787 /// The old stream keeps serving until the rename lands; see
788 /// [`Backing::replace_all`].
789 fn rewrite(&mut self, batches: &[RecordBatch]) -> Result<()> {
790 let bytes = encode_stream(&self.schema, batches, &self.options)?;
791 self.backing.replace_all(&bytes, self.fsync)?;
792 // The live log is now a DIFFERENT INODE: `replace_all` renamed a
793 // replacement over it and reopened. The gate still holds a descriptor
794 // on the old, now-unlinked one, and every later barrier would flush a
795 // file nothing reads — a silent loss of durability that no test of the
796 // *contents* could see. Rebind before anything can sync again.
797 self.gate.rebind(&self.backing)?;
798 let len = bytes.len() as u64;
799 self.durability.written.store(len, Ordering::SeqCst);
800 if self.fsync {
801 self.durability.syncs.fetch_add(1, Ordering::SeqCst);
802 self.durability.durable.store(len, Ordering::SeqCst);
803 // Everything staged before this point is durable by construction:
804 // `replace_all` fsynced a complete replacement that contains it.
805 // Publishing the watermark releases any waiter whose sequence the
806 // shortened file will never reach as a byte offset again — which is
807 // exactly why the watermark counts commits and not bytes.
808 let staged = self.durability.written_seq.load(Ordering::SeqCst);
809 self.durability
810 .durable_seq
811 .fetch_max(staged, Ordering::SeqCst);
812 } else {
813 self.durability.durable.store(0, Ordering::SeqCst);
814 }
815 // A rewritten log is a NEW stream. Its schema message was emitted by a
816 // fresh generator, so the appending one has to be fresh too or the next
817 // append encodes against state this stream never wrote.
818 self.generator = IpcDataGenerator::default();
819 self.tracker = DictionaryTracker::new(false);
820 self.batches = batches.len();
821 Ok(())
822 }
823
824 fn write_schema(&mut self) -> Result<()> {
825 let encoded = self.generator.schema_to_bytes_with_dictionary_tracker(
826 &self.schema,
827 &mut self.tracker,
828 &self.options,
829 );
830 let mut framed = Vec::new();
831 write_message(&mut framed, encoded, &self.options).map_err(arrow_err)?;
832 self.commit(&framed)
833 }
834
835 /// Append **one** `RecordBatch` as **one** framed IPC message, and return
836 /// the **commit sequence** that must be made durable before the caller may
837 /// acknowledge it.
838 ///
839 /// This does not sync. The caller releases the writer mutex and then calls
840 /// [`Log::barrier`] with what this returned — which is what lets the next
841 /// pusher encode and write while this one is still on the platter. See
842 /// [`SyncGate`].
843 fn append(&mut self, batch: &RecordBatch) -> Result<u64> {
844 let (dictionaries, message) = self
845 .generator
846 .encode(
847 batch,
848 &mut self.tracker,
849 &self.options,
850 &mut IpcWriteContext::default(),
851 )
852 .map_err(arrow_err)?;
853 let mut framed = Vec::new();
854 // This schema carries no dictionary-encoded column, so this loop is
855 // empty in practice; it is here so that adding one cannot silently drop
856 // the dictionary messages the batch would then depend on.
857 for dictionary in dictionaries {
858 write_message(&mut framed, dictionary, &self.options).map_err(arrow_err)?;
859 }
860 write_message(&mut framed, message, &self.options).map_err(arrow_err)?;
861 let sequence = self.stage(&framed)?;
862 self.batches += 1;
863 Ok(sequence)
864 }
865
866 /// Write `framed` and take a commit sequence for it. **Not durable yet.**
867 ///
868 /// The two counters are bumped *after* the bytes reach the kernel, which is
869 /// what makes [`SyncGate::barrier`]'s read-before-sync watermark honest: a
870 /// sequence that is visible in `written_seq` is a sequence whose bytes an
871 /// `fsync` starting now is guaranteed to cover.
872 fn stage(&self, framed: &[u8]) -> Result<u64> {
873 self.backing.append(framed)?;
874 self.durability
875 .written
876 .fetch_add(framed.len() as u64, Ordering::SeqCst);
877 Ok(self.durability.written_seq.fetch_add(1, Ordering::SeqCst) + 1)
878 }
879
880 /// Make commit `sequence` durable. Callable **without** the writer mutex.
881 fn barrier(&self, sequence: u64) -> Result<()> {
882 self.gate.barrier(&self.durability, sequence, self.fsync)
883 }
884
885 /// Write `framed` and make it durable before returning — the whole
886 /// acknowledgement barrier in one call, for the paths that hold the writer
887 /// mutex throughout anyway (opening a fresh log, sealing a secret) and gain
888 /// nothing from group commit.
889 fn commit(&self, framed: &[u8]) -> Result<()> {
890 let sequence = self.stage(framed)?;
891 self.barrier(sequence)
892 }
893}
894
895// ───────────────────────────── the lock ──────────────────────────────────────
896
897/// The `O_EXCL` lock file that makes "one writer" true across processes.
898///
899/// `flock` would release itself when a crashed process's descriptors close; an
900/// `O_EXCL` file does not, and needs no `unsafe` and no `libc`, which this
901/// crate forbids outright.
902///
903/// # Recovery is at startup, not an operator verb
904///
905/// The trade used to be *"a stale lock after a hard kill, cleared by
906/// [`ArrowLogStore::force_unlock`]"*, and it does not survive the no-OS rule's premise: one
907/// binary as PID 1 in a distroless image, with no shell and nobody to type the
908/// verb. A power cut left every repository the server had open holding a lock
909/// and the appliance booted into a permanent refusal to serve.
910///
911/// So the lock file records **who** holds it and [`acquire`](LockFile::acquire)
912/// reads that back. The owner is `<pid> <start-time>`: the pid alone is not
913/// enough, because pids are recycled and a recycled one reads as "still
914/// running". The start time is field 22 of `/proc/<pid>/stat`, in clock ticks
915/// since boot, which changes on every process — so a pid that has been reused
916/// is detected rather than obeyed. No `unsafe`, no `libc`, no new dependency.
917///
918/// **The rule is: reclaim only what is provably dead.** Alive, or *undecidable*
919/// — no `/proc` on this platform, an unparseable owner line, a lock written by
920/// an older gunnar that recorded no start time — both mean refuse.
921/// [`ArrowLogStore::force_unlock`] stays for exactly those cases. Reclaiming a
922/// lock that is genuinely held lets two writers interleave frames and destroys
923/// the log, which is far worse than an appliance that will not start.
924#[derive(Debug)]
925struct LockFile {
926 path: PathBuf,
927}
928
929/// Who a lock file says holds it.
930#[derive(Debug, Clone, PartialEq, Eq)]
931struct LockOwner {
932 pid: u32,
933 /// `None` for a lock file written before the liveness check, which recorded
934 /// only a pid.
935 /// It is what makes pid reuse undetectable, so it is treated as
936 /// undecidable rather than as "no reuse happened".
937 started: Option<u64>,
938}
939
940impl LockOwner {
941 fn parse(text: &str) -> Option<Self> {
942 let mut fields = text.split_whitespace();
943 let pid = fields.next()?.parse().ok()?;
944 let started = fields.next().and_then(|f| f.parse().ok());
945 Some(LockOwner { pid, started })
946 }
947
948 fn line(&self) -> String {
949 match self.started {
950 Some(started) => format!("{} {}\n", self.pid, started),
951 None => format!("{}\n", self.pid),
952 }
953 }
954
955 fn this_process() -> Self {
956 let pid = std::process::id();
957 LockOwner {
958 pid,
959 started: match proc_start_time(pid) {
960 ProcStartTime::Started(ticks) => Some(ticks),
961 _ => None,
962 },
963 }
964 }
965}
966
967/// What `/proc` says about a pid.
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969enum ProcStartTime {
970 /// The process exists and started at this tick.
971 Started(u64),
972 /// There is no such process. The one answer that permits a reclaim.
973 NoSuchProcess,
974 /// No `/proc`, or a `stat` that did not parse. **Not** the same as dead.
975 Unknown,
976}
977
978/// Read field 22 of `/proc/<pid>/stat` — the process's start time in clock
979/// ticks since boot.
980///
981/// Field 2 is `comm`, which is parenthesised and may itself contain spaces and
982/// close-parens, so the split is anchored on the **last** `)` in the line and
983/// not on whitespace. Everything after it starts at field 3, which puts field
984/// 22 at index 19.
985fn proc_start_time(pid: u32) -> ProcStartTime {
986 // If this platform has no `/proc` at all, a missing `/proc/<pid>/stat` says
987 // nothing about the process, and reading it as "dead" would reclaim every
988 // live lock on that platform.
989 if !Path::new("/proc/self/stat").exists() {
990 return ProcStartTime::Unknown;
991 }
992 let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
993 Ok(stat) => stat,
994 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
995 return ProcStartTime::NoSuchProcess
996 }
997 Err(_) => return ProcStartTime::Unknown,
998 };
999 let Some((_, fields)) = stat.rsplit_once(')') else {
1000 return ProcStartTime::Unknown;
1001 };
1002 // Field 3, the state. A zombie has been killed and is waiting to be
1003 // collected by its parent: it holds no descriptors and can write nothing,
1004 // so it is gone for the purpose of a lock — and its pid cannot be recycled
1005 // while it sits there, so calling it gone is safe as well as true. Without
1006 // this a crashed appliance whose reaper is slow still refuses to start.
1007 if fields.split_whitespace().next() == Some("Z") {
1008 return ProcStartTime::NoSuchProcess;
1009 }
1010 match fields
1011 .split_whitespace()
1012 .nth(19)
1013 .and_then(|ticks| ticks.parse().ok())
1014 {
1015 Some(ticks) => ProcStartTime::Started(ticks),
1016 None => ProcStartTime::Unknown,
1017 }
1018}
1019
1020/// Is the lock file's owner still running?
1021///
1022/// `false` only when the answer is **certain**. See [`LockFile`].
1023fn owner_is_gone(owner: &LockOwner) -> bool {
1024 match (proc_start_time(owner.pid), owner.started) {
1025 (ProcStartTime::NoSuchProcess, _) => true,
1026 // The pid is live but is not the process that took the lock: it was
1027 // recycled. This is the case a bare `/proc/<pid>` existence check gets
1028 // wrong, and it gets it wrong in the direction of never recovering.
1029 (ProcStartTime::Started(now), Some(then)) => now != then,
1030 // A live pid whose identity cannot be confirmed, or no `/proc`.
1031 (ProcStartTime::Started(_), None) | (ProcStartTime::Unknown, _) => false,
1032 }
1033}
1034
1035impl LockFile {
1036 fn acquire(path: PathBuf) -> Result<Self> {
1037 match Self::create(&path) {
1038 Ok(lock) => Ok(lock),
1039 Err(Error::StoreLocked { .. }) => Self::reclaim_if_stale(path),
1040 Err(err) => Err(err),
1041 }
1042 }
1043
1044 fn create(path: &Path) -> Result<Self> {
1045 match OpenOptions::new().write(true).create_new(true).open(path) {
1046 Ok(mut file) => {
1047 let owner = LockOwner::this_process();
1048 // Not best-effort any more: an owner line that failed to reach
1049 // the disk makes every later recovery undecidable, and a
1050 // silent failure here would only be discovered after a crash.
1051 if let Err(err) = write!(file, "{}", owner.line()).and_then(|()| file.sync_all()) {
1052 tracing::warn!(
1053 path = %path.display(),
1054 %err,
1055 "the store lock could not record its owner; a crash will need \
1056 force_unlock"
1057 );
1058 }
1059 Ok(LockFile {
1060 path: path.to_path_buf(),
1061 })
1062 }
1063 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
1064 Err(Error::StoreLocked {
1065 path: path.to_path_buf(),
1066 })
1067 }
1068 Err(err) => Err(Error::Io(err)),
1069 }
1070 }
1071
1072 fn reclaim_if_stale(path: PathBuf) -> Result<Self> {
1073 let owner = std::fs::read_to_string(&path)
1074 .ok()
1075 .and_then(|text| LockOwner::parse(&text));
1076 let Some(owner) = owner else {
1077 tracing::warn!(
1078 path = %path.display(),
1079 "the store lock names no owner this gunnar can read, so it cannot be \
1080 shown to be stale; refusing"
1081 );
1082 return Err(Error::StoreLocked { path });
1083 };
1084 if !owner_is_gone(&owner) {
1085 return Err(Error::StoreLocked { path });
1086 }
1087 tracing::warn!(
1088 path = %path.display(),
1089 pid = owner.pid,
1090 "the store lock was left by a process that is gone; reclaiming it"
1091 );
1092 match std::fs::remove_file(&path) {
1093 Ok(()) => {}
1094 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1095 Err(err) => return Err(Error::Io(err)),
1096 }
1097 // Deliberately one attempt: if a second starter reclaimed the same
1098 // stale lock a microsecond earlier, it now holds a live lock and this
1099 // one must refuse. Retrying would be a loop that ends in two writers.
1100 Self::create(&path)
1101 }
1102}
1103
1104impl Drop for LockFile {
1105 fn drop(&mut self) {
1106 if let Err(err) = std::fs::remove_file(&self.path) {
1107 tracing::warn!(path = %self.path.display(), %err, "could not release the store lock");
1108 }
1109 }
1110}
1111
1112// ───────────────────────────── the store ─────────────────────────────────────
1113
1114/// The bytes of a store's secret log when there is no filesystem at all.
1115///
1116/// Hand the same handle back to [`ArrowLogStore::open_memory`] to simulate a
1117/// restart: the store is rebuilt by replaying these bytes, exactly as the file
1118/// backing replays a file.
1119#[derive(Clone, Debug, Default)]
1120pub struct MemoryLog {
1121 secrets: Arc<Mutex<Vec<u8>>>,
1122}
1123
1124struct Inner {
1125 hash_kind: gix_hash::Kind,
1126 actor: String,
1127 policy: CompactionPolicy,
1128 secrets_log: Mutex<Log>,
1129 secrets_state: RwLock<BTreeMap<String, Vec<u8>>>,
1130 _lock: Option<LockFile>,
1131}
1132
1133/// One repository's secrets, as an Arrow IPC log.
1134///
1135/// Cheap to clone: every clone is the same store, and the exclusive lock lives
1136/// until the last handle is dropped.
1137#[derive(Clone)]
1138pub struct ArrowLogStore {
1139 inner: Arc<Inner>,
1140}
1141
1142impl std::fmt::Debug for ArrowLogStore {
1143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144 f.debug_struct("ArrowLogStore")
1145 .field("hash_kind", &self.inner.hash_kind)
1146 .field("lock", &self.inner._lock)
1147 .finish()
1148 }
1149}
1150
1151impl ArrowLogStore {
1152 /// Open the logs in `dir`, creating them if absent, and take the exclusive lock.
1153 ///
1154 /// Fails with [`Error::StoreLocked`] if another writer already holds it, and
1155 /// with [`Error::HashKindMismatch`] if the log on disk belongs to a
1156 /// repository of the other hash kind.
1157 pub fn open(dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
1158 Self::open_inner(dir.as_ref(), hash_kind, true, CompactionPolicy::default())
1159 }
1160
1161 /// [`ArrowLogStore::open`] with a chosen compaction policy.
1162 ///
1163 /// The operator lever for threshold-based secret-log compaction. Replaced or
1164 /// deleted secrets are compacted immediately regardless of this policy.
1165 pub fn open_with_policy(
1166 dir: impl AsRef<Path>,
1167 hash_kind: gix_hash::Kind,
1168 policy: CompactionPolicy,
1169 ) -> Result<Self> {
1170 Self::open_inner(dir.as_ref(), hash_kind, true, policy)
1171 }
1172
1173 fn open_inner(
1174 dir: &Path,
1175 hash_kind: gix_hash::Kind,
1176 fsync: bool,
1177 policy: CompactionPolicy,
1178 ) -> Result<Self> {
1179 std::fs::create_dir_all(dir)?;
1180 // The lock comes first: nothing may read a log it is not entitled to
1181 // append to, because the replayed state is what CAS is checked against.
1182 let lock = LockFile::acquire(dir.join(LOCK_FILE))?;
1183 let open_file = |name: &str| -> Result<Backing> {
1184 let path = dir.join(name);
1185 let file = OpenOptions::new()
1186 .read(true)
1187 .write(true)
1188 .create(true)
1189 .truncate(false)
1190 .open(&path)?;
1191 Ok(Backing::File { file, path })
1192 };
1193 Self::assemble(
1194 hash_kind,
1195 open_file(SECRETS_LOG)?,
1196 Some(lock),
1197 fsync,
1198 policy,
1199 )
1200 }
1201
1202 /// A store with no file behind it. This is the no-OS shape, and what the
1203 /// no-filesystem conformance run uses.
1204 ///
1205 /// The framing, encoding and replay code is the same as the file backing's;
1206 /// what this shape cannot speak to is durability, since there is nothing for
1207 /// an fsync to mean. The durability claim is made by the file tests only.
1208 pub fn in_memory(hash_kind: gix_hash::Kind) -> Result<(Self, MemoryLog)> {
1209 let log = MemoryLog::default();
1210 let store = Self::open_memory(&log, hash_kind)?;
1211 Ok((store, log))
1212 }
1213
1214 /// Rebuild a store by replaying `log`, the in-memory restart.
1215 pub fn open_memory(log: &MemoryLog, hash_kind: gix_hash::Kind) -> Result<Self> {
1216 Self::open_memory_with_policy(log, hash_kind, CompactionPolicy::default())
1217 }
1218
1219 /// [`ArrowLogStore::open_memory`] with a chosen compaction policy.
1220 pub fn open_memory_with_policy(
1221 log: &MemoryLog,
1222 hash_kind: gix_hash::Kind,
1223 policy: CompactionPolicy,
1224 ) -> Result<Self> {
1225 Self::assemble(
1226 hash_kind,
1227 Backing::Memory {
1228 bytes: log.secrets.clone(),
1229 },
1230 None,
1231 true,
1232 policy,
1233 )
1234 }
1235
1236 fn assemble(
1237 hash_kind: gix_hash::Kind,
1238 secrets_backing: Backing,
1239 lock: Option<LockFile>,
1240 fsync: bool,
1241 policy: CompactionPolicy,
1242 ) -> Result<Self> {
1243 let (secrets_log, secret_batches) = Log::open(secrets_backing, secret_schema(), fsync)?;
1244 let secrets_batches_on_open = secrets_log.batches;
1245 let store = ArrowLogStore {
1246 inner: Arc::new(Inner {
1247 hash_kind,
1248 actor: DEFAULT_ACTOR.to_owned(),
1249 policy,
1250 secrets_log: Mutex::new(secrets_log),
1251 secrets_state: RwLock::new(replay_secrets(&secret_batches)?),
1252 _lock: lock,
1253 }),
1254 };
1255 if policy.should_compact(secrets_batches_on_open) {
1256 if let Err(err) = store.compact_secrets_now() {
1257 tracing::warn!(%err, "could not fold an oversized secret log at open");
1258 }
1259 }
1260 Ok(store)
1261 }
1262
1263 /// Remove a lock file left behind by a process that was killed.
1264 ///
1265 /// Only ever correct when no gunnar is running against `dir`. The lock is
1266 /// what keeps Arrow stream appends single-writer; breaking one that is
1267 /// genuinely held lets two writers interleave frames and destroy the log.
1268 ///
1269 /// # This is no longer the normal recovery path
1270 ///
1271 /// [`open`](ArrowLogStore::open) reclaims a lock whose owner is **provably**
1272 /// gone, so an appliance with no shell restarts after a power cut on its
1273 /// own. What is left for this verb is the undecidable cases, where breaking
1274 /// the lock is a judgement no program should make unasked: a platform with
1275 /// no `/proc`, a lock file written by a gunnar older than the liveness
1276 /// check, or one
1277 /// whose owner line never reached the disk.
1278 pub fn force_unlock(dir: impl AsRef<Path>) -> Result<()> {
1279 match std::fs::remove_file(dir.as_ref().join(LOCK_FILE)) {
1280 Ok(()) => Ok(()),
1281 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1282 Err(err) => Err(Error::Io(err)),
1283 }
1284 }
1285
1286 /// The hash kind this repository uses.
1287 pub fn hash_kind(&self) -> gix_hash::Kind {
1288 self.inner.hash_kind
1289 }
1290
1291 /// The compaction policy in force.
1292 pub fn compaction_policy(&self) -> CompactionPolicy {
1293 self.inner.policy
1294 }
1295
1296 /// Fold the secret log: current values only, no history, no archive.
1297 fn compact_secrets_now(&self) -> Result<()> {
1298 let mut log = self
1299 .inner
1300 .secrets_log
1301 .lock()
1302 .map_err(|_| poisoned("secret log"))?;
1303 compact_secrets(&self.inner, &mut log)
1304 }
1305
1306 /// A handle implementing [`SecretStore`].
1307 pub fn secrets(&self) -> ArrowLogSecretStore {
1308 ArrowLogSecretStore {
1309 inner: self.inner.clone(),
1310 }
1311 }
1312}
1313
1314// ───────────────────────────── shared helpers ────────────────────────────────
1315
1316fn now_unix_ms() -> i64 {
1317 SystemTime::now()
1318 .duration_since(UNIX_EPOCH)
1319 .map(|d| d.as_millis() as i64)
1320 .unwrap_or(0)
1321}
1322
1323/// An Arrow fault, as a [`gunnar_store::Error`].
1324///
1325/// `gunnar-store` used to carry `impl From<arrow_schema::ArrowError> for Error`
1326/// so a `?` did this implicitly. It does not any more: that `From` was the last
1327/// Arrow in the contract crate, and keeping one impl there would have kept the
1328/// dependency edge this module's move to `gunnar-core` exists to cut (see
1329/// `gunnar_store`'s crate header). One function, called at every site the `?`
1330/// used to cover, rather than a second opinion about what an Arrow fault is.
1331pub(crate) fn arrow_err(err: arrow_schema::ArrowError) -> Error {
1332 Error::backend(format!("arrow: {err}"))
1333}
1334
1335/// The one door between bytes on disk and arrow-ipc's `StreamReader`: znippy's
1336/// IPC guard. arrow-ipc trusts the lengths a message declares, so a hostile
1337/// file does not give an `Err` there, it panics or aborts the process (a 1 TiB
1338/// `bodyLength` is allocated before a byte of it is read). The guard checks
1339/// every length arrow is about to trust against the bytes that are there; it
1340/// reads the metadata only and copies nothing. `stream` is a framed prefix
1341/// ([`valid_prefix_len`]), so a torn tail never reaches it: what it refuses is a
1342/// well-framed message that lies, which is an error and not a truncation.
1343pub(crate) fn ipc_guard(stream: &[u8]) -> Result<()> {
1344 znippy_common::ipc_guard::check_stream(stream)
1345 .map_err(|e| Error::backend(format!("arrow: refused by the IPC guard: {e}")))
1346}
1347
1348pub(crate) fn column<'a, T: 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
1349 batch
1350 .column_by_name(name)
1351 .and_then(|c| c.as_any().downcast_ref::<T>())
1352 .ok_or_else(|| {
1353 Error::backend(format!(
1354 "the Arrow log has no usable `{name}` column; the file is not a gunnar log"
1355 ))
1356 })
1357}
1358
1359// ───────────────────────────── compaction ────────────────────────────────────
1360
1361/// The same fold for secrets, with **no archive and no window**.
1362///
1363/// Retaining superseded secret values would make a rotated host key immortal,
1364/// which is the opposite of what rotation is for.
1365fn compact_secrets(inner: &Inner, log: &mut Log) -> Result<()> {
1366 let snapshot = {
1367 let state = inner.secrets_state.read().map_err(|_| poisoned("secret"))?;
1368 let mut keys = StringBuilder::new();
1369 let mut values = BinaryBuilder::new();
1370 let mut actors = StringBuilder::new();
1371 let mut ats = Int64Builder::new();
1372 let at = now_unix_ms();
1373 for (key, value) in state.iter() {
1374 keys.append_value(key);
1375 values.append_value(value);
1376 actors.append_value(COMPACTION_ACTOR);
1377 ats.append_value(at);
1378 }
1379 RecordBatch::try_new(
1380 log.schema.clone(),
1381 vec![
1382 Arc::new(keys.finish()),
1383 Arc::new(values.finish()),
1384 Arc::new(actors.finish()),
1385 Arc::new(ats.finish()),
1386 ],
1387 )
1388 .map_err(arrow_err)?
1389 };
1390 log.rewrite(&[snapshot])
1391}
1392
1393// ───────────────────────────── secrets ───────────────────────────────────────
1394
1395/// Secrets in an Arrow IPC log: the SSH host key and the admin bootstrap
1396/// material, on a box with no home directory.
1397#[derive(Clone)]
1398pub struct ArrowLogSecretStore {
1399 inner: Arc<Inner>,
1400}
1401
1402impl std::fmt::Debug for ArrowLogSecretStore {
1403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1404 f.write_str("ArrowLogSecretStore")
1405 }
1406}
1407
1408fn build_secret_batch(
1409 schema: &SchemaRef,
1410 actor: &str,
1411 at: i64,
1412 key: &str,
1413 value: Option<&[u8]>,
1414) -> Result<RecordBatch> {
1415 let mut keys = StringBuilder::new();
1416 let mut values = BinaryBuilder::new();
1417 let mut actors = StringBuilder::new();
1418 let mut ats = Int64Builder::new();
1419 keys.append_value(key);
1420 match value {
1421 Some(bytes) => values.append_value(bytes),
1422 None => values.append_null(),
1423 }
1424 actors.append_value(actor);
1425 ats.append_value(at);
1426 RecordBatch::try_new(
1427 schema.clone(),
1428 vec![
1429 Arc::new(keys.finish()),
1430 Arc::new(values.finish()),
1431 Arc::new(actors.finish()),
1432 Arc::new(ats.finish()),
1433 ],
1434 )
1435 .map_err(arrow_err)
1436}
1437
1438fn replay_secrets(batches: &[RecordBatch]) -> Result<BTreeMap<String, Vec<u8>>> {
1439 let mut state = BTreeMap::new();
1440 for batch in batches {
1441 let keys = column::<StringArray>(batch, COL_KEY)?;
1442 let values = column::<BinaryArray>(batch, COL_VALUE)?;
1443 for row in 0..batch.num_rows() {
1444 let key = keys.value(row).to_owned();
1445 if values.is_null(row) {
1446 state.remove(&key);
1447 } else {
1448 state.insert(key, values.value(row).to_vec());
1449 }
1450 }
1451 }
1452 Ok(state)
1453}
1454
1455impl ArrowLogSecretStore {
1456 fn write(&self, key: &str, value: Option<&[u8]>) -> Result<()> {
1457 let mut log = self
1458 .inner
1459 .secrets_log
1460 .lock()
1461 .map_err(|_| poisoned("secret log"))?;
1462 self.write_locked(&mut log, key, value)
1463 }
1464
1465 fn write_locked(&self, log: &mut Log, key: &str, value: Option<&[u8]>) -> Result<()> {
1466 let batch = build_secret_batch(&log.schema, &self.inner.actor, now_unix_ms(), key, value)?;
1467 // Append AND barrier, both under the writer mutex — deliberately *not*
1468 // the group commit the ref log does.
1469 //
1470 // A secret is written when a host key is minted or rotated, which is
1471 // rare and never concurrent, so there is nothing to group and releasing
1472 // the lock first would buy nothing while widening the window in which a
1473 // rotated secret is still readable. `append` only stages; forgetting
1474 // this line is a secret that is acknowledged and not on the platter,
1475 // which is what `a_secret_is_durable_before_the_write_returns` exists to
1476 // catch.
1477 let sequence = log.append(&batch)?;
1478 log.barrier(sequence)?;
1479 let superseded = {
1480 let mut state = self
1481 .inner
1482 .secrets_state
1483 .write()
1484 .map_err(|_| poisoned("secret"))?;
1485 match value {
1486 Some(bytes) => state.insert(key.to_owned(), bytes.to_vec()).is_some(),
1487 None => state.remove(key).is_some(),
1488 }
1489 };
1490
1491 // A superseded secret is compacted out **now**, not when a batch count
1492 // says so.
1493 //
1494 // This module's own header says "a rotated SSH host key must actually
1495 // stop existing". It did not: the log is append-only and the only
1496 // trigger was `should_compact(log.batches)`, so the previous value sat
1497 // in `secrets.arrow` for up to `after_batches` further writes — on a
1498 // store that sees a handful of writes in its lifetime, that is
1499 // *forever*. Found by `gunnar-core`'s sealing-migration test, which sealed a
1500 // legacy plaintext host key and then found the plaintext still in the
1501 // file.
1502 //
1503 // Refs deliberately keep their history; secrets are the one log where
1504 // the old value is the defect. The fold is a rewrite of a map with a
1505 // handful of entries, so doing it on every replacement costs nothing
1506 // that a threshold was protecting.
1507 if superseded {
1508 compact_secrets(&self.inner, log).map_err(|err| {
1509 Error::backend(format!(
1510 "the new value for the secret {key} was stored, but the PREVIOUS value \
1511 could not be compacted out of the log and is still on disk: {err}"
1512 ))
1513 })?;
1514 } else if self.inner.policy.should_compact(log.batches) {
1515 if let Err(err) = compact_secrets(&self.inner, log) {
1516 tracing::warn!(%err, "could not compact the secret log; it keeps growing");
1517 }
1518 }
1519 Ok(())
1520 }
1521
1522 fn current(&self, key: &str) -> Result<Option<Vec<u8>>> {
1523 Ok(self
1524 .inner
1525 .secrets_state
1526 .read()
1527 .map_err(|_| poisoned("secret"))?
1528 .get(key)
1529 .cloned())
1530 }
1531}
1532
1533impl SecretStore for ArrowLogSecretStore {
1534 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
1535 self.current(key)
1536 }
1537
1538 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
1539 self.write(key, Some(value))
1540 }
1541
1542 fn delete(&self, key: &str) -> Result<()> {
1543 // A tombstone for a key that is not there would grow the log for
1544 // nothing, and "delete what is absent" is explicitly not an error.
1545 if self.current(key)?.is_none() {
1546 return Ok(());
1547 }
1548 self.write(key, None)
1549 }
1550
1551 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
1552 // The writer mutex is taken *before* the existence check, so the loser of
1553 // a race blocks here and then finds the winner's value. `mk` runs once.
1554 let mut log = self
1555 .inner
1556 .secrets_log
1557 .lock()
1558 .map_err(|_| poisoned("secret log"))?;
1559 if let Some(existing) = self.current(key)? {
1560 return Ok(existing);
1561 }
1562 let value = mk()?;
1563 self.write_locked(&mut log, key, Some(&value))?;
1564 Ok(value)
1565 }
1566}
1567
1568// ───────────────────────────── tests ─────────────────────────────────────────
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573 use gunnar_store::SecretStoreExt;
1574
1575 const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
1576
1577 fn dir() -> tempfile::TempDir {
1578 tempfile::tempdir().expect("a temporary directory")
1579 }
1580
1581 #[test]
1582 fn the_in_memory_secret_log_replays_across_a_rebuild() {
1583 let (store, log) = ArrowLogStore::in_memory(KIND).expect("in-memory store");
1584 store.secrets().put("k", b"v").expect("put");
1585 drop(store);
1586
1587 let rebuilt = ArrowLogStore::open_memory(&log, KIND).expect("replay");
1588 assert_eq!(
1589 rebuilt.secrets().get("k").expect("get"),
1590 Some(b"v".to_vec())
1591 );
1592 }
1593
1594 #[test]
1595 fn a_secret_delete_is_a_tombstone_that_survives_a_reopen() {
1596 let dir = dir();
1597 {
1598 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1599 store.secrets().put("k", b"v1").expect("put");
1600 store.secrets().delete("k").expect("delete");
1601 }
1602 let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1603 assert!(store.secrets().get("k").expect("get").is_none());
1604 store.secrets().put("k", b"v2").expect("put again");
1605 assert_eq!(store.secrets().get("k").expect("get"), Some(b"v2".to_vec()));
1606 }
1607
1608 #[test]
1609 fn a_rotated_secret_leaves_no_trace_of_the_previous_value_on_disk() {
1610 let dir = dir();
1611 let old = b"OLD-SECRET-VALUE-that-must-not-survive-rotation";
1612 let new = b"NEW-SECRET-VALUE-after-the-rotation";
1613
1614 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1615 store.secrets().put("ssh/host_ed25519", old).expect("put");
1616
1617 let contains = |needle: &[u8]| {
1618 let bytes = std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log");
1619 assert!(!bytes.is_empty(), "the secret log is empty");
1620 bytes.windows(needle.len()).any(|w| w == needle)
1621 };
1622 assert!(contains(old));
1623
1624 store
1625 .secrets()
1626 .put("ssh/host_ed25519", new)
1627 .expect("rotate");
1628 assert!(!contains(old), "the previous secret survived rotation");
1629 assert!(contains(new), "the new value is not in the log");
1630 assert_eq!(
1631 store.secrets().get("ssh/host_ed25519").expect("get"),
1632 Some(new.to_vec())
1633 );
1634
1635 store.secrets().delete("ssh/host_ed25519").expect("delete");
1636 assert!(
1637 !contains(new),
1638 "a deleted secret's value is still in the log"
1639 );
1640 }
1641
1642 #[test]
1643 fn compacting_secrets_does_not_retain_superseded_values() {
1644 let dir = dir();
1645 let store = ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1646 .expect("open");
1647 for generation in 0..12 {
1648 store
1649 .secrets()
1650 .put(
1651 "host-key",
1652 format!("secret-generation-{generation:02}").as_bytes(),
1653 )
1654 .expect("put");
1655 }
1656
1657 let haystack = String::from_utf8_lossy(
1658 &std::fs::read(dir.path().join(SECRETS_LOG)).expect("read the secret log"),
1659 )
1660 .into_owned();
1661 assert!(!haystack.contains("secret-generation-00"));
1662 assert!(haystack.contains("secret-generation-11"));
1663 assert!(!dir.path().join(REFS_ARCHIVE_DIR).exists());
1664 drop(store);
1665
1666 let reopened =
1667 ArrowLogStore::open_with_policy(dir.path(), KIND, CompactionPolicy::new(4, 0))
1668 .expect("reopen");
1669 assert_eq!(
1670 reopened.secrets().get("host-key").expect("get"),
1671 Some(b"secret-generation-11".to_vec())
1672 );
1673 }
1674
1675 #[test]
1676 fn get_or_create_does_not_remint_across_a_reopen() {
1677 let dir = dir();
1678 let first = {
1679 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1680 store
1681 .secrets()
1682 .get_or_create_with(gunnar_store::SSH_HOST_KEY, || Ok(b"host-key-1".to_vec()))
1683 .expect("mint")
1684 };
1685 let store = ArrowLogStore::open(dir.path(), KIND).expect("reopen");
1686 let second = store
1687 .secrets()
1688 .get_or_create_with(gunnar_store::SSH_HOST_KEY, || {
1689 panic!("the host key was re-minted after a restart")
1690 })
1691 .expect("read back");
1692 assert_eq!(first, second);
1693 }
1694
1695 #[test]
1696 fn a_secret_is_durable_before_the_write_returns() {
1697 let dir = dir();
1698 let store = ArrowLogStore::open(dir.path(), KIND).expect("open");
1699 store
1700 .secrets()
1701 .put(gunnar_store::SSH_HOST_KEY, b"a-host-key")
1702 .expect("write a secret");
1703
1704 let on_disk = std::fs::metadata(dir.path().join(SECRETS_LOG))
1705 .expect("stat")
1706 .len();
1707 let durability = &store
1708 .inner
1709 .secrets_log
1710 .lock()
1711 .expect("the writer")
1712 .durability;
1713 let durable = durability.durable.load(Ordering::SeqCst);
1714 let written = durability.written.load(Ordering::SeqCst);
1715 assert_eq!(written, on_disk);
1716 assert_eq!(durable, written);
1717 assert!(durability.syncs.load(Ordering::SeqCst) >= 2);
1718 }
1719}