#[path = "txlog_tee.rs"]
pub mod tee;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
use serde::{Deserialize, Serialize};
const MAGIC: [u8; 4] = *b"GTX1";
const HEADER_LEN: usize = 32;
const TRAILER_LEN: usize = 4;
const SEG_PREFIX: &str = "seg-";
const SEG_EXT: &str = "log";
const IDX_EXT: &str = "idx";
pub const DEFAULT_SEGMENT_BYTES: u64 = 1 << 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u16)]
pub enum Kind {
Pack = 1,
Refs = 2,
ControlBlob = 3,
ControlDoc = 4,
Secret = 5,
LfsPut = 6,
LfsDelete = 7,
RepoStore = 8,
Kek = 9,
Append = 10,
File = 11,
SnapshotAt = 12,
Redacted = 13,
Erase = 14,
Activity = 15,
}
impl Kind {
pub fn from_u16(v: u16) -> Option<Self> {
Some(match v {
1 => Kind::Pack,
2 => Kind::Refs,
3 => Kind::ControlBlob,
4 => Kind::ControlDoc,
5 => Kind::Secret,
6 => Kind::LfsPut,
7 => Kind::LfsDelete,
8 => Kind::RepoStore,
9 => Kind::Kek,
10 => Kind::Append,
11 => Kind::File,
12 => Kind::SnapshotAt,
13 => Kind::Redacted,
14 => Kind::Erase,
15 => Kind::Activity,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefEdit {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old: Option<Option<String>>,
pub new: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peeled: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "k", rename_all = "snake_case")]
pub enum Meta {
Pack {
repo: String,
hash: String,
},
Refs {
repo: String,
atomic: bool,
edits: Vec<RefEdit>,
},
ControlBlob {
id: String,
},
ControlDoc {
name: String,
expected: Option<String>,
new: String,
},
Secret {
key: String,
deleted: bool,
},
Lfs {
repo: String,
oid: String,
size: u64,
},
RepoStore {
repo: String,
op: String,
hash: String,
},
Kek {},
Append {
log: String,
},
File {
path: String,
deleted: bool,
},
Activity {},
SnapshotAt {
at: u64,
},
Erase {
account: String,
repos: Vec<String>,
reason: String,
},
}
impl Meta {
pub fn repo(&self) -> Option<&str> {
match self {
Meta::Pack { repo, .. }
| Meta::Refs { repo, .. }
| Meta::Lfs { repo, .. }
| Meta::RepoStore { repo, .. } => Some(repo),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Erased {
pub frames: u64,
pub bytes: u64,
}
impl Meta {
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("Meta serializes")
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
serde_json::from_slice(bytes).map_err(|e| Error::BadMeta(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub seq: u64,
pub kind: u16,
pub meta: Vec<u8>,
pub blob: Vec<u8>,
}
impl Entry {
pub fn kind(&self) -> Option<Kind> {
Kind::from_u16(self.kind)
}
pub fn meta(&self) -> Result<Meta> {
Meta::from_bytes(&self.meta)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Located {
pub seq: u64,
pub kind: u16,
pub meta: Vec<u8>,
pub path: PathBuf,
pub blob_offset: u64,
pub blob_len: u64,
pub payload_crc: Option<u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("txlog: {0}")]
Io(#[from] io::Error),
#[error("txlog: bad meta: {0}")]
BadMeta(String),
#[error("txlog: seq {seq} not retained (first retained is {first})")]
NotRetained {
seq: u64,
first: u64,
},
#[error("txlog: seq {seq} is beyond head {head}")]
BeyondHead {
seq: u64,
head: u64,
},
#[error("txlog: corrupt frame at {path}:{offset}: {what}")]
Corrupt {
path: PathBuf,
offset: u64,
what: &'static str,
},
#[error("txlog: released — the files under {0} are given up while the data set is grown; writes resume after the remount")]
Released(PathBuf),
#[error("txlog: append out of order: got {got}, expected {expected}")]
OutOfOrder {
got: u64,
expected: u64,
},
#[error("txlog: this box is a twin (or restoring); it originates no transactions")]
Follower,
#[error("txlog: unexpected file {0}")]
Unexpected(PathBuf),
#[error("txlog: poisoned under {dir} — {cause}; every write refuses until the log is reopened from its last durable frame")]
Poisoned {
dir: PathBuf,
cause: String,
},
}
pub type Result<T> = std::result::Result<T, Error>;
const CRC32C_POLY: u32 = 0x82F6_3B78;
const fn crc32c_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut c = i as u32;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 {
CRC32C_POLY ^ (c >> 1)
} else {
c >> 1
};
k += 1;
}
table[i] = c;
i += 1;
}
table
}
static CRC32C_TABLE: [u32; 256] = crc32c_table();
#[derive(Debug, Clone, Copy)]
pub struct Crc32c(u32);
impl Default for Crc32c {
fn default() -> Self {
Self::new()
}
}
impl Crc32c {
pub const fn new() -> Self {
Crc32c(0xFFFF_FFFF)
}
pub fn update(&mut self, bytes: &[u8]) {
let mut c = self.0;
for &b in bytes {
c = CRC32C_TABLE[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
}
self.0 = c;
}
pub fn finish(self) -> u32 {
self.0 ^ 0xFFFF_FFFF
}
pub fn state(&self) -> u32 {
self.0
}
pub const fn resume(state: u32) -> Self {
Crc32c(state)
}
}
pub fn crc32c(bytes: &[u8]) -> u32 {
let mut c = Crc32c::new();
c.update(bytes);
c.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Header {
seq: u64,
kind: u16,
meta_len: u32,
blob_len: u64,
}
impl Header {
fn encode(&self) -> [u8; HEADER_LEN] {
let mut h = [0u8; HEADER_LEN];
h[0..4].copy_from_slice(&MAGIC);
h[4..12].copy_from_slice(&self.seq.to_le_bytes());
h[12..14].copy_from_slice(&self.kind.to_le_bytes());
h[14..16].copy_from_slice(&0u16.to_le_bytes());
h[16..20].copy_from_slice(&self.meta_len.to_le_bytes());
h[20..28].copy_from_slice(&self.blob_len.to_le_bytes());
let crc = crc32c(&h[0..28]);
h[28..32].copy_from_slice(&crc.to_le_bytes());
h
}
fn decode(h: &[u8]) -> Option<Self> {
if h.len() < HEADER_LEN || h[0..4] != MAGIC {
return None;
}
let crc = u32::from_le_bytes(h[28..32].try_into().ok()?);
if crc32c(&h[0..28]) != crc {
return None;
}
Some(Header {
seq: u64::from_le_bytes(h[4..12].try_into().ok()?),
kind: u16::from_le_bytes(h[12..14].try_into().ok()?),
meta_len: u32::from_le_bytes(h[16..20].try_into().ok()?),
blob_len: u64::from_le_bytes(h[20..28].try_into().ok()?),
})
}
fn frame_len(&self) -> u64 {
HEADER_LEN as u64 + self.meta_len as u64 + self.blob_len + TRAILER_LEN as u64
}
}
fn seg_path(dir: &Path, first: u64) -> PathBuf {
dir.join(format!("{SEG_PREFIX}{first:020}.{SEG_EXT}"))
}
fn idx_path(dir: &Path, first: u64) -> PathBuf {
dir.join(format!("{SEG_PREFIX}{first:020}.{IDX_EXT}"))
}
fn parse_seg_name(name: &str) -> Option<(u64, bool)> {
let rest = name.strip_prefix(SEG_PREFIX)?;
let (num, ext) = rest.split_once('.')?;
let first: u64 = num.parse().ok()?;
match ext {
SEG_EXT => Some((first, true)),
IDX_EXT => Some((first, false)),
_ => None,
}
}
fn sync_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
fn zero_range(file: &mut File, at: u64, len: u64) -> io::Result<()> {
if len == 0 {
return Ok(());
}
#[cfg(target_os = "linux")]
{
use rustix::fs::{fallocate, FallocateFlags};
if fallocate(
&*file,
FallocateFlags::PUNCH_HOLE | FallocateFlags::KEEP_SIZE,
at,
len,
)
.is_ok()
{
return Ok(());
}
}
let zeros = vec![0u8; (1usize << 20).min(len as usize)];
file.seek(SeekFrom::Start(at))?;
let mut left = len;
while left > 0 {
let n = left.min(zeros.len() as u64) as usize;
file.write_all(&zeros[..n])?;
left -= n as u64;
}
Ok(())
}
#[derive(Debug)]
struct Segment {
first: u64,
offsets: Vec<u64>,
len: u64,
}
impl Segment {
fn last_seq(&self) -> Option<u64> {
if self.offsets.is_empty() {
None
} else {
Some(self.first + self.offsets.len() as u64 - 1)
}
}
}
fn scan_frames(
path: &Path,
file: &mut File,
first: u64,
offsets: &mut Vec<u64>,
from: u64,
) -> Result<(u64, bool)> {
let total = file.metadata()?.len();
let mut pos = from;
let mut hdr = [0u8; HEADER_LEN];
let mut buf = Vec::new();
while pos < total {
if total - pos < HEADER_LEN as u64 {
return Ok((pos, true));
}
file.seek(SeekFrom::Start(pos))?;
file.read_exact(&mut hdr)?;
let Some(h) = Header::decode(&hdr) else {
return Ok((pos, true));
};
let expect = first + offsets.len() as u64;
if h.seq != expect {
return Ok((pos, true));
}
let payload = h.meta_len as u64 + h.blob_len;
if total - pos < h.frame_len() {
return Ok((pos, true));
}
if h.kind == Kind::Redacted as u16 {
offsets.push(pos);
pos += h.frame_len();
continue;
}
let mut crc = Crc32c::new();
let mut remaining = payload;
buf.resize(1 << 20, 0);
while remaining > 0 {
let n = remaining.min(buf.len() as u64) as usize;
file.read_exact(&mut buf[..n])?;
crc.update(&buf[..n]);
remaining -= n as u64;
}
let mut tr = [0u8; TRAILER_LEN];
file.read_exact(&mut tr)?;
if u32::from_le_bytes(tr) != crc.finish() {
return Ok((pos, true));
}
let _ = path;
offsets.push(pos);
pos += h.frame_len();
}
Ok((pos, false))
}
fn open_segment(dir: &Path, first: u64, repair: bool) -> Result<Segment> {
let path = seg_path(dir, first);
let mut file = OpenOptions::new().read(true).write(repair).open(&path)?;
let total = file.metadata()?.len();
let mut offsets: Vec<u64> = Vec::new();
let ipath = idx_path(dir, first);
if let Ok(bytes) = fs::read(&ipath) {
let n = bytes.len() / 8;
offsets.reserve(n);
for i in 0..n {
let off = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().expect("8 bytes"));
offsets.push(off);
}
let monotone = offsets.windows(2).all(|w| w[0] < w[1]);
if !monotone || offsets.first().is_some_and(|&o| o != 0) {
offsets.clear();
}
while let Some(&last) = offsets.last() {
if last + HEADER_LEN as u64 > total {
offsets.pop();
continue;
}
file.seek(SeekFrom::Start(last))?;
let mut hdr = [0u8; HEADER_LEN];
file.read_exact(&mut hdr)?;
match Header::decode(&hdr) {
Some(h)
if h.seq == first + offsets.len() as u64 - 1
&& last + h.frame_len() <= total =>
{
break;
}
_ => {
offsets.pop();
}
}
}
}
let trusted = offsets.len();
let resume = offsets.pop().unwrap_or(0);
let (valid_len, torn) = scan_frames(&path, &mut file, first, &mut offsets, resume)?;
if torn && repair {
tracing::warn!(
segment = %path.display(),
kept = valid_len,
discarded = total - valid_len,
"txlog: torn tail cut"
);
file.set_len(valid_len)?;
file.sync_all()?;
}
if (torn && repair) || offsets.len() != trusted {
if repair {
let mut bytes = Vec::with_capacity(offsets.len() * 8);
for off in &offsets {
bytes.extend_from_slice(&off.to_le_bytes());
}
fs::write(&ipath, bytes)?;
}
}
Ok(Segment {
first,
offsets,
len: valid_len,
})
}
struct Writer {
file: File,
idx: File,
seg: Segment,
disk_len: u64,
buf: Vec<u8>,
published: usize,
#[cfg(test)]
faults: WriterFaults,
}
#[cfg(test)]
#[derive(Default)]
struct WriterFaults {
fail_put_after: Option<usize>,
fail_cut: bool,
}
const STAGE_BYTES: usize = 1 << 20;
const DIRECT_BYTES: usize = 64 << 10;
impl Writer {
fn flush_buf(&mut self) -> io::Result<()> {
if !self.buf.is_empty() {
self.file.write_all(&self.buf)?;
self.disk_len += self.buf.len() as u64;
self.buf.clear();
}
Ok(())
}
fn put(&mut self, bytes: &[u8]) -> io::Result<()> {
#[cfg(test)]
if let Some(n) = self.faults.fail_put_after {
if n == 0 {
self.faults.fail_put_after = None;
return Err(io::Error::other("txlog test: injected write failure"));
}
self.faults.fail_put_after = Some(n - 1);
}
if bytes.len() >= DIRECT_BYTES {
self.flush_buf()?;
self.file.write_all(bytes)?;
self.disk_len += bytes.len() as u64;
return Ok(());
}
self.buf.extend_from_slice(bytes);
if self.buf.len() >= STAGE_BYTES {
self.flush_buf()?;
}
Ok(())
}
fn unpublished(&self) -> usize {
self.seg.offsets.len() - self.published
}
fn cut_to_committed(&mut self) -> io::Result<()> {
#[cfg(test)]
if std::mem::take(&mut self.faults.fail_cut) {
return Err(io::Error::other("txlog test: injected truncate failure"));
}
let keep = self.seg.len;
if keep >= self.disk_len {
self.buf.truncate((keep - self.disk_len) as usize);
return Ok(());
}
self.buf.clear();
self.file.set_len(keep)?;
self.file.seek(SeekFrom::Start(keep))?;
self.disk_len = keep;
self.file.sync_data()
}
}
pub const TEST_GROUP_ENV: &str = "GUNNAR_TXLOG_TEST_GROUP";
#[cfg(feature = "test-hooks")]
fn test_group_from_env() -> Option<usize> {
std::env::var(TEST_GROUP_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n > 1)
}
#[cfg(not(feature = "test-hooks"))]
fn test_group_from_env() -> Option<usize> {
None
}
pub const TEST_GROUP_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
pub struct Log {
dir: PathBuf,
segments: Mutex<Vec<Segment>>,
writer: Mutex<Writer>,
head: AtomicU64,
first: AtomicU64,
segment_bytes: u64,
follower: AtomicBool,
gate: Arc<Gate>,
released: AtomicBool,
poisoned: Mutex<Option<String>>,
sync_state: Mutex<bool>,
synced: std::sync::Condvar,
#[cfg(test)]
fsync_gates: Mutex<std::collections::VecDeque<std::sync::mpsc::Receiver<()>>>,
#[cfg(test)]
fail_fsyncs: std::sync::atomic::AtomicU32,
test_group: Option<usize>,
frame_syncs: AtomicU64,
erase_syncs: AtomicU64,
erase_lock: Mutex<()>,
#[cfg(test)]
erase_gates: Mutex<std::collections::VecDeque<std::sync::mpsc::Receiver<()>>>,
}
#[derive(Default)]
struct Gate {
state: Mutex<GateState>,
cv: std::sync::Condvar,
}
#[derive(Default)]
struct GateState {
frozen_by: Option<std::thread::ThreadId>,
inflight: u64,
}
thread_local! {
static MUTATION_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
#[must_use = "a mutation guard must be held until the store has taken the write"]
pub struct MutationGuard {
gate: Option<Arc<Gate>>,
}
impl Drop for MutationGuard {
fn drop(&mut self) {
MUTATION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
if let Some(gate) = self.gate.take() {
let mut st = gate.state.lock().unwrap_or_else(|p| p.into_inner());
st.inflight = st.inflight.saturating_sub(1);
gate.cv.notify_all();
}
}
}
pub struct FreezeGuard {
gate: Arc<Gate>,
}
impl Drop for FreezeGuard {
fn drop(&mut self) {
let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
st.frozen_by = None;
self.gate.cv.notify_all();
}
}
impl std::fmt::Debug for Log {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Log")
.field("dir", &self.dir)
.field("first", &self.first_seq())
.field("head", &self.head_seq())
.finish()
}
}
pub const SPOOL_DIR: &str = "spool";
impl Log {
pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
Self::open_with(dir, DEFAULT_SEGMENT_BYTES)
}
pub fn open_with(dir: impl AsRef<Path>, segment_bytes: u64) -> Result<Self> {
let dir = dir.as_ref().to_path_buf();
fs::create_dir_all(&dir)?;
let mut firsts: Vec<u64> = Vec::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
match parse_seg_name(&name) {
Some((first, true)) => firsts.push(first),
Some((_, false)) => {}
None if name == SPOOL_DIR && entry.file_type().is_ok_and(|t| t.is_dir()) => {}
None => return Err(Error::Unexpected(entry.path())),
}
}
firsts.sort_unstable();
let mut segments = Vec::with_capacity(firsts.len().max(1));
let n = firsts.len();
for (i, first) in firsts.iter().enumerate() {
let seg = open_segment(&dir, *first, true)?;
if i + 1 < n && seg.offsets.is_empty() {
return Err(Error::Corrupt {
path: seg_path(&dir, *first),
offset: 0,
what: "closed segment holds no frame",
});
}
if let Some(next) = firsts.get(i + 1) {
match seg.last_seq() {
Some(last) if last + 1 == *next => {}
_ => {
return Err(Error::Corrupt {
path: seg_path(&dir, *first),
offset: seg.len,
what: "segment does not run up to the next one",
})
}
}
}
segments.push(seg);
}
if segments.is_empty() {
let first = 1;
File::create(seg_path(&dir, first))?;
File::create(idx_path(&dir, first))?;
sync_dir(&dir)?;
segments.push(Segment {
first,
offsets: Vec::new(),
len: 0,
});
}
let open = segments.last().expect("one segment");
let head = open.last_seq().unwrap_or(open.first - 1);
let first = segments[0].first;
let mut file = OpenOptions::new()
.append(true)
.open(seg_path(&dir, open.first))?;
file.seek(SeekFrom::Start(open.len))?;
let idx = OpenOptions::new()
.append(true)
.open(idx_path(&dir, open.first))?;
let seg = Segment {
first: open.first,
offsets: open.offsets.clone(),
len: open.len,
};
let published = seg.offsets.len();
Ok(Log {
dir,
segments: Mutex::new(segments),
writer: Mutex::new(Writer {
file,
idx,
disk_len: seg.len,
seg,
buf: Vec::new(),
published,
#[cfg(test)]
faults: WriterFaults::default(),
}),
head: AtomicU64::new(head),
first: AtomicU64::new(first),
segment_bytes,
follower: AtomicBool::new(false),
gate: Arc::new(Gate::default()),
released: AtomicBool::new(false),
poisoned: Mutex::new(None),
sync_state: Mutex::new(false),
synced: std::sync::Condvar::new(),
#[cfg(test)]
fsync_gates: Mutex::new(Default::default()),
#[cfg(test)]
fail_fsyncs: std::sync::atomic::AtomicU32::new(0),
test_group: test_group_from_env(),
frame_syncs: AtomicU64::new(0),
erase_syncs: AtomicU64::new(0),
erase_lock: Mutex::new(()),
#[cfg(test)]
erase_gates: Mutex::new(Default::default()),
})
}
pub fn mutation(&self) -> MutationGuard {
let depth = MUTATION_DEPTH.with(|d| {
let v = d.get();
d.set(v + 1);
v
});
if depth > 0 {
return MutationGuard { gate: None };
}
let me = std::thread::current().id();
let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
while matches!(st.frozen_by, Some(t) if t != me) {
st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
st.inflight += 1;
MutationGuard {
gate: Some(Arc::clone(&self.gate)),
}
}
pub fn freeze(&self) -> FreezeGuard {
debug_assert_eq!(
MUTATION_DEPTH.with(|d| d.get()),
0,
"freeze from inside a mutation waits for itself"
);
let me = std::thread::current().id();
let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
while st.frozen_by.is_some_and(|t| t != me) {
st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
st.frozen_by = Some(me);
while st.inflight > 0 {
st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
FreezeGuard {
gate: Arc::clone(&self.gate),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn set_follower(&self, on: bool) {
self.follower.store(on, Ordering::Release);
}
pub fn is_follower(&self) -> bool {
self.follower.load(Ordering::Acquire)
}
pub fn group_hold(&self) -> Option<usize> {
self.test_group
}
pub fn head_seq(&self) -> u64 {
self.head.load(Ordering::Acquire)
}
pub fn first_seq(&self) -> u64 {
self.first.load(Ordering::Acquire)
}
pub fn append(&self, kind: Kind, meta: &Meta, blob: &[u8]) -> Result<u64> {
if self.is_follower() {
return Err(Error::Follower);
}
let meta = meta.to_bytes();
self.append_raw(kind as u16, &meta, blob)
}
pub fn append_at(&self, seq: u64, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
{
let mut w = self.lock_writer()?;
let expected = w.seg.first + w.seg.offsets.len() as u64;
if seq != expected {
return Err(Error::OutOfOrder { got: seq, expected });
}
self.stage_locked(&mut w, kind, meta, blob)?;
}
self.sync_through(seq)?;
Ok(seq)
}
pub fn append_raw(&self, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
let seq = {
let mut w = self.lock_writer()?;
self.stage_locked(&mut w, kind, meta, blob)?
};
self.sync_through(seq)?;
Ok(seq)
}
pub fn stage(&self, kind: Kind, meta: &Meta, blob: &[u8]) -> Result<u64> {
if self.is_follower() {
return Err(Error::Follower);
}
self.stage_raw(kind as u16, &meta.to_bytes(), blob)
}
pub fn stage_raw(&self, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
let mut w = self.lock_writer()?;
self.stage_locked(&mut w, kind, meta, blob)
}
fn stage_locked(&self, w: &mut Writer, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
self.roll_if_full(w)?;
let seq = w.seg.first + w.seg.offsets.len() as u64;
let header = Header {
seq,
kind,
meta_len: meta.len() as u32,
blob_len: blob.len() as u64,
};
let h = header.encode();
let mut crc = Crc32c::new();
crc.update(meta);
crc.update(blob);
let tr = crc.finish().to_le_bytes();
let put = (|| {
w.put(&h)?;
w.put(meta)?;
w.put(blob)?;
w.put(&tr)
})();
if let Err(e) = put {
if let Err(cut) = w.cut_to_committed() {
self.poison(format!("a half-written frame could not be cut off the segment ({cut}) after its write failed ({e})"));
}
return Err(e.into());
}
let off = w.seg.len;
w.seg.offsets.push(off);
w.seg.len += header.frame_len();
Ok(seq)
}
pub fn sync_through(&self, seq: u64) -> Result<()> {
{
let mut leading = self.sync_state.lock().unwrap_or_else(|p| p.into_inner());
loop {
if self.head_seq() >= seq {
return Ok(());
}
if !*leading {
*leading = true;
break;
}
leading = self.synced.wait(leading).unwrap_or_else(|p| p.into_inner());
}
}
struct Lead<'a>(&'a Log);
impl Drop for Lead<'_> {
fn drop(&mut self) {
*self.0.sync_state.lock().unwrap_or_else(|p| p.into_inner()) = false;
self.0.synced.notify_all();
}
}
let _lead = Lead(self);
if let Some(n) = self.test_group {
self.hold_for_the_group(n);
}
let snap = (|| -> Result<(File, u64, usize)> {
let mut w = self.lock_writer()?;
w.flush_buf()?;
Ok((w.file.try_clone()?, w.seg.first, w.seg.offsets.len()))
})();
let (file, first, upto) = match snap {
Ok(s) => s,
Err(_) if self.head_seq() >= seq => return Ok(()),
Err(e) => return Err(e),
};
#[cfg(test)]
{
let gate = self
.fsync_gates
.lock()
.unwrap_or_else(|p| p.into_inner())
.pop_front();
if let Some(gate) = gate {
let _ = gate.recv();
}
}
if let Err(e) = self.sync_frames(&file) {
let mut w = self.writer.lock().expect("txlog writer");
if w.seg.first == first {
return Err(self.poison_and_cut(&mut w, e));
}
drop(w);
if self.head_seq() < seq {
return Err(self.poison_and_cut(&mut self.writer.lock().expect("txlog writer"), e));
}
return Ok(());
}
self.frame_syncs.fetch_add(1, Ordering::Relaxed);
let mut w = match self.lock_writer() {
Ok(w) => w,
Err(_) if self.head_seq() >= seq => return Ok(()),
Err(e) => return Err(e),
};
if w.seg.first == first && upto > w.published {
self.publish_upto(&mut w, upto)?;
}
Ok(())
}
fn hold_for_the_group(&self, n: usize) {
let until = std::time::Instant::now() + TEST_GROUP_WAIT;
loop {
let staged = self.writer.lock().map(|w| w.unpublished()).unwrap_or(n);
if staged >= n || std::time::Instant::now() >= until {
return;
}
std::thread::sleep(std::time::Duration::from_micros(200));
}
}
fn sync_frames(&self, file: &File) -> io::Result<()> {
#[cfg(test)]
loop {
let n = self.fail_fsyncs.load(Ordering::SeqCst);
if n == 0 {
break;
}
if self
.fail_fsyncs
.compare_exchange(n, n - 1, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
return Err(io::Error::other("txlog test: injected fsync failure (EIO)"));
}
}
file.sync_data()
}
#[cfg(test)]
fn fail_next_fsyncs(&self, n: u32) {
self.fail_fsyncs.store(n, Ordering::SeqCst);
}
#[cfg(test)]
fn hold_next_fsync(&self, gate: std::sync::mpsc::Receiver<()>) {
self.fsync_gates.lock().unwrap().push_back(gate);
}
pub fn sync(&self) -> Result<u64> {
let mut w = self.lock_writer()?;
self.publish(&mut w)?;
Ok(self.head_seq())
}
pub fn unsynced(&self) -> u64 {
self.writer.lock().expect("txlog writer").unpublished() as u64
}
fn publish(&self, w: &mut Writer) -> Result<()> {
w.flush_buf()?;
let n = w.unpublished();
if n == 0 {
return Ok(());
}
if let Some(cause) = self.poisoned_by() {
return Err(Error::Poisoned {
dir: self.dir.clone(),
cause,
});
}
if let Err(e) = self.sync_frames(&w.file) {
return Err(self.poison_and_cut(w, e));
}
self.frame_syncs.fetch_add(1, Ordering::Relaxed);
self.publish_upto(w, w.seg.offsets.len())
}
pub fn poisoned_by(&self) -> Option<String> {
self.poisoned
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
fn poison(&self, cause: String) {
let mut p = self.poisoned.lock().unwrap_or_else(|p| p.into_inner());
if p.is_none() {
tracing::error!(
target: crate::observe::TARGET_STORE,
event = "txlog.poisoned",
dir = %self.dir.display(),
cause = %cause,
"the transaction log is poisoned: every write refuses until it is reopened"
);
*p = Some(cause);
}
}
fn poison_and_cut(&self, w: &mut Writer, e: io::Error) -> Error {
let mut cause = format!("a frame fsync failed ({e})");
let keep = w.seg.offsets.get(w.published).copied().unwrap_or(w.seg.len);
w.seg.offsets.truncate(w.published);
w.seg.len = keep;
if keep >= w.disk_len {
w.buf.truncate((keep - w.disk_len) as usize);
} else {
w.buf.clear();
let cut = (|| {
w.file.set_len(keep)?;
w.file.seek(SeekFrom::Start(keep))?;
w.disk_len = keep;
w.file.sync_data()
})();
if let Err(c) = cut {
cause.push_str(&format!(
", and the frames it did not make durable could not be cut ({c})"
));
}
}
self.poison(cause.clone());
Error::Poisoned {
dir: self.dir.clone(),
cause,
}
}
pub fn frame_syncs(&self) -> u64 {
self.frame_syncs.load(Ordering::Relaxed)
}
fn publish_upto(&self, w: &mut Writer, upto: usize) -> Result<()> {
if upto <= w.published {
return Ok(());
}
let fresh: Vec<u64> = w.seg.offsets[w.published..upto].to_vec();
let mut bytes = Vec::with_capacity(fresh.len() * 8);
for off in &fresh {
bytes.extend_from_slice(&off.to_le_bytes());
}
w.idx.write_all(&bytes)?;
w.published = upto;
let end = match w.seg.offsets.get(upto) {
Some(&next) => next,
None => w.seg.len,
};
{
let mut segs = self.segments.lock().expect("txlog segments");
let open = segs.last_mut().expect("open segment");
debug_assert_eq!(
open.first, w.seg.first,
"the published open segment is the writer's"
);
open.offsets.extend_from_slice(&fresh);
open.len = end;
}
let last = w.seg.first + upto as u64 - 1;
self.head.store(last, Ordering::Release);
Ok(())
}
pub fn begin(
&self,
seq: Option<u64>,
kind: u16,
meta: &[u8],
blob_len: u64,
) -> Result<Pending<'_>> {
if seq.is_none() && self.is_follower() {
return Err(Error::Follower);
}
let mut w = self.lock_writer()?;
self.roll_if_full(&mut w)?;
let next = w.seg.first + w.seg.offsets.len() as u64;
if let Some(seq) = seq {
if seq != next {
return Err(Error::OutOfOrder {
got: seq,
expected: next,
});
}
}
let header = Header {
seq: next,
kind,
meta_len: meta.len() as u32,
blob_len,
};
let h = header.encode();
w.put(&h)?;
w.put(meta)?;
let mut crc = Crc32c::new();
crc.update(meta);
Ok(Pending {
log: self,
w,
header,
crc,
written: 0,
finished: false,
})
}
fn lock_writer(&self) -> Result<std::sync::MutexGuard<'_, Writer>> {
let w = self.writer.lock().expect("txlog writer");
if let Some(cause) = self.poisoned_by() {
return Err(Error::Poisoned {
dir: self.dir.clone(),
cause,
});
}
if self.released.load(Ordering::Acquire) {
return Err(Error::Released(self.dir.clone()));
}
Ok(w)
}
pub fn release(&self) -> Result<()> {
let mut w = self.writer.lock().expect("txlog writer");
if self.released.load(Ordering::Acquire) {
return Ok(());
}
self.publish(&mut w)?;
w.file.sync_all()?;
w.idx.sync_all()?;
w.file = OpenOptions::new().append(true).open("/dev/null")?;
w.idx = OpenOptions::new().append(true).open("/dev/null")?;
self.released.store(true, Ordering::Release);
Ok(())
}
pub fn reacquire(&self) -> Result<()> {
let mut w = self.writer.lock().expect("txlog writer");
if !self.released.load(Ordering::Acquire) {
return Ok(());
}
let path = seg_path(&self.dir, w.seg.first);
let mut file = OpenOptions::new().append(true).open(&path)?;
let on_disk = file.metadata()?.len();
if on_disk != w.seg.len {
return Err(Error::Corrupt {
path,
offset: on_disk,
what: "the open segment changed length while the log was released",
});
}
file.seek(SeekFrom::Start(w.seg.len))?;
let idx = OpenOptions::new()
.append(true)
.open(idx_path(&self.dir, w.seg.first))?;
w.file = file;
w.idx = idx;
w.disk_len = w.seg.len;
self.released.store(false, Ordering::Release);
Ok(())
}
pub fn is_released(&self) -> bool {
self.released.load(Ordering::Acquire)
}
fn roll_if_full(&self, w: &mut Writer) -> Result<()> {
if w.seg.len < self.segment_bytes || w.seg.offsets.is_empty() {
return Ok(());
}
let next_first = w.seg.first + w.seg.offsets.len() as u64;
self.publish(w)?;
w.file.sync_all()?;
w.idx.sync_all()?;
let file = File::create(seg_path(&self.dir, next_first))?;
let idx = File::create(idx_path(&self.dir, next_first))?;
sync_dir(&self.dir)?;
w.file = file;
w.idx = idx;
w.disk_len = 0;
w.published = 0;
w.seg = Segment {
first: next_first,
offsets: Vec::new(),
len: 0,
};
self.segments.lock().expect("txlog segments").push(Segment {
first: next_first,
offsets: Vec::new(),
len: 0,
});
Ok(())
}
fn locate(&self, seq: u64) -> Result<(PathBuf, u64)> {
let head = self.head_seq();
if seq > head {
return Err(Error::BeyondHead { seq, head });
}
let first = self.first_seq();
if seq < first {
return Err(Error::NotRetained { seq, first });
}
let segs = self.segments.lock().expect("txlog segments");
let i = match segs.binary_search_by(|s| s.first.cmp(&seq)) {
Ok(i) => i,
Err(0) => return Err(Error::NotRetained { seq, first }),
Err(i) => i - 1,
};
let seg = &segs[i];
let k = (seq - seg.first) as usize;
let off = *seg.offsets.get(k).ok_or(Error::BeyondHead { seq, head })?;
Ok((seg_path(&self.dir, seg.first), off))
}
pub fn read(&self, seq: u64) -> Result<Entry> {
let (path, off) = self.locate(seq)?;
let mut file = File::open(&path)?;
file.seek(SeekFrom::Start(off))?;
let mut hdr = [0u8; HEADER_LEN];
file.read_exact(&mut hdr)?;
let h = Header::decode(&hdr).ok_or(Error::Corrupt {
path: path.clone(),
offset: off,
what: "header",
})?;
let mut meta = vec![0u8; h.meta_len as usize];
file.read_exact(&mut meta)?;
if h.kind == Kind::Redacted as u16 {
return Ok(Entry {
seq: h.seq,
kind: h.kind,
meta,
blob: Vec::new(),
});
}
let mut blob = vec![0u8; h.blob_len as usize];
file.read_exact(&mut blob)?;
let mut tr = [0u8; TRAILER_LEN];
file.read_exact(&mut tr)?;
let mut crc = Crc32c::new();
crc.update(&meta);
crc.update(&blob);
if crc.finish() != u32::from_le_bytes(tr) {
return Err(Error::Corrupt {
path,
offset: off,
what: "payload crc",
});
}
Ok(Entry {
seq: h.seq,
kind: h.kind,
meta,
blob,
})
}
pub fn locate_blob(&self, seq: u64) -> Result<Located> {
let (path, off) = self.locate(seq)?;
let mut file = File::open(&path)?;
file.seek(SeekFrom::Start(off))?;
let mut hdr = [0u8; HEADER_LEN];
file.read_exact(&mut hdr)?;
let h = Header::decode(&hdr).ok_or(Error::Corrupt {
path: path.clone(),
offset: off,
what: "header",
})?;
let mut meta = vec![0u8; h.meta_len as usize];
file.read_exact(&mut meta)?;
Ok(Located {
seq: h.seq,
kind: h.kind,
meta,
path,
blob_offset: off + HEADER_LEN as u64 + h.meta_len as u64,
blob_len: if h.kind == Kind::Redacted as u16 {
0
} else {
h.blob_len
},
payload_crc: None,
})
}
pub fn read_pieces<E: From<Error>>(
&self,
seq: u64,
piece: usize,
mut each: impl FnMut(&Located, &[u8], bool) -> std::result::Result<(), E>,
) -> std::result::Result<Located, E> {
let mut at = self.locate_blob(seq)?;
let frame_at = at.blob_offset - HEADER_LEN as u64 - at.meta.len() as u64;
let corrupt = |path: &Path| Error::Corrupt {
path: path.to_path_buf(),
offset: frame_at,
what: "payload crc",
};
if at.kind == Kind::Redacted as u16 {
each(&at, &[], true)?;
return Ok(at);
}
let mut file = File::open(&at.path).map_err(Error::from)?;
file.seek(SeekFrom::Start(at.blob_offset))
.map_err(Error::from)?;
let mut crc = Crc32c::new();
crc.update(&at.meta);
let mut buf = vec![0u8; (piece.max(1) as u64).min(at.blob_len) as usize];
let mut left = at.blob_len;
loop {
let n = (buf.len() as u64).min(left) as usize;
file.read_exact(&mut buf[..n]).map_err(Error::from)?;
crc.update(&buf[..n]);
left -= n as u64;
if left > 0 {
each(&at, &buf[..n], false)?;
continue;
}
let mut tr = [0u8; TRAILER_LEN];
file.read_exact(&mut tr).map_err(Error::from)?;
if crc.finish() != u32::from_le_bytes(tr) {
return Err(corrupt(&at.path).into());
}
at.payload_crc = Some(u32::from_le_bytes(tr));
each(&at, &buf[..n], true)?;
return Ok(at);
}
}
pub fn read_pieces_from<E: From<Error>>(
&self,
seq: u64,
from: u64,
piece: usize,
mut each: impl FnMut(&Located, &[u8], bool) -> std::result::Result<(), E>,
) -> std::result::Result<Located, E> {
if from == 0 {
return self.read_pieces(seq, piece, each);
}
let mut at = self.locate_blob(seq)?;
if at.kind == Kind::Redacted as u16 || from > at.blob_len {
return Err(Error::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"txlog: seq {seq} read from byte {from} of a {}-byte blob",
at.blob_len
),
))
.into());
}
let mut file = File::open(&at.path).map_err(Error::from)?;
file.seek(SeekFrom::Start(at.blob_offset + from))
.map_err(Error::from)?;
let mut left = at.blob_len - from;
let mut buf = vec![0u8; (piece.max(1) as u64).min(left.max(1)) as usize];
loop {
let n = (buf.len() as u64).min(left) as usize;
file.read_exact(&mut buf[..n]).map_err(Error::from)?;
left -= n as u64;
if left > 0 {
each(&at, &buf[..n], false)?;
continue;
}
let mut tr = [0u8; TRAILER_LEN];
file.read_exact(&mut tr).map_err(Error::from)?;
at.payload_crc = Some(u32::from_le_bytes(tr));
each(&at, &buf[..n], true)?;
return Ok(at);
}
}
pub fn erase(&self, before: u64, repos: &[String]) -> Result<Erased> {
let _one = self.erase_lock.lock().unwrap_or_else(|p| p.into_inner());
let plan: Vec<(u64, Vec<u64>)> = {
let mut w = self.lock_writer()?;
self.publish(&mut w)?;
self.segments
.lock()
.expect("txlog segments")
.iter()
.filter(|s| !s.offsets.is_empty() && s.first < before)
.map(|s| (s.first, s.offsets.clone()))
.collect()
};
let data = [
Kind::Pack as u16,
Kind::Refs as u16,
Kind::LfsPut as u16,
Kind::LfsDelete as u16,
];
let mut out = Erased::default();
let mut hdr = [0u8; HEADER_LEN];
for (first, offsets) in plan {
let path = seg_path(&self.dir, first);
let mut file = match OpenOptions::new().read(true).write(true).open(&path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
let mut punch: Vec<(u64, u64)> = Vec::new();
for (i, &off) in offsets.iter().enumerate() {
let seq = first + i as u64;
if seq >= before {
break;
}
file.seek(SeekFrom::Start(off))?;
file.read_exact(&mut hdr)?;
let h = Header::decode(&hdr).ok_or(Error::Corrupt {
path: path.clone(),
offset: off,
what: "header",
})?;
let redacted_already = h.kind == Kind::Redacted as u16;
if !redacted_already && !data.contains(&h.kind) {
continue;
}
let mut meta = vec![0u8; h.meta_len as usize];
file.read_exact(&mut meta)?;
let named = Meta::from_bytes(&meta)
.ok()
.and_then(|m| m.repo().map(str::to_owned));
if !named.is_some_and(|r| repos.contains(&r)) {
continue;
}
let blob_at = off + HEADER_LEN as u64 + h.meta_len as u64;
if redacted_already {
punch.push((blob_at, h.blob_len));
continue;
}
let redacted = Header {
kind: Kind::Redacted as u16,
..h
};
file.seek(SeekFrom::Start(off))?;
file.write_all(&redacted.encode())?;
punch.push((blob_at, h.blob_len));
out.frames += 1;
out.bytes += h.blob_len;
}
if punch.is_empty() {
continue;
}
file.sync_data()?;
self.erase_syncs.fetch_add(1, Ordering::Relaxed);
#[cfg(test)]
self.erase_gate();
for (at, len) in punch {
zero_range(&mut file, at, len)?;
}
file.sync_all()?;
self.erase_syncs.fetch_add(1, Ordering::Relaxed);
}
Ok(out)
}
pub fn erase_syncs(&self) -> u64 {
self.erase_syncs.load(Ordering::Relaxed)
}
#[cfg(test)]
fn hold_next_erase(&self, gate: std::sync::mpsc::Receiver<()>) {
self.erase_gates.lock().unwrap().push_back(gate);
}
#[cfg(test)]
fn erase_gate(&self) {
let gate = self
.erase_gates
.lock()
.unwrap_or_else(|p| p.into_inner())
.pop_front();
if let Some(gate) = gate {
let _ = gate.recv();
}
}
pub fn scan_meta(&self, mut each: impl FnMut(u64, u16, &[u8])) -> Result<()> {
let head = self.head_seq();
let plan: Vec<(u64, u64, u64)> = self
.segments
.lock()
.expect("txlog segments")
.iter()
.filter(|s| !s.offsets.is_empty())
.map(|s| (s.first, s.offsets[0], s.offsets.len() as u64))
.collect();
let mut hdr = [0u8; HEADER_LEN];
let mut meta: Vec<u8> = Vec::with_capacity(256);
for (first, start, n) in plan {
let path = seg_path(&self.dir, first);
let mut r = io::BufReader::with_capacity(1 << 16, File::open(&path)?);
r.seek(SeekFrom::Start(start))?;
let mut offset = start;
for seq in first..first + n {
if seq > head {
return Ok(());
}
r.read_exact(&mut hdr)?;
let h = match Header::decode(&hdr) {
Some(h) if h.seq == seq => h,
Some(_) => {
return Err(Error::Corrupt {
path,
offset,
what: "sequence out of place",
})
}
None => {
return Err(Error::Corrupt {
path,
offset,
what: "header",
})
}
};
meta.resize(h.meta_len as usize, 0);
r.read_exact(&mut meta)?;
each(seq, h.kind, &meta);
r.seek_relative((h.blob_len + TRAILER_LEN as u64) as i64)?;
offset += h.frame_len();
}
}
Ok(())
}
pub fn range(&self, from: u64, to: u64) -> Result<Vec<Entry>> {
let to = to.min(self.head_seq());
let mut out = Vec::new();
let mut seq = from;
while seq <= to {
out.push(self.read(seq)?);
seq += 1;
}
Ok(out)
}
pub fn truncate_before(&self, seq: u64) -> Result<u64> {
let _w = self.lock_writer()?;
let mut segs = self.segments.lock().expect("txlog segments");
while segs.len() > 1 {
let drop = match segs[0].last_seq() {
Some(last) => last < seq,
None => true,
};
if !drop {
break;
}
let first = segs[0].first;
fs::remove_file(seg_path(&self.dir, first))?;
let _ = fs::remove_file(idx_path(&self.dir, first));
segs.remove(0);
}
sync_dir(&self.dir)?;
let first = segs[0].first;
self.first.store(first, Ordering::Release);
Ok(first)
}
pub fn retain_from(&self, seq: u64) -> Result<u64> {
{
let mut w = self.writer.lock().expect("txlog writer");
self.publish(&mut w)?;
if !w.seg.offsets.is_empty() {
let next_first = w.seg.first + w.seg.offsets.len() as u64;
w.file.sync_all()?;
w.idx.sync_all()?;
let file = File::create(seg_path(&self.dir, next_first))?;
let idx = File::create(idx_path(&self.dir, next_first))?;
sync_dir(&self.dir)?;
w.file = file;
w.idx = idx;
w.disk_len = 0;
w.published = 0;
w.seg = Segment {
first: next_first,
offsets: Vec::new(),
len: 0,
};
self.segments.lock().expect("txlog segments").push(Segment {
first: next_first,
offsets: Vec::new(),
len: 0,
});
}
}
self.truncate_before(seq)
}
pub fn reset(&self, keep_at_most: u64) -> Result<()> {
let head = self.head_seq();
if head > keep_at_most {
return Err(Error::OutOfOrder {
got: head,
expected: keep_at_most,
});
}
self.restart_after(0)
}
pub fn restart_after(&self, at: u64) -> Result<()> {
let mut w = self.lock_writer()?;
let mut segs = self.segments.lock().expect("txlog segments");
for s in segs.iter() {
fs::remove_file(seg_path(&self.dir, s.first))?;
let _ = fs::remove_file(idx_path(&self.dir, s.first));
}
segs.clear();
let first = at + 1;
File::create(seg_path(&self.dir, first))?;
File::create(idx_path(&self.dir, first))?;
sync_dir(&self.dir)?;
segs.push(Segment {
first,
offsets: Vec::new(),
len: 0,
});
w.file = OpenOptions::new()
.append(true)
.open(seg_path(&self.dir, first))?;
w.idx = OpenOptions::new()
.append(true)
.open(idx_path(&self.dir, first))?;
w.seg = Segment {
first,
offsets: Vec::new(),
len: 0,
};
w.disk_len = 0;
w.buf.clear();
w.published = 0;
self.head.store(at, Ordering::Release);
self.first.store(first, Ordering::Release);
Ok(())
}
pub fn layout(&self) -> Vec<(u64, u64, u64)> {
self.segments
.lock()
.expect("txlog segments")
.iter()
.map(|s| (s.first, s.first + s.offsets.len() as u64 - 1, s.len))
.collect()
}
pub fn bytes(&self) -> u64 {
self.segments
.lock()
.expect("txlog segments")
.iter()
.map(|s| s.len)
.sum()
}
}
pub struct Pending<'a> {
log: &'a Log,
w: std::sync::MutexGuard<'a, Writer>,
header: Header,
crc: Crc32c,
written: u64,
finished: bool,
}
impl Pending<'_> {
pub fn seq(&self) -> u64 {
self.header.seq
}
pub fn remaining(&self) -> u64 {
self.header.blob_len - self.written
}
pub fn write(&mut self, chunk: &[u8]) -> Result<()> {
if chunk.len() as u64 > self.remaining() {
return Err(Error::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"txlog: chunk exceeds the declared blob length",
)));
}
self.w.put(chunk)?;
self.crc.update(chunk);
self.written += chunk.len() as u64;
Ok(())
}
pub fn commit(mut self) -> Result<u64> {
let seq = self.close()?;
self.log.publish(&mut self.w)?;
Ok(seq)
}
pub fn commit_unsynced(mut self) -> Result<u64> {
self.close()
}
fn close(&mut self) -> Result<u64> {
if self.remaining() != 0 {
let owed = self.remaining();
self.abort_inner()?;
return Err(Error::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("txlog: blob short by {owed} bytes at commit"),
)));
}
let tr = self.crc.finish().to_le_bytes();
self.w.put(&tr)?;
self.finished = true;
let off = self.w.seg.len;
self.w.seg.offsets.push(off);
self.w.seg.len += self.header.frame_len();
Ok(self.header.seq)
}
pub fn abort(mut self) -> Result<()> {
self.abort_inner()
}
fn abort_inner(&mut self) -> Result<()> {
self.finished = true;
if let Err(e) = self.w.cut_to_committed() {
self.log.poison(format!(
"a partial frame could not be cut off the segment ({e})"
));
return Err(e.into());
}
Ok(())
}
}
impl Drop for Pending<'_> {
fn drop(&mut self) {
if self.finished {
return;
}
let _ = self.abort_inner();
}
}
pub fn is_cache_path(rel: &str) -> bool {
let rel = rel.trim_start_matches("./");
let rel = rel
.strip_prefix(".serialdat/")
.or_else(|| rel.strip_prefix("serialdat/"))
.unwrap_or(rel);
let first = rel.split('/').next().unwrap_or("");
let name = rel.rsplit('/').next().unwrap_or("");
matches!(first, ".korp-disktest" | "gc" | "quarantine" | "meta" | "serialdat")
|| rel.split('/').any(|c| c.starts_with("objects.exploded"))
|| name == "objects.graph"
|| name.ends_with(".tmp")
|| name.starts_with("incoming-")
|| name == "grow-journal"
}
pub const AUDIT_REL: &str = "log/audit-chain.jsonl";
pub const AUDIT_LOCAL_REL: &str = "log/audit-chain.local.jsonl";
pub const HUB_CONF_REL: &str = "wg/hub.conf";
pub const HUB_CONF_FRAMED_REL: &str = "wg/hub.conf.framed";
pub const WG_IDENTITY_KEY_REL: &str = "wg/identity.key";
pub const WG_IDENTITY_PUB_REL: &str = "wg/identity.pub";
pub fn is_per_box_path(rel: &str) -> bool {
let rel = rel.trim_start_matches("./");
let rel = rel
.strip_prefix(".serialdat/")
.or_else(|| rel.strip_prefix("serialdat/"))
.unwrap_or(rel);
if rel == AUDIT_REL || rel == HUB_CONF_REL {
return false;
}
if matches!(
rel,
WG_IDENTITY_KEY_REL | WG_IDENTITY_PUB_REL | HUB_CONF_FRAMED_REL
) {
return true;
}
let first = rel.split('/').next().unwrap_or("");
matches!(
first,
"crash" | "log" | "twin.json" | "twin.key" | "txlog-restoring" | "txlog-fresh-docs.json" | "txlog-reseal" | "txlog-snapshot-installing" | "txlog-snapshot-base" | "txlog-snapshot-base.old"
| "txlog-snapshot-dropped" | "txlog-snapshot-too-small"
| "txlog-restore-partial"
| "txlog-restore-snapshot"
)
}
pub const FRAMED_FILES: &[&str] = &[
"attestations.jsonl",
"transparency-heads.jsonl",
"builds.jsonl",
"build-heads.jsonl",
"forge.jsonl",
"forge-heads.jsonl",
"membership.jsonl",
"membership-heads.jsonl",
"replication.json",
"webhooks.json",
AUDIT_REL,
HUB_CONF_REL,
"identity.json",
];
pub fn never_framed(rel: &str) -> bool {
is_cache_path(rel) || is_per_box_path(rel)
}
pub mod sink {
use super::*;
static ROOTS: Mutex<Vec<(PathBuf, Weak<Log>)>> = Mutex::new(Vec::new());
fn refuse_cache(rel: &str) -> Result<()> {
if never_framed(rel) {
return Err(Error::BadMeta(format!(
"{rel} is a cache or per-box path; it is never a transaction"
)));
}
Ok(())
}
pub fn register(root: &Path, log: &Arc<Log>) {
let mut roots = ROOTS.lock().expect("txlog sink");
roots.retain(|(r, w)| w.strong_count() > 0 && r != root);
roots.push((root.to_path_buf(), Arc::downgrade(log)));
}
pub fn log_for(path: &Path) -> Option<(Arc<Log>, String)> {
let roots = ROOTS.lock().expect("txlog sink");
roots
.iter()
.filter(|(r, _)| path.starts_with(r))
.max_by_key(|(r, _)| r.as_os_str().len())
.and_then(|(r, w)| {
let log = w.upgrade()?;
let rel = path.strip_prefix(r).ok()?.to_string_lossy().into_owned();
Some((log, rel))
})
}
#[must_use = "hold the frame until the file has the write"]
pub struct Framed(#[allow(dead_code)] Option<MutationGuard>);
pub fn append_line(path: &Path, line: &str) -> Result<Framed> {
if let Some((log, rel)) = log_for(path) {
refuse_cache(&rel)?;
let guard = log.mutation();
log.append(Kind::Append, &Meta::Append { log: rel }, line.as_bytes())?;
return Ok(Framed(Some(guard)));
}
Ok(Framed(None))
}
#[must_use = "wait for the frame to be durable, then write the line, then drop this"]
pub struct StagedLine {
_guard: MutationGuard,
log: Arc<Log>,
seq: u64,
}
impl StagedLine {
pub fn seq(&self) -> u64 {
self.seq
}
pub fn durable(&self) -> Result<()> {
self.log.sync_through(self.seq)
}
pub fn log(&self) -> &Arc<Log> {
&self.log
}
}
pub fn stage_line(path: &Path, line: &str) -> Result<Option<StagedLine>> {
if let Some((log, rel)) = log_for(path) {
refuse_cache(&rel)?;
if log.is_follower() {
return Err(Error::Follower);
}
let guard = log.mutation();
let seq = log.stage_raw(
Kind::Append as u16,
&Meta::Append { log: rel }.to_bytes(),
line.as_bytes(),
)?;
return Ok(Some(StagedLine {
_guard: guard,
log,
seq,
}));
}
Ok(None)
}
pub fn replace_file(path: &Path, bytes: &[u8], deleted: bool) -> Result<Framed> {
if let Some((log, rel)) = log_for(path) {
refuse_cache(&rel)?;
let guard = log.mutation();
log.append(Kind::File, &Meta::File { path: rel, deleted }, bytes)?;
return Ok(Framed(Some(guard)));
}
Ok(Framed(None))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(repo: &str) -> Meta {
Meta::Pack {
repo: repo.into(),
hash: "sha256".into(),
}
}
#[test]
fn a_failed_fsync_poisons_the_log_until_it_is_reopened() {
let dir = tempfile::tempdir().unwrap();
let m = meta("g/r").to_bytes();
{
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
log.fail_next_fsyncs(1);
log.append_raw(Kind::Pack as u16, &m, b"two")
.expect_err("its fsync failed");
let next = log.append_raw(Kind::Pack as u16, &m, b"three");
assert!(next.is_err(), "an append after a failed fsync was taken (and published the failed frame with it): {next:?}");
assert!(
log.sync().is_err() || log.head_seq() == 1,
"a sync published past the failed fsync"
);
assert_eq!(
log.head_seq(),
1,
"the frame whose fsync failed was published"
);
assert!(
log.read(2).is_err(),
"the frame whose fsync failed is readable"
);
}
let log = Log::open(dir.path()).unwrap();
assert_eq!(
log.head_seq(),
1,
"a reopen resurrected the frame whose fsync failed"
);
assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"again").unwrap(), 2);
assert_eq!(log.read(2).unwrap().blob, b"again");
}
#[test]
fn a_failed_cut_of_a_half_frame_poisons_the_log() {
let dir = tempfile::tempdir().unwrap();
let m = meta("g/r").to_bytes();
{
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
{
let mut w = log.writer.lock().unwrap();
w.faults.fail_put_after = Some(3);
w.faults.fail_cut = true;
}
let big = vec![7u8; DIRECT_BYTES * 2];
log.append_raw(Kind::Pack as u16, &m, &big)
.expect_err("its trailer failed");
let next = log.append_raw(Kind::Pack as u16, &m, b"three");
assert!(
next.is_err(),
"an append after a failed cut landed after the half frame: {next:?}"
);
assert_eq!(log.head_seq(), 1);
}
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 1);
assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"three").unwrap(), 2);
assert_eq!(log.read(2).unwrap().blob, b"three");
}
#[test]
fn crc32c_known_answer() {
assert_eq!(crc32c(b"123456789"), 0xE306_9283);
let mut a = Crc32c::new();
a.update(b"1234");
let mut b = Crc32c::resume(a.state());
b.update(b"56789");
assert_eq!(b.finish(), 0xE306_9283);
}
#[test]
fn appends_are_dense_and_read_back() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 0);
assert_eq!(log.first_seq(), 1);
for i in 1..=10u64 {
let s = log
.append(Kind::Pack, &meta("a/b"), &[i as u8; 100])
.unwrap();
assert_eq!(s, i);
}
assert_eq!(log.head_seq(), 10);
let e = log.read(7).unwrap();
assert_eq!(e.seq, 7);
assert_eq!(e.kind(), Some(Kind::Pack));
assert_eq!(e.blob, vec![7u8; 100]);
assert_eq!(e.meta().unwrap(), meta("a/b"));
assert!(matches!(log.read(11), Err(Error::BeyondHead { .. })));
assert!(matches!(log.read(0), Err(Error::NotRetained { .. })));
}
#[test]
fn reopen_keeps_head_and_trusts_index() {
let dir = tempfile::tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
for i in 0..5u64 {
log.append(Kind::Refs, &meta("x/y"), &[i as u8]).unwrap();
}
}
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 5);
assert_eq!(log.read(5).unwrap().blob, vec![4u8]);
assert_eq!(log.append(Kind::Refs, &meta("x/y"), b"6").unwrap(), 6);
}
#[test]
fn torn_tail_is_cut_and_the_log_goes_on() {
let dir = tempfile::tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
log.append(Kind::Pack, &meta("a/b"), &[1u8; 1000]).unwrap();
log.append(Kind::Pack, &meta("a/b"), &[2u8; 1000]).unwrap();
}
let path = seg_path(dir.path(), 1);
let len = fs::metadata(&path).unwrap().len();
let f = OpenOptions::new().write(true).open(&path).unwrap();
f.set_len(len - 100).unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 1, "the torn second frame is gone");
assert_eq!(
fs::metadata(&path).unwrap().len(),
len - 1000 - 32 - 4 - meta("a/b").to_bytes().len() as u64
);
assert_eq!(log.append(Kind::Pack, &meta("a/b"), &[3u8; 10]).unwrap(), 2);
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.read(2).unwrap().blob, vec![3u8; 10]);
}
fn unsynced(log: &Log, seq: u64, blob: &[u8]) -> u64 {
let m = meta("g/r").to_bytes();
let mut p = log
.begin(Some(seq), Kind::Pack as u16, &m, blob.len() as u64)
.unwrap();
p.write(blob).unwrap();
p.commit_unsynced().unwrap()
}
#[test]
fn a_group_is_invisible_until_synced_and_then_durable_as_one() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
log.set_follower(true);
for seq in 1..=5u64 {
assert_eq!(unsynced(&log, seq, &[seq as u8; 100]), seq);
}
assert_eq!(log.head_seq(), 0, "nothing acknowledged before the sync");
assert_eq!(log.unsynced(), 5);
assert!(
matches!(log.read(1), Err(Error::BeyondHead { .. })),
"a reader does not see a frame that is not durable"
);
assert!(
matches!(
log.begin(Some(5), Kind::Pack as u16, b"{}", 0),
Err(Error::OutOfOrder {
got: 5,
expected: 6
})
),
"the gap rule counts the group"
);
assert_eq!(
fs::metadata(idx_path(dir.path(), 1)).unwrap().len(),
0,
"the index is written after the data's fsync"
);
assert_eq!(log.sync().unwrap(), 5);
assert_eq!((log.head_seq(), log.unsynced()), (5, 0));
assert_eq!(log.read(3).unwrap().blob, vec![3u8; 100]);
assert_eq!(
fs::metadata(idx_path(dir.path(), 1)).unwrap().len(),
5 * 8,
"one index entry per frame"
);
assert_eq!(log.sync().unwrap(), 5, "nothing pending: nothing done");
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 5);
assert_eq!(log.read(5).unwrap().blob, vec![5u8; 100]);
}
#[test]
fn a_crash_before_the_sync_loses_only_what_was_never_acknowledged() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
for seq in 1..=3u64 {
unsynced(&log, seq, b"acked");
}
assert_eq!(log.sync().unwrap(), 3);
unsynced(&log, 4, b"staged");
unsynced(&log, 5, b"staged");
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(
log.head_seq(),
3,
"the acknowledged group is whole; the staged one never reached the file"
);
let big = vec![7u8; DIRECT_BYTES * 2];
unsynced(&log, 4, &big);
let m = meta("g/r").to_bytes();
let mut half = log
.begin(Some(5), Kind::Pack as u16, &m, (DIRECT_BYTES * 4) as u64)
.unwrap();
half.write(&big).unwrap();
std::mem::forget(half); let path = seg_path(dir.path(), 1);
let on_disk = fs::metadata(&path).unwrap().len();
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(
log.head_seq(),
4,
"the whole frame that reached the file is kept, the half one is cut"
);
assert_eq!(log.read(4).unwrap().blob, big);
assert!(
fs::metadata(&path).unwrap().len() < on_disk,
"the half frame is gone from the segment"
);
assert_eq!(log.append(Kind::Pack, &meta("g/r"), b"next").unwrap(), 5);
}
#[test]
fn an_abort_inside_a_group_keeps_the_frames_committed_before_it() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
unsynced(&log, 1, b"one");
unsynced(&log, 2, &vec![2u8; DIRECT_BYTES]);
let m = meta("g/r").to_bytes();
{
let mut p = log.begin(Some(3), Kind::Pack as u16, &m, 10).unwrap();
p.write(b"abc").unwrap();
}
{
let mut p = log
.begin(Some(3), Kind::Pack as u16, &m, (DIRECT_BYTES * 2) as u64)
.unwrap();
p.write(&vec![9u8; DIRECT_BYTES]).unwrap();
p.abort().unwrap();
}
assert_eq!(log.sync().unwrap(), 2);
assert_eq!(log.read(2).unwrap().blob, vec![2u8; DIRECT_BYTES]);
assert_eq!(unsynced(&log, 3, b"three"), 3);
assert_eq!(log.sync().unwrap(), 3);
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 3);
assert_eq!(log.read(3).unwrap().blob, b"three".to_vec());
}
#[test]
fn a_waiter_whose_frame_is_durable_returns_without_waiting_for_the_next_leaders_fsync() {
use std::sync::mpsc::channel;
use std::time::Duration;
let dir = tempfile::tempdir().unwrap();
let log = Arc::new(Log::open(dir.path()).unwrap());
let m = meta("g/r").to_bytes();
assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"x").unwrap(), 2);
let (open_a, gate_a) = channel::<()>();
let (open_b, gate_b) = channel::<()>();
log.hold_next_fsync(gate_a);
log.hold_next_fsync(gate_b);
let gates_left = |log: &Log| log.fsync_gates.lock().unwrap().len();
let a = {
let log = Arc::clone(&log);
std::thread::spawn(move || log.sync_through(1).unwrap())
};
while gates_left(&log) != 1 {
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"three").unwrap(), 3);
let b = {
let log = Arc::clone(&log);
std::thread::spawn(move || log.sync_through(3).unwrap())
};
std::thread::sleep(Duration::from_millis(200));
let (x_done, x_said) = channel::<()>();
let x = {
let log = Arc::clone(&log);
std::thread::spawn(move || {
log.sync_through(2).unwrap();
let _ = x_done.send(());
})
};
std::thread::sleep(Duration::from_millis(200));
open_a.send(()).unwrap();
a.join().unwrap();
assert!(log.head_seq() >= 2, "A published frames 1 and 2");
let t = std::time::Instant::now();
while gates_left(&log) != 0 && t.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(5));
}
let held = gates_left(&log) == 0;
let back = x_said.recv_timeout(Duration::from_secs(5));
open_b.send(()).unwrap();
b.join().unwrap();
x.join().unwrap();
assert!(held, "B led frame 3's fsync (its gate was taken)");
assert!(
back.is_ok(),
"X's frame was durable since A published it, yet X waited behind B's fsync of frame 3"
);
assert_eq!(log.head_seq(), 3);
}
#[test]
fn an_erase_is_two_fsyncs_per_segment_and_appends_do_not_wait_for_it() {
use std::sync::mpsc::channel;
use std::time::Duration;
let dir = tempfile::tempdir().unwrap();
let log = Arc::new(Log::open_with(dir.path(), 64 << 10).unwrap());
for i in 0..200u64 {
let repo = if i % 2 == 0 { "gone/a" } else { "stays/b" };
log.append(Kind::Pack, &meta(repo), &vec![i as u8; 1500])
.unwrap();
log.append(Kind::Pack, &meta("gone/a"), &vec![7u8; 600])
.unwrap();
}
let before = log.append(Kind::Refs, &meta("stays/b"), b"marker").unwrap();
let segments = log.segments.lock().unwrap().len() as u64;
let syncs0 = log.erase_syncs();
let (open, gate) = channel::<()>();
log.hold_next_erase(gate);
let eraser = {
let log = Arc::clone(&log);
std::thread::spawn(move || log.erase(before, &["gone/a".to_string()]).unwrap())
};
while !log.erase_gates.lock().unwrap().is_empty() {
std::thread::sleep(Duration::from_millis(5));
}
let (done, said) = channel::<u64>();
let appender = {
let log = Arc::clone(&log);
std::thread::spawn(move || {
let s = log.append(Kind::Refs, &meta("stays/b"), b"during").unwrap();
let _ = done.send(s);
})
};
let appended = said.recv_timeout(Duration::from_secs(3));
open.send(()).unwrap();
let erased = eraser.join().unwrap();
appender.join().unwrap();
let syncs = log.erase_syncs() - syncs0;
let line = format!(
"erased {} frames in {segments} segments with {syncs} fsyncs",
erased.frames
);
eprintln!("{line}");
assert_eq!(erased.frames, 300, "{line}");
assert!(appended.is_ok(), "an append waited for the erase: {line}");
assert!(
syncs <= 2 * segments,
"about two fsyncs per segment: {line}"
);
for seq in 1..before {
let e = log.read(seq).unwrap();
let gone = Meta::from_bytes(&e.meta).unwrap().repo() == Some("gone/a");
assert_eq!(e.kind == Kind::Redacted as u16, gone, "seq {seq}");
assert_eq!(e.blob.is_empty(), gone, "seq {seq}");
}
}
#[test]
fn a_group_across_a_segment_roll_and_a_synced_append_after_it() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open_with(dir.path(), 4096).unwrap();
for seq in 1..=40u64 {
unsynced(&log, seq, &vec![seq as u8; 300]);
}
assert!(
log.head_seq() > 0,
"the rolls published the groups in the closed segments"
);
assert_eq!(log.sync().unwrap(), 40);
unsynced(&log, 41, b"group");
assert_eq!(
log.append(Kind::Pack, &meta("g/r"), b"synced").unwrap(),
42,
"the append comes after the group"
);
assert_eq!(log.head_seq(), 42);
for seq in 1..=40u64 {
assert_eq!(
log.read(seq).unwrap().blob,
vec![seq as u8; 300],
"seq {seq}"
);
}
drop(log);
let log = Log::open_with(dir.path(), 4096).unwrap();
assert_eq!(log.head_seq(), 42);
assert_eq!(log.read(41).unwrap().blob, b"group".to_vec());
assert_eq!(log.read(42).unwrap().blob, b"synced".to_vec());
}
#[test]
fn torn_header_is_cut_too() {
let dir = tempfile::tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
log.append(Kind::Pack, &meta("a/b"), &[1u8; 10]).unwrap();
}
let path = seg_path(dir.path(), 1);
let mut f = OpenOptions::new().append(true).open(&path).unwrap();
f.write_all(b"GTX1\x05\x00\x00").unwrap(); let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 1);
assert_eq!(log.append(Kind::Pack, &meta("a/b"), &[2u8; 10]).unwrap(), 2);
}
#[test]
fn scan_meta_agrees_with_locate_blob_across_segments_and_retention() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open_with(dir.path(), 4096).unwrap();
for i in 0..200u64 {
let blob = vec![i as u8; (i as usize * 37) % 9000];
log.append(Kind::Pack, &meta(&format!("r/{i}")), &blob)
.unwrap();
}
log.retain_from(50).unwrap();
let mut seen = Vec::new();
log.scan_meta(|seq, kind, m| seen.push((seq, kind, m.to_vec())))
.unwrap();
let want: Vec<_> = (log.first_seq()..=log.head_seq())
.map(|s| {
let at = log.locate_blob(s).unwrap();
(at.seq, at.kind, at.meta)
})
.collect();
assert!(log.first_seq() > 1 && !want.is_empty());
assert_eq!(seen, want);
}
#[test]
fn corrupted_payload_in_tail_is_cut() {
let dir = tempfile::tempdir().unwrap();
let first_len;
{
let log = Log::open(dir.path()).unwrap();
log.append(Kind::Pack, &meta("a/b"), &[1u8; 10]).unwrap();
first_len = log.bytes();
log.append(Kind::Pack, &meta("a/b"), &[2u8; 10]).unwrap();
}
let path = seg_path(dir.path(), 1);
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(
first_len + 32 + meta("a/b").to_bytes().len() as u64 + 3,
))
.unwrap();
f.write_all(&[0xEE]).unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 1);
}
#[test]
fn stale_index_is_rebuilt_from_data() {
let dir = tempfile::tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
for _ in 0..4 {
log.append(Kind::Pack, &meta("a/b"), &[9u8; 10]).unwrap();
}
}
fs::remove_file(idx_path(dir.path(), 1)).unwrap();
fs::write(idx_path(dir.path(), 1), []).unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 4);
drop(log);
fs::write(idx_path(dir.path(), 1), [0u8; 8 * 9]).unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 4);
assert_eq!(log.read(3).unwrap().seq, 3);
}
#[test]
fn segments_roll_and_retention_drops_whole_segments() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open_with(dir.path(), 300).unwrap();
for i in 1..=20u64 {
log.append(Kind::Pack, &meta("a/b"), &[i as u8; 100])
.unwrap();
}
assert!(log.segments.lock().unwrap().len() > 3);
assert_eq!(log.read(1).unwrap().blob[0], 1);
let first = log.truncate_before(10).unwrap();
assert!(first <= 10 && first > 1, "first={first}");
assert!(matches!(log.read(1), Err(Error::NotRetained { .. })));
assert_eq!(log.read(10).unwrap().blob[0], 10);
assert_eq!(log.read(20).unwrap().blob[0], 20);
drop(log);
let log = Log::open_with(dir.path(), 300).unwrap();
assert_eq!(log.first_seq(), first);
assert_eq!(log.head_seq(), 20);
assert_eq!(
log.append(Kind::Pack, &meta("a/b"), &[21u8; 10]).unwrap(),
21
);
}
#[test]
fn append_at_refuses_out_of_order() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
let m = meta("a/b").to_bytes();
assert_eq!(log.append_at(1, 1, &m, b"x").unwrap(), 1);
assert!(matches!(
log.append_at(3, 1, &m, b"x"),
Err(Error::OutOfOrder {
got: 3,
expected: 2
})
));
assert!(matches!(
log.append_at(1, 1, &m, b"x"),
Err(Error::OutOfOrder { .. })
));
assert_eq!(log.append_at(2, 1, &m, b"x").unwrap(), 2);
}
#[test]
fn streaming_append_commits_and_aborts() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
let m = meta("a/b").to_bytes();
{
let mut p = log.begin(Some(1), 1, &m, 6).unwrap();
p.write(b"abc").unwrap();
p.write(b"def").unwrap();
assert_eq!(p.commit().unwrap(), 1);
}
{
let mut p = log.begin(None, 1, &m, 6).unwrap();
p.write(b"abc").unwrap();
p.abort().unwrap();
}
assert_eq!(log.head_seq(), 1);
{
let mut p = log.begin(None, 1, &m, 3).unwrap();
p.write(b"xy").unwrap();
assert!(p.commit().is_err(), "short blob is refused");
}
assert_eq!(log.head_seq(), 1);
assert_eq!(log.append_at(2, 1, &m, b"ok").unwrap(), 2);
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 2);
assert_eq!(log.read(1).unwrap().blob, b"abcdef");
assert_eq!(log.read(2).unwrap().blob, b"ok");
let l = log.locate_blob(1).unwrap();
assert_eq!(l.blob_len, 6);
let mut f = File::open(&l.path).unwrap();
f.seek(SeekFrom::Start(l.blob_offset)).unwrap();
let mut b = vec![0u8; 6];
f.read_exact(&mut b).unwrap();
assert_eq!(b, b"abcdef");
}
#[test]
fn a_streaming_append_dropped_half_way_leaves_nothing_and_the_next_append_reads_back() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
let m = meta("a/b").to_bytes();
assert_eq!(log.append_at(1, 1, &m, b"first").unwrap(), 1);
let before = log.bytes();
{
let mut p = log.begin(Some(2), 1, &m, 1 << 20).unwrap();
p.write(&[7u8; 4096]).unwrap();
}
assert_eq!(log.head_seq(), 1, "the dropped frame is not in the log");
let seg = log.locate_blob(1).unwrap().path;
assert_eq!(
std::fs::metadata(&seg).unwrap().len(),
before,
"and not in the segment file either"
);
assert_eq!(log.append_at(2, 1, &m, b"second").unwrap(), 2);
assert_eq!(
log.read(2).unwrap().blob,
b"second",
"the next append reads back in this process"
);
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 2);
assert_eq!(log.read(1).unwrap().blob, b"first");
assert_eq!(log.read(2).unwrap().blob, b"second", "and after a restart");
}
#[test]
fn read_pieces_streams_the_blob_and_withholds_the_last_piece_of_a_corrupt_frame() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
let blob: Vec<u8> = (0..10_000u32).map(|i| (i * 7 % 251) as u8).collect();
let s = log.append(Kind::Pack, &meta("a/b"), &blob).unwrap();
let empty = log.append(Kind::Refs, &meta("a/b"), b"").unwrap();
let rot = log.append(Kind::Pack, &meta("a/c"), &blob).unwrap();
let mut got = Vec::new();
let mut lasts = 0;
let mut want = Crc32c::new();
want.update(&meta("a/b").to_bytes());
want.update(&blob);
let want = want.finish();
let at = log
.read_pieces::<Error>(s, 3000, |l, p, last| {
assert_eq!(l.seq, s);
assert!(p.len() <= 3000);
assert_eq!(
l.payload_crc,
last.then_some(want),
"the crc is handed over with the last piece and only then"
);
got.extend_from_slice(p);
lasts += usize::from(last);
Ok(())
})
.unwrap();
assert_eq!(
(got, lasts, at.blob_len),
(blob.clone(), 1, blob.len() as u64)
);
assert_eq!(at.payload_crc, Some(want));
let mut tail = Vec::new();
let at = log
.read_pieces_from::<Error>(s, 4321, 3000, |l, p, last| {
assert!(p.len() <= 3000);
assert_eq!(l.payload_crc, last.then_some(want));
tail.extend_from_slice(p);
Ok(())
})
.unwrap();
assert_eq!(
(tail.as_slice(), at.payload_crc),
(&blob[4321..], Some(want))
);
let mut end = Vec::new();
log.read_pieces_from::<Error>(s, blob.len() as u64, 3000, |l, p, last| {
end.push((p.len(), last, l.payload_crc));
Ok(())
})
.unwrap();
assert_eq!(end, vec![(0, true, Some(want))]);
assert!(log
.read_pieces_from::<Error>(s, blob.len() as u64 + 1, 3000, |_, _, _| Ok(()))
.is_err());
let mut pieces = Vec::new();
log.read_pieces::<Error>(empty, 3000, |_, p, last| {
pieces.push((p.len(), last));
Ok(())
})
.unwrap();
assert_eq!(pieces, vec![(0, true)]);
let l = log.locate_blob(rot).unwrap();
let mut f = OpenOptions::new()
.read(true)
.write(true)
.open(&l.path)
.unwrap();
f.seek(SeekFrom::Start(l.blob_offset + 5000)).unwrap();
let mut b = [0u8; 1];
f.read_exact(&mut b).unwrap();
f.seek(SeekFrom::Start(l.blob_offset + 5000)).unwrap();
f.write_all(&[b[0] ^ 0xFF]).unwrap();
drop(f);
let mut seen = 0usize;
let mut last_seen = false;
let r = log.read_pieces::<Error>(rot, 3000, |_, p, last| {
seen += p.len();
last_seen |= last;
Ok(())
});
assert!(
matches!(
r,
Err(Error::Corrupt {
what: "payload crc",
..
})
),
"{r:?}"
);
assert!(
!last_seen,
"the last piece of a corrupt frame is never handed over"
);
assert!(
seen < blob.len(),
"{seen} of {} bytes went out before the refusal",
blob.len()
);
assert!(
matches!(log.read(rot), Err(Error::Corrupt { .. })),
"read refuses the same frame"
);
log.erase(rot + 1, &["a/b".to_string()]).unwrap();
let mut erased = Vec::new();
log.read_pieces::<Error>(s, 3000, |l, p, last| {
erased.push((l.kind, p.len(), last));
Ok(())
})
.unwrap();
assert_eq!(erased, vec![(Kind::Redacted as u16, 0, true)]);
}
#[test]
fn unknown_kind_is_stored_and_read_back() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
log.append_raw(999, b"{}", b"future").unwrap();
let e = log.read(1).unwrap();
assert_eq!(e.kind(), None);
assert_eq!(e.blob, b"future");
}
#[test]
fn reset_starts_over_at_one_and_refuses_a_box_with_history() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
log.append(Kind::Kek, &Meta::Kek {}, &[7u8; 32]).unwrap();
assert!(log.reset(0).is_err());
assert_eq!(log.head_seq(), 1);
log.reset(1).unwrap();
assert_eq!(log.head_seq(), 0);
assert_eq!(log.first_seq(), 1);
log.append_at(1, Kind::Kek as u16, &Meta::Kek {}.to_bytes(), &[9u8; 32])
.unwrap();
drop(log);
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head_seq(), 1);
assert_eq!(log.read(1).unwrap().blob, vec![9u8; 32]);
}
#[test]
fn kek_append_and_file_kinds_round_trip() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
let metas = [
(Kind::Kek, Meta::Kek {}),
(
Kind::Append,
Meta::Append {
log: "attestations.jsonl".into(),
},
),
(
Kind::File,
Meta::File {
path: "replication.json".into(),
deleted: false,
},
),
];
for (k, m) in &metas {
log.append(*k, m, b"x").unwrap();
}
for (i, (k, m)) in metas.iter().enumerate() {
let e = log.read(i as u64 + 1).unwrap();
assert_eq!(e.kind(), Some(*k));
assert_eq!(&e.meta().unwrap(), m);
}
}
#[test]
fn a_follower_log_refuses_originations_and_takes_the_primarys_numbers() {
let dir = tempfile::tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
log.set_follower(true);
assert!(matches!(
log.append(Kind::Pack, &meta("a/b"), b"x"),
Err(Error::Follower)
));
assert!(matches!(
log.begin(None, Kind::Pack as u16, b"{}", 1),
Err(Error::Follower)
));
log.append_at(1, Kind::Pack as u16, &meta("a/b").to_bytes(), b"x")
.unwrap();
assert_eq!(log.head_seq(), 1);
log.set_follower(false);
assert_eq!(log.append(Kind::Pack, &meta("a/b"), b"y").unwrap(), 2);
}
#[test]
fn cache_and_per_box_paths_are_judged_by_their_place_in_the_serialdat_tree() {
for p in [
"gc/x",
".serialdat/gc/x",
"serialdat/quarantine/p.pack",
".serialdat/meta/identity.json",
"repos/a/b.git/objects.exploded/1",
".korp-disktest/probe",
"webhooks.json.tmp",
] {
assert!(is_cache_path(p) && never_framed(p), "{p} is cache");
}
for p in [
"crash/panic-1",
".serialdat/log/console.txt",
"log/audit-chain.local.jsonl",
".serialdat/log/audit-chain.local.jsonl",
"twin.key",
] {
assert!(
!is_cache_path(p) && is_per_box_path(p) && never_framed(p),
"{p} is per box"
);
}
for p in [
"attestations.jsonl",
"replication.json",
"repos/a/b.git/pack",
"control.redb",
] {
assert!(!never_framed(p), "{p} is a transaction's path");
}
}
#[test]
fn the_audit_trail_is_framed_and_the_twins_own_trail_is_not() {
for p in [
"log/audit-chain.jsonl",
".serialdat/log/audit-chain.jsonl",
"serialdat/log/audit-chain.jsonl",
"./log/audit-chain.jsonl",
] {
assert!(
!is_per_box_path(p),
"{p} is the audit trail and travels with the log"
);
assert!(
!never_framed(p),
"{p} must be framable, or a restore loses the audit trail"
);
}
assert!(
FRAMED_FILES.contains(&AUDIT_REL),
"a snapshot must carry the audit trail"
);
assert!(is_per_box_path(AUDIT_LOCAL_REL) && never_framed(AUDIT_LOCAL_REL));
assert!(!FRAMED_FILES.contains(&AUDIT_LOCAL_REL));
}
#[test]
fn the_hub_declaration_travels_and_the_machines_own_identity_never_does() {
for p in [HUB_CONF_REL, ".serialdat/wg/hub.conf", "./wg/hub.conf"] {
assert!(
!never_framed(p),
"{p} must travel: a restored main without it is unreachable"
);
}
assert!(
FRAMED_FILES.contains(&HUB_CONF_REL),
"a snapshot must carry the hub declaration"
);
for p in [
WG_IDENTITY_KEY_REL,
WG_IDENTITY_PUB_REL,
HUB_CONF_FRAMED_REL,
] {
assert!(
is_per_box_path(p) && never_framed(p),
"{p} is this machine's own and never travels"
);
assert!(!FRAMED_FILES.contains(&p), "{p} must not be in a snapshot");
}
}
#[test]
#[cfg(target_os = "linux")]
fn a_released_log_holds_nothing_under_its_dir_refuses_writes_and_resumes_after_reacquire() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("txlog");
let log = Log::open(&dir).unwrap();
let holds = |dir: &Path| -> Vec<PathBuf> {
fs::read_dir("/proc/self/fd")
.unwrap()
.filter_map(|e| fs::read_link(e.ok()?.path()).ok())
.filter(|p| p.starts_with(dir))
.collect()
};
log.append(Kind::Kek, &Meta::Kek {}, b"one").unwrap();
log.append(Kind::Kek, &Meta::Kek {}, b"two").unwrap();
assert!(!holds(&dir).is_empty(), "the writer holds the open segment");
log.release().unwrap();
log.release().unwrap();
assert!(log.is_released());
assert_eq!(
holds(&dir),
Vec::<PathBuf>::new(),
"released: nothing under the dir"
);
assert!(matches!(
log.append(Kind::Kek, &Meta::Kek {}, b"no"),
Err(Error::Released(_))
));
assert!(matches!(
log.begin(None, 1, b"", 1),
Err(Error::Released(_))
));
assert_eq!(log.read(2).unwrap().blob, b"two", "readers open per call");
log.reacquire().unwrap();
log.reacquire().unwrap();
assert_eq!(log.append(Kind::Kek, &Meta::Kek {}, b"three").unwrap(), 3);
drop(log);
let back = Log::open(&dir).unwrap();
assert_eq!(back.head_seq(), 3);
assert_eq!(back.read(3).unwrap().blob, b"three");
}
#[test]
fn reacquire_refuses_a_segment_that_changed_length() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("txlog");
let log = Log::open(&dir).unwrap();
log.append(Kind::Kek, &Meta::Kek {}, b"one").unwrap();
log.release().unwrap();
OpenOptions::new()
.append(true)
.open(seg_path(&dir, 1))
.unwrap()
.write_all(b"junk")
.unwrap();
assert!(matches!(log.reacquire(), Err(Error::Corrupt { .. })));
assert!(log.is_released(), "still refusing writes");
}
#[test]
fn the_sink_frames_writes_under_a_registered_root_and_nothing_elsewhere() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let log = Arc::new(Log::open(root.path().join("txlog")).unwrap());
sink::register(root.path(), &log);
drop(sink::append_line(&root.path().join("attestations.jsonl"), "{\"a\":1}").unwrap());
drop(sink::replace_file(&root.path().join("webhooks.json"), b"[]", false).unwrap());
drop(sink::append_line(&other.path().join("attestations.jsonl"), "{}").unwrap());
assert_eq!(log.head_seq(), 2);
assert_eq!(
log.read(1).unwrap().meta().unwrap(),
Meta::Append {
log: "attestations.jsonl".into()
}
);
assert_eq!(
log.read(2).unwrap().meta().unwrap(),
Meta::File {
path: "webhooks.json".into(),
deleted: false
}
);
drop(log);
drop(sink::append_line(&root.path().join("attestations.jsonl"), "{}").unwrap());
}
#[test]
fn restart_after_leaves_an_empty_log_whose_next_number_follows_the_snapshot_and_survives_a_reopen(
) {
let dir = tempfile::tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
log.append(Kind::Pack, &meta("a/b"), b"x").unwrap();
log.restart_after(41).unwrap();
assert_eq!((log.first_seq(), log.head_seq()), (42, 41));
assert!(matches!(
log.append_at(43, Kind::Pack as u16, b"{}", b""),
Err(Error::OutOfOrder { .. })
));
assert_eq!(
log.append_at(42, Kind::Pack as u16, &meta("a/b").to_bytes(), b"y")
.unwrap(),
42
);
}
let log = Log::open(dir.path()).unwrap();
assert_eq!((log.first_seq(), log.head_seq()), (42, 42));
assert_eq!(log.read(42).unwrap().blob, b"y");
assert!(matches!(log.read(1), Err(Error::NotRetained { .. })));
}
#[test]
fn a_freeze_waits_for_a_mutation_in_flight_and_holds_new_ones_until_it_ends() {
use std::sync::atomic::AtomicU32;
let dir = tempfile::tempdir().unwrap();
let log = Arc::new(Log::open(dir.path()).unwrap());
let stage = Arc::new(AtomicU32::new(0));
let (l, s) = (Arc::clone(&log), Arc::clone(&stage));
let (go_tx, go_rx) = std::sync::mpsc::channel::<()>();
let (in_tx, in_rx) = std::sync::mpsc::channel::<()>();
let writer = std::thread::spawn(move || {
let g = l.mutation();
l.append(Kind::Pack, &meta("a/b"), b"1").unwrap();
in_tx.send(()).unwrap();
go_rx.recv().unwrap();
s.store(1, Ordering::SeqCst); drop(g);
});
in_rx.recv().unwrap();
let (l, s) = (Arc::clone(&log), Arc::clone(&stage));
let (frozen_tx, frozen_rx) = std::sync::mpsc::channel::<()>();
let freezer = std::thread::spawn(move || {
let f = l.freeze();
frozen_tx.send(()).unwrap();
assert_eq!(
s.load(Ordering::SeqCst),
1,
"freeze returned before the mutation in flight ended"
);
let head = l.head_seq();
std::thread::sleep(std::time::Duration::from_millis(50));
assert_eq!(l.head_seq(), head, "no mutation started while frozen");
s.store(2, Ordering::SeqCst);
drop(f);
});
std::thread::sleep(std::time::Duration::from_millis(30));
go_tx.send(()).unwrap();
writer.join().unwrap();
frozen_rx.recv().unwrap();
let g = log.mutation();
assert_eq!(
stage.load(Ordering::SeqCst),
2,
"a mutation started while the log was frozen"
);
log.append(Kind::Pack, &meta("a/b"), b"2").unwrap();
drop(g);
freezer.join().unwrap();
let outer = log.mutation();
let inner = log.mutation();
drop(inner);
drop(outer);
let f = log.freeze();
let own = log.mutation();
drop(own);
drop(f);
}
}
#[cfg(test)]
mod spool_tests {
use super::*;
#[test]
fn the_lfs_spool_directory_is_part_of_the_layout_and_does_not_refuse_the_log() {
let dir = tempfile::tempdir().expect("temp dir");
let log = Log::open(dir.path()).expect("a fresh log opens");
drop(log);
std::fs::create_dir_all(dir.path().join(SPOOL_DIR)).expect("the spool dir");
Log::open(dir.path()).expect("a log whose spool directory exists still opens");
}
#[test]
fn a_stray_entry_is_still_refused_by_name() {
let dir = tempfile::tempdir().expect("temp dir");
Log::open(dir.path()).expect("a fresh log opens");
std::fs::write(dir.path().join("stray"), b"x").expect("write");
let err = Log::open(dir.path()).expect_err("a stray file is not part of the layout");
assert!(matches!(err, Error::Unexpected(_)), "{err}");
}
}