use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use znippy_plugin_git::archive_write::{acked_packs, read_journal};
use znippy_plugin_git::{
Caps, GitHashKind, GitObjectKind, GitOps, GitStore, WriterArm, canonical,
};
const PER_WRITER: usize = 24;
const CHILD: &str = "AN_P014_CHILD";
fn one_blob_pack(body: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut pack = b"PACK".to_vec();
pack.extend_from_slice(&2u32.to_be_bytes());
pack.extend_from_slice(&1u32.to_be_bytes());
let mut size = body.len() as u64;
let mut header = vec![(3u8 << 4) | (size as u8 & 0x0f)];
size >>= 4;
while size > 0 {
let last = header.len() - 1;
header[last] |= 0x80;
header.push((size & 0x7f) as u8);
size >>= 7;
}
pack.extend_from_slice(&header);
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(body).unwrap();
pack.extend_from_slice(&e.finish().unwrap());
pack.extend_from_slice(&[0u8; 20]);
let oid = GitHashKind::Sha1.oid_of(&canonical(GitObjectKind::Blob, body));
(pack, oid)
}
fn body_of(tag: &str, i: usize) -> Vec<u8> {
format!("P-014 writer {tag} object {i} {}", "z".repeat(64 + i * 13)).into_bytes()
}
fn workload(tag: &str, n: usize) -> Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> {
(0..n)
.map(|i| {
let b = body_of(tag, i);
let (p, o) = one_blob_pack(&b);
(p, o, b)
})
.collect()
}
#[derive(Clone, Debug)]
struct Acked {
oid: Vec<u8>,
body: Vec<u8>,
pack: Vec<u8>,
offset: u64,
len: u64,
}
fn write_receipt(path: &Path, acked: &[Acked]) {
let mut s = String::new();
for a in acked {
s.push_str(&format!("{} {} {}\n", hex::encode(&a.oid), a.offset, a.len));
}
std::fs::write(path, s).unwrap();
}
fn read_receipt(path: &Path, tags: &[&str]) -> Vec<Acked> {
let mut by_oid: BTreeMap<Vec<u8>, (Vec<u8>, Vec<u8>)> = BTreeMap::new();
for t in tags {
for (p, o, b) in workload(t, PER_WRITER) {
by_oid.insert(o, (p, b));
}
}
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let mut f = l.split_whitespace();
let oid = hex::decode(f.next().unwrap()).unwrap();
let offset: u64 = f.next().unwrap().parse().unwrap();
let len: u64 = f.next().unwrap().parse().unwrap();
let (pack, body) = by_oid
.get(&oid)
.unwrap_or_else(|| panic!("a writer acked an oid no workload produced"))
.clone();
Acked {
oid,
body,
pack,
offset,
len,
}
})
.collect()
}
fn audit(root: &Path, acked: &[Acked]) {
assert!(
!acked.is_empty(),
"no writer acked anything — this test proves nothing unless at least one \
of the two got through"
);
let blobs = root.join("objects.pack");
let on_disk = std::fs::read(&blobs).expect("objects.pack");
let journal = read_journal(&root.join("objects.pack.journal")).expect("journal");
let claimed = acked_packs(&journal);
assert_eq!(
claimed.len(),
acked.len(),
"the journal claims {} packs but {} pushes were acked — an acked push is \
missing from the durable record",
claimed.len(),
acked.len()
);
let mut sorted = claimed.clone();
sorted.sort_unstable();
for w in sorted.windows(2) {
let (a, b) = (w[0], w[1]);
assert!(
a.0 + a.1 <= b.0,
"two acked packs share bytes: ({}, {}) overlaps ({}, {}). Both writers \
were told their push was durable and they wrote on top of each other.",
a.0,
a.1,
b.0,
b.1
);
}
if let Some(&(o, l)) = sorted.last() {
assert!(
o + l <= on_disk.len() as u64,
"the journal claims bytes {}..{} but objects.pack is only {} B — an acked \
extent points past the end of the archive",
o,
o + l,
on_disk.len()
);
}
let mut lost = Vec::new();
for a in acked {
let end = (a.offset + a.len) as usize;
if end > on_disk.len() || &on_disk[a.offset as usize..end] != &a.pack[..] {
lost.push(hex::encode(&a.oid));
}
}
assert!(
lost.is_empty(),
"{} of {} acked packs are NOT at the extent they were acked at — the bytes \
were overwritten by the other writer and nothing errored. Lost oids: {lost:?}",
lost.len(),
acked.len()
);
let store = GitStore::open(root, "rickard").expect("reopen the store over the result");
store.wait_indexed();
store.absorb_pending().expect("absorb");
let mut unreadable = Vec::new();
for a in acked {
match store.content(&a.oid) {
Ok(Some((GitObjectKind::Blob, got))) if got == a.body => {}
other => unreadable.push((hex::encode(&a.oid), format!("{other:?}").len())),
}
let row = store
.get(&a.oid)
.expect("get")
.unwrap_or_else(|| panic!("{} is not in the index", hex::encode(&a.oid)));
let (ro, rl) = row.extent;
assert!(
ro >= a.offset && ro + rl <= a.offset + a.len,
"the index puts {} at ({ro}, {rl}), outside the ({}, {}) the journal \
claims for its pack — the index and the pack disagree",
hex::encode(&a.oid),
a.offset,
a.len
);
}
assert!(
unreadable.is_empty(),
"{} of {} acked objects do not read back as the body they were pushed with: {:?}",
unreadable.len(),
acked.len(),
unreadable.iter().map(|(o, _)| o).collect::<Vec<_>>()
);
let oids: Vec<&[u8]> = acked.iter().map(|a| a.oid.as_slice()).collect();
let mut emitted = Vec::new();
store
.emit_oids(&oids, &[], &Caps::modern(), &mut emitted)
.expect("emit a pack of everything that survived");
fsck_strict(root, &emitted, acked.len());
}
fn fsck_strict(root: &Path, pack: &[u8], objects: usize) {
let scratch = root.join("fsck");
std::fs::create_dir_all(&scratch).unwrap();
znippy_plugin_git::git_oracle::assert_git_accepts(
&scratch,
"surviving.git",
pack,
znippy_plugin_git::git_oracle::Strictness::Connected,
);
let repo = scratch.join("repo");
std::fs::create_dir_all(&repo).unwrap();
let init = Command::new("git")
.args(["init", "-q", "--bare", "."])
.current_dir(&repo)
.output()
.expect("git init");
assert!(init.status.success());
let mut unpack = Command::new("git")
.args(["unpack-objects", "-q"])
.current_dir(&repo)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("git unpack-objects");
unpack.stdin.take().unwrap().write_all(pack).unwrap();
let up = unpack.wait_with_output().unwrap();
assert!(
up.status.success(),
"git unpack-objects refused the surviving archive:\n{}",
String::from_utf8_lossy(&up.stderr)
);
let loose = count_loose(&repo.join("objects"));
assert_eq!(
loose, objects,
"git unpacked {loose} objects out of an archive that acked {objects}"
);
let fsck = Command::new("git")
.args(["fsck", "--strict", "--no-progress"])
.current_dir(&repo)
.output()
.expect("git fsck");
let stderr = String::from_utf8_lossy(&fsck.stderr);
assert!(
fsck.status.success(),
"git fsck --strict is not clean:\n{stderr}"
);
for bad in ["error", "corrupt", "missing", "broken"] {
assert!(
!stderr.to_lowercase().contains(bad),
"git fsck --strict reported `{bad}`:\n{stderr}"
);
}
}
fn count_loose(objects: &Path) -> usize {
let mut n = 0;
let Ok(rd) = std::fs::read_dir(objects) else {
return 0;
};
for e in rd.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if name.len() == 2 && name.chars().all(|c| c.is_ascii_hexdigit()) {
n += std::fs::read_dir(e.path()).map(|d| d.flatten().count()).unwrap_or(0);
}
}
n
}
fn await_path(p: &Path, what: &str) {
let deadline = Instant::now() + Duration::from_secs(120);
while !p.exists() {
assert!(
Instant::now() < deadline,
"{what} never appeared at {} — the peer writer never reached the \
rendezvous, so nothing was made concurrent and this test proves nothing",
p.display()
);
std::thread::yield_now();
}
}
fn tmproot(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"p014-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn p014_child_writer() {
let Ok(spec) = std::env::var(CHILD) else {
return; };
let f: Vec<&str> = spec.split('|').collect();
let (root, layer, arm, tag) = (Path::new(f[0]), f[1], f[2], f[3]);
let ready = root.join(format!("ready.{tag}"));
let receipt = root.join(format!("receipt.{tag}"));
let go = root.join("go");
let work = workload(tag, PER_WRITER);
let mut acked: Vec<Acked> = Vec::new();
let opened: Result<Box<dyn FnMut(&[u8]) -> anyhow::Result<(u64, u64)>>, String> = match layer {
"writer" => {
let armv = WriterArm::parse(arm).unwrap();
match armv.create(&root.join("objects.pack")) {
Ok(w) => Ok(Box::new(move |b: &[u8]| w.append(b))),
Err(e) => Err(format!("{e:#}")),
}
}
"store" => match GitStore::open(root, "rickard") {
Ok(s) => Ok(Box::new(move |b: &[u8]| {
let tx = s.put(b, &[])?;
Ok(tx.extent.expect("a pack push records its extent"))
})),
Err(e) => Err(format!("{e:#}")),
},
l => panic!("unknown layer {l}"),
};
let refusal = root.join(format!("refused.{tag}"));
match opened {
Err(ref why) => std::fs::write(&refusal, why).unwrap(),
Ok(_) => {}
}
std::fs::write(&ready, b"").unwrap();
await_path(&go, "the parent's go");
if let Ok(mut append) = opened {
for (pack, oid, body) in &work {
match append(pack) {
Ok((offset, len)) => acked.push(Acked {
oid: oid.clone(),
body: body.clone(),
pack: pack.clone(),
offset,
len,
}),
Err(_) => break,
}
}
}
write_receipt(&receipt, &acked);
std::fs::write(root.join(format!("done.{tag}")), b"").unwrap();
}
fn drive_two(root: &Path, layer: &str, arm: &str) -> Vec<Acked> {
let exe = std::env::current_exe().expect("current_exe");
let mut kids = Vec::new();
for tag in ["A", "B"] {
let child = Command::new(&exe)
.args(["--exact", "p014_child_writer", "--nocapture"])
.env(
CHILD,
format!("{}|{layer}|{arm}|{tag}", root.display()),
)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn the peer writer");
await_path(&root.join(format!("ready.{tag}")), "a writer's handle");
kids.push(child);
}
std::fs::write(root.join("go"), b"").unwrap();
for (i, k) in kids.into_iter().enumerate() {
let out = k.wait_with_output().expect("peer writer");
assert!(
out.status.success(),
"peer writer {} died: {}",
["A", "B"][i],
String::from_utf8_lossy(&out.stderr)
);
}
let mut acked = read_receipt(&root.join("receipt.A"), &["A", "B"]);
acked.extend(read_receipt(&root.join("receipt.B"), &["A", "B"]));
acked
}
fn refusal(root: &Path, tag: &str) -> Option<String> {
std::fs::read_to_string(root.join(format!("refused.{tag}"))).ok()
}
#[test]
fn two_processes_appending_to_one_objects_pack_are_refused() {
for arm in ["fast", "safe"] {
let root = tmproot(&format!("proc-{arm}"));
let acked = drive_two(&root, "writer", arm);
if arm == "safe" {
audit(&root, &acked);
} else {
let on_disk = std::fs::read(root.join("objects.pack")).unwrap();
let mut lost = Vec::new();
for a in &acked {
let end = (a.offset + a.len) as usize;
if end > on_disk.len() || on_disk[a.offset as usize..end] != a.pack[..] {
lost.push(hex::encode(&a.oid));
}
}
assert!(
lost.is_empty(),
"[{arm}] {} of {} acked packs are NOT at the extent they were acked \
at — the bytes were overwritten by the other writer and nothing \
errored. Lost oids: {lost:?}",
lost.len(),
acked.len()
);
}
let refused: Vec<&str> = ["A", "B"]
.into_iter()
.filter(|t| refusal(&root, t).is_some())
.collect();
assert_eq!(
refused.len(),
1,
"[{arm}] exactly one of the two writers must be refused the archive; \
{} were. Refusals: {:?}",
refused.len(),
["A", "B"].map(|t| refusal(&root, t))
);
let why = refusal(&root, refused[0]).unwrap();
assert!(
why.contains("objects.pack"),
"[{arm}] the refusal must name the blob file it is protecting, or the \
archive is only as safe as whatever else happened to be locked: {why}"
);
assert_eq!(
acked.len(),
PER_WRITER,
"[{arm}] exactly one writer's whole workload must have been acked"
);
}
}
#[test]
fn two_processes_opening_one_store_root_are_refused_by_the_writer_lock() {
let root = tmproot("proc-store");
let acked = drive_two(&root, "store", "safe");
let refused: Vec<&str> = ["A", "B"]
.into_iter()
.filter(|t| refusal(&root, t).is_some())
.collect();
assert_eq!(refused.len(), 1, "exactly one store open must be refused");
let why = refusal(&root, refused[0]).unwrap();
assert!(
why.contains("objects.pack"),
"the second open was refused by something other than the writer lock — \
this store is protected by an accident that a change to the tail would \
remove: {why}"
);
assert_eq!(acked.len(), PER_WRITER);
audit(&root, &acked);
}
#[test]
fn two_concurrent_pushes_on_one_store_handle_keep_every_object() {
let root = tmproot("threads");
let store = GitStore::open(&root, "rickard").unwrap();
let work: Vec<Vec<(Vec<u8>, Vec<u8>, Vec<u8>)>> = ["T0", "T1"]
.iter()
.map(|t| workload(t, PER_WRITER))
.collect();
let barrier = std::sync::Barrier::new(2);
let acked = std::sync::Mutex::new(Vec::<Acked>::new());
std::thread::scope(|s| {
for w in &work {
let (store, barrier, acked) = (&store, &barrier, &acked);
s.spawn(move || {
barrier.wait();
for (pack, oid, body) in w {
let tx = store
.put(pack, &[])
.expect("a concurrent push must not be refused inside one process");
let (offset, len) = tx.extent.expect("a pack push records its extent");
acked.lock().unwrap().push(Acked {
oid: oid.clone(),
body: body.clone(),
pack: pack.clone(),
offset,
len,
});
}
});
}
});
store.wait_indexed();
store.absorb_pending().unwrap();
let acked = acked.into_inner().unwrap();
assert_eq!(
acked.len(),
2 * PER_WRITER,
"both threads must have been served — this is not a place to serialise"
);
drop(store);
audit(&root, &acked);
}