use std::path::Path;
use crate::chunk_index_inplace::alloc_probe;
use crate::edit::{AppendBuilder, AppendTarget, MemoryStrategy, SyncPolicy, WriteEngine};
use crate::error::{Error, FormatError};
use crate::file_lock::FileLocking;
use crate::file_space_info::FileSpaceStrategy;
use crate::image::disk_log::{self, DiskOp};
use crate::source::MetadataCacheConfig;
use crate::type_builders::DatasetBuilder;
use crate::writer::FileBuilder;
enum Verdict {
Clean,
Loud(String),
Silent(String),
}
impl Verdict {
fn of<T>(read: Result<T, Error>, judge: impl FnOnce(T) -> Result<(), String>) -> Verdict {
match read {
Err(e) => Verdict::Loud(std::format!("{e:?}")),
Ok(v) => match judge(v) {
Ok(()) => Verdict::Clean,
Err(why) => Verdict::Silent(why),
},
}
}
}
struct Recording {
label: String,
base: Vec<u8>,
real: std::path::PathBuf,
ops: Vec<DiskOp>,
data_blocks: usize,
super_blocks: usize,
}
impl Recording {
fn of(label: &str, path: &Path, work: impl FnOnce(&Path)) -> Self {
let base = std::fs::read(path).expect("the starting file");
alloc_probe::take();
disk_log::start();
work(path);
let ops = disk_log::take();
let (data_blocks, super_blocks) = alloc_probe::take();
if std::env::var_os("CRASH_REPLAY_OPS").is_some() {
for (i, op) in ops.iter().enumerate() {
std::eprintln!("OP {i} {}", op.describe());
}
}
assert!(
!ops.is_empty(),
"{label}: recorded nothing, so there is no crash point to replay"
);
Self {
label: label.to_string(),
base,
real: path.to_path_buf(),
ops,
data_blocks,
super_blocks,
}
}
fn assert_positioned(&self, data_blocks: usize, super_blocks: usize) {
assert!(
self.data_blocks >= data_blocks && self.super_blocks >= super_blocks,
"{}: the recorded window allocated {} data block(s) and {} super block(s), \
short of the {data_blocks} and {super_blocks} it is positioned for. \
Nothing below can see a barrier that is not crossed.",
self.label,
self.data_blocks,
self.super_blocks
);
let pages = std::fs::metadata(&self.real).unwrap().len() / GATHER_PAGE;
assert!(
pages >= MIN_PAGES,
"{}: the file spans {pages} gather pages of {GATHER_PAGE} bytes, under the \
{MIN_PAGES} this needs. Below that, a publish point near the front of the \
file and the block it names at end-of-file merge into one gathered write, \
which is atomic, and no replayed prefix can fall between them.",
self.label
);
}
fn assert_publishes_across_a_gather_page(&self) {
let crossing = self.ops.iter().any(|op| match *op {
DiskOp::Write { offset, ref bytes } => {
offset < GATHER_PAGE && offset + bytes.len() as u64 > GATHER_PAGE
}
DiskOp::SetLen(_) => false,
});
assert!(
crossing,
"{}: no write starts inside the first {GATHER_PAGE}-byte gather page and \
reaches past it, so the header this sweep publishes into fits in one page \
and every publish is atomic whatever the engine does. The fixture needs a \
wider object header.",
self.label
);
}
fn apply(image: &mut Vec<u8>, op: &DiskOp) {
match op {
DiskOp::Write { offset, bytes } => {
let start = *offset as usize;
let end = start + bytes.len();
if image.len() < end {
image.resize(end, 0);
}
image[start..end].copy_from_slice(bytes);
}
DiskOp::SetLen(len) => image.resize(*len as usize, 0),
}
}
fn replay_every_prefix(
&self,
dir: &Path,
check: impl Fn(&Path) -> Verdict,
finished: impl Fn(&Path) -> Result<(), String>,
) -> Tally {
let path = dir.join(std::format!("{}.replay.h5", self.label));
let mut image = self.base.clone();
let mut tally = Tally {
total: self.ops.len() + 1,
clean: 0,
loud: Vec::new(),
silent: Vec::new(),
};
for k in 0..=self.ops.len() {
if k > 0 {
Self::apply(&mut image, &self.ops[k - 1]);
}
std::fs::write(&path, &image).unwrap();
if k == self.ops.len() {
assert_eq!(
image,
std::fs::read(&self.real).unwrap(),
"{}: replaying every recorded operation does not reproduce the \
file the session left, so the log is not everything that \
reached the disk",
self.label
);
if let Err(why) = finished(&path) {
panic!(
"{}: the completed workload did not reach the state this \
sweep is judging interruptions of: {why}",
self.label
);
}
}
let after = if k == 0 {
"the starting file".to_string()
} else {
self.ops[k - 1].describe()
};
match check(&path) {
Verdict::Clean => tally.clean += 1,
Verdict::Loud(why) => tally
.loud
.push(std::format!("after op {k} ({after}): {why}")),
Verdict::Silent(why) => tally
.silent
.push(std::format!("after op {k} ({after}): {why}")),
}
}
std::fs::remove_file(&path).ok();
if std::env::var_os("CRASH_REPLAY_STATS").is_some() {
std::eprintln!(
"{}: {} prefixes, {} clean, {} loud, {} silent",
self.label,
tally.total,
tally.clean,
tally.loud.len(),
tally.silent.len()
);
}
tally.assert_sound(&self.label);
tally
}
}
struct Tally {
total: usize,
clean: usize,
loud: Vec<String>,
silent: Vec<String>,
}
impl Tally {
fn assert_sound(&self, label: &str) {
assert!(
self.silent.is_empty(),
"{label}: {} of {} replayed prefixes read cleanly and returned the wrong data:\n {}",
self.silent.len(),
self.total,
self.silent.join("\n ")
);
assert!(
self.loud.is_empty(),
"{label}: {} of {} replayed prefixes refused to read, though every one \
of them is a crash during an operation that leaves the previous value \
in place and readable:\n {}",
self.loud.len(),
self.total,
self.loud.join("\n ")
);
}
}
const GATHER_PAGE: u64 = crate::file_space_info::DEFAULT_PAGE_SIZE;
const MIN_PAGES: u64 = 8;
const CHUNK: u64 = 64;
const ROUND: i32 = CHUNK as i32;
const WARMUP_ROUNDS: i32 = 230;
const ROUNDS: i32 = 90;
const WARMUP: i32 = WARMUP_ROUNDS * ROUND;
fn warmed_base(path: &Path, paged: bool) {
warmed_base_padded(path, paged, 0);
}
fn warmed_base_padded(path: &Path, paged: bool, pad: usize) {
let mut b = FileBuilder::new();
if paged {
b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
.with_file_space_page_size(4096);
}
{
let d = b.create_dataset("d");
d.with_i32_data(&[0i32])
.with_shape(&[1])
.with_maxshape(&[u64::MAX])
.with_chunks(&[CHUNK]);
if pad > 0 {
d.set_attr(
"pad",
crate::type_builders::AttrValue::AsciiString("x".repeat(pad)),
);
}
}
b.write(path).unwrap();
let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
let mut n = 1i32;
while n < WARMUP {
let take = ROUND.min(WARMUP - n);
append(&mut s, n, take);
n += take;
}
drop(s);
}
fn append(s: &mut WriteEngine, from: i32, count: i32) {
let mut b = AppendBuilder::new();
b.append_i32(&(from..from + count).collect::<Vec<_>>());
s.append_inplace_gathered(AppendTarget::Path("d"), &b, 4)
.unwrap();
}
fn appended_prefix_is_intact(path: &Path, lo: i32, hi: i32) -> Verdict {
if let Err(why) = recorded_eof_covers_the_file(path) {
return Verdict::Silent(why);
}
let read = crate::reader::File::open(path).and_then(|f| f.dataset("d")?.read_i32());
Verdict::of(read, |data| {
let n = data.len() as i32;
if !(lo..=hi).contains(&n) {
return Err(std::format!(
"length {n} is outside the {lo}..={hi} this crash point can produce"
));
}
if let Some(i) = (0..data.len()).find(|&i| data[i] != i as i32) {
return Err(std::format!(
"element {i} is {} rather than {i} (length {n})",
data[i]
));
}
Ok(())
})
}
fn recorded_eof_covers_the_file(path: &Path) -> Result<(), String> {
let bytes = std::fs::read(path).map_err(|e| std::format!("reading the file back: {e}"))?;
let Ok(sb) = crate::superblock::Superblock::parse(&bytes, 0) else {
return Ok(());
};
if sb.eof_address > bytes.len() as u64 {
return Err(std::format!(
"the superblock records end-of-file at {} but the file is {} bytes: \
every byte between is one a later session would allocate from and \
never find",
sb.eof_address,
bytes.len()
));
}
Ok(())
}
fn appended_all_the_way(path: &Path, hi: i32) -> Result<(), String> {
let data = crate::reader::File::open(path)
.and_then(|f| f.dataset("d")?.read_i32())
.map_err(|e| std::format!("the finished file does not read: {e:?}"))?;
if data.len() as i32 != hi {
return Err(std::format!(
"it holds {} elements rather than the {hi} the workload appended",
data.len()
));
}
Ok(())
}
#[test]
fn appending_to_a_paged_file_survives_a_crash_at_every_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("append_paged.h5");
warmed_base(&path, true);
let rec = Recording::of("append-paged", &path, |p| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
for r in 0..ROUNDS {
append(&mut s, WARMUP + r * ROUND, ROUND);
}
s.finalize_persist().unwrap();
drop(s);
});
rec.assert_positioned(2, 1);
let hi = WARMUP + ROUNDS * ROUND;
rec.replay_every_prefix(
dir.path(),
|p| appended_prefix_is_intact(p, WARMUP, hi),
|p| appended_all_the_way(p, hi),
);
}
#[test]
fn a_crashed_append_can_be_reopened_and_appended_to() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("recover.h5");
warmed_base(&path, false);
const RECOVER_ROUNDS: i32 = ROUNDS;
const RECOVER_CHUNKS: i32 = 40;
const RECOVER_APPEND: i32 = RECOVER_CHUNKS * ROUND;
let rec = Recording::of("recover", &path, |p| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
for r in 0..RECOVER_ROUNDS {
append(&mut s, WARMUP + r * ROUND, ROUND);
}
drop(s);
});
rec.assert_positioned(2, 1);
let hi = WARMUP + RECOVER_ROUNDS * ROUND;
rec.replay_every_prefix(
dir.path(),
|p| {
match appended_prefix_is_intact(p, WARMUP, hi) {
Verdict::Clean => {}
other => return other,
}
let before = crate::reader::File::open(p)
.and_then(|f| f.dataset("d")?.read_i32())
.expect("just read it")
.len() as i32;
let reopened = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled)?;
s.set_sync_policy(SyncPolicy::OnClose);
let mut b = AppendBuilder::new();
b.append_i32(&(before..before + RECOVER_APPEND).collect::<Vec<_>>());
s.append_inplace_gathered(AppendTarget::Path("d"), &b, 4)?;
drop(s);
Ok::<(), Error>(())
}));
match reopened {
Ok(Ok(())) => {}
Ok(Err(e)) => {
return Verdict::Silent(std::format!(
"reads cleanly at {before} elements, but reopening and appending fails: {e:?}"
));
}
Err(panic) => {
let why = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("(non-string panic)")
.to_string();
return Verdict::Silent(std::format!(
"reads cleanly at {before} elements, but reopening and appending panics: {why}"
));
}
}
appended_prefix_is_intact(p, before + RECOVER_APPEND, before + RECOVER_APPEND)
},
|p| appended_all_the_way(p, hi),
);
}
const CHURN: i32 = 6;
const COMMIT_BASE: i32 = 4096;
const COMMIT_STEP: i32 = 1024;
fn added_values(r: i32) -> Vec<i32> {
(0..2048 + r).map(|i| i * 7 + r).collect()
}
fn doomed_values(r: i32) -> Vec<i32> {
(0..1024 + r).map(|i| i * 3 + r).collect()
}
fn slot_values(g: i32) -> Vec<i32> {
(0..512 + 37 * g).map(|i| i * 11 + g).collect()
}
fn commit_base(path: &Path, paged: bool) {
let mut b = FileBuilder::new();
if paged {
b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
.with_file_space_page_size(4096);
}
b.create_dataset("d")
.with_i32_data(&(0..COMMIT_BASE).collect::<Vec<i32>>())
.with_shape(&[COMMIT_BASE as u64])
.with_maxshape(&[u64::MAX])
.with_chunks(&[COMMIT_STEP as u64]);
for r in 0..CHURN {
let v = doomed_values(r);
b.create_dataset(&std::format!("doomed{r}"))
.with_i32_data(&v)
.with_shape(&[v.len() as u64]);
}
let v = slot_values(0);
b.create_dataset("slot")
.with_i32_data(&v)
.with_shape(&[v.len() as u64]);
b.write(path).unwrap();
}
fn read_optional(f: &crate::reader::File, path: &str) -> Result<Option<Vec<i32>>, Error> {
match f.dataset(path) {
Ok(ds) => ds.read_i32().map(Some),
Err(Error::Format(FormatError::PathNotFound { .. })) => Ok(None),
Err(e) => Err(e),
}
}
type CommitState = (
Vec<i32>,
Vec<Option<Vec<i32>>>,
Vec<Option<Vec<i32>>>,
Vec<i32>,
);
fn commit_state_is_one_or_the_other(path: &Path) -> Verdict {
let read = (|| -> Result<CommitState, Error> {
let f = crate::reader::File::open(path)?;
let d = f.dataset("d")?.read_i32()?;
let mut added = Vec::new();
let mut doomed = Vec::new();
for r in 0..CHURN {
added.push(read_optional(&f, &std::format!("added{r}"))?);
doomed.push(read_optional(&f, &std::format!("doomed{r}"))?);
}
let slot = match f.dataset("slot") {
Ok(ds) => ds.read_i32()?,
Err(Error::Format(FormatError::PathNotFound { .. })) => Vec::new(),
Err(e) => return Err(e),
};
Ok((d, added, doomed, slot))
})();
Verdict::of(read, |(d, added, doomed, slot)| {
let n = d.len() as i32;
if !(COMMIT_BASE..=COMMIT_BASE + CHURN * COMMIT_STEP).contains(&n) {
return Err(std::format!(
"`d` has {n} elements, outside the {COMMIT_BASE}..={} this session can produce",
COMMIT_BASE + CHURN * COMMIT_STEP
));
}
if let Some(i) = (0..d.len()).find(|&i| d[i] != i as i32) {
return Err(std::format!("`d`[{i}] is {} rather than {i}", d[i]));
}
for r in 0..CHURN {
if let Some(a) = &added[r as usize] {
if *a != added_values(r) {
return Err(std::format!(
"`added{r}` is present with the wrong bytes (len {}, wanted {})",
a.len(),
added_values(r).len()
));
}
}
if let Some(x) = &doomed[r as usize] {
if *x != doomed_values(r) {
return Err(std::format!(
"`doomed{r}` is still present but its bytes have changed"
));
}
}
if added[r as usize].is_some() != doomed[r as usize].is_none() {
return Err(std::format!(
"round {r} is half-committed: added{r} {}, doomed{r} {}",
if added[r as usize].is_some() {
"present"
} else {
"absent"
},
if doomed[r as usize].is_some() {
"present"
} else {
"absent"
}
));
}
}
let committed: Vec<bool> = (0..CHURN).map(|r| added[r as usize].is_some()).collect();
if let Some(r) = (1..CHURN as usize).find(|&r| committed[r] && !committed[r - 1]) {
return Err(std::format!(
"round {r} committed but round {} did not, though {r} came second",
r - 1
));
}
if slot.is_empty() {
return Err("`slot` is not in the file, though every commit that \
removes it puts its replacement back in the same commit"
.to_string());
}
let c = committed.iter().filter(|&&b| b).count() as i32;
if slot != slot_values(c) {
let found = (0..=CHURN).find(|&g| slot == slot_values(g));
return Err(match found {
Some(g) => {
std::format!("`slot` holds generation {g} while {c} round(s) have committed")
}
None => std::format!(
"`slot` holds {} elements, which is no generation this session writes \
({c} round(s) have committed, so generation {c} was expected)",
slot.len()
),
});
}
Ok(())
})
}
fn every_round_committed(path: &Path) -> Result<(), String> {
let f = crate::reader::File::open(path)
.map_err(|e| std::format!("the finished file does not open: {e:?}"))?;
let d = f
.dataset("d")
.and_then(|d| d.read_i32())
.map_err(|e| std::format!("the finished `d` does not read: {e:?}"))?;
let want = COMMIT_BASE + CHURN * COMMIT_STEP;
if d.len() as i32 != want {
return Err(std::format!(
"`d` holds {} elements rather than {want}",
d.len()
));
}
for r in 0..CHURN {
if f.dataset(&std::format!("added{r}")).is_err() {
return Err(std::format!("`added{r}` was never created"));
}
if f.dataset(&std::format!("doomed{r}")).is_ok() {
return Err(std::format!("`doomed{r}` was never deleted"));
}
}
let slot = f
.dataset("slot")
.and_then(|d| d.read_i32())
.map_err(|e| std::format!("the finished `slot` does not read: {e:?}"))?;
if slot != slot_values(CHURN) {
return Err(std::format!(
"`slot` holds {} elements rather than generation {CHURN}'s {}",
slot.len(),
slot_values(CHURN).len()
));
}
Ok(())
}
fn churn(s: &mut WriteEngine, r: i32) {
let from = COMMIT_BASE + r * COMMIT_STEP;
let mut b = AppendBuilder::new();
b.append_i32(&(from..from + COMMIT_STEP).collect::<Vec<_>>());
s.append_inplace_gathered(AppendTarget::Path("d"), &b, 4)
.unwrap();
let v = added_values(r);
let mut db = DatasetBuilder::new(&std::format!("added{r}"));
db.with_i32_data(&v).with_shape(&[v.len() as u64]);
s.stage_created_dataset(&std::format!("/added{r}"), db)
.unwrap();
s.delete(&std::format!("doomed{r}")).unwrap();
s.delete("slot").unwrap();
let v = slot_values(r + 1);
let mut sb = DatasetBuilder::new("slot");
sb.with_i32_data(&v).with_shape(&[v.len() as u64]);
s.stage_created_dataset("/slot", sb).unwrap();
s.commit().unwrap();
assert!(!s.has_staged_edits(), "the commit left edits staged");
}
#[test]
fn committing_survives_a_crash_at_every_write() {
for paged in [false, true] {
for bounded in [false, true] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("commit.h5");
commit_base(&path, paged);
let label = std::format!(
"commit-{}-{}",
if paged { "paged" } else { "plain" },
if bounded { "bounded" } else { "mirrored" }
);
let rec = Recording::of(&label, &path, |p| {
let mut s = if bounded {
WriteEngine::open_rw_with_strategy(
p,
MetadataCacheConfig::new(64 * 1024),
FileLocking::Enabled,
MemoryStrategy::Bounded,
)
.unwrap()
} else {
WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap()
};
s.set_sync_policy(SyncPolicy::OnClose);
for r in 0..CHURN {
churn(&mut s, r);
}
if paged {
s.finalize_persist().unwrap();
}
drop(s);
});
rec.assert_positioned(1, 0);
rec.replay_every_prefix(
dir.path(),
commit_state_is_one_or_the_other,
every_round_committed,
);
}
}
}
#[test]
fn crash_states_read_back_with_and_without_write_gathering() {
use crate::image::WriteBuffering;
const ROUNDS_EACH: i32 = 20;
let hi = WARMUP + ROUNDS_EACH * ROUND;
let sweep = |label: &str, mode: Option<WriteBuffering>| -> Tally {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compare.h5");
warmed_base(&path, false);
let rec = Recording::of(label, &path, |p| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
if let Some(mode) = mode {
s.set_write_buffering(mode).unwrap();
}
for r in 0..ROUNDS_EACH {
append(&mut s, WARMUP + r * ROUND, ROUND);
}
drop(s);
});
rec.assert_positioned(1, 0);
rec.replay_every_prefix(
dir.path(),
|p| appended_prefix_is_intact(p, WARMUP, hi),
|p| appended_all_the_way(p, hi),
)
};
let gathered = sweep("compare-gathered", None);
let straight = sweep("compare-unbuffered", Some(WriteBuffering::Unbuffered));
assert!(
straight.total > gathered.total,
"the unbuffered sweep should stop at more instants than the gathered one, \
but swept {} prefixes against {}",
straight.total,
gathered.total
);
}
#[test]
fn publishing_into_a_live_super_block_survives_a_crash_at_every_write() {
use crate::image::WriteBuffering;
const START_ROUNDS: i32 = 300;
const ROUNDS_EACH: i32 = 20;
let lo = START_ROUNDS * ROUND;
let hi = lo + ROUNDS_EACH * ROUND;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("live_super.h5");
warmed_base(&path, false);
{
let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
let mut n = WARMUP;
while n < lo {
append(&mut s, n, ROUND);
n += ROUND;
}
drop(s);
}
let rec = Recording::of("live-super", &path, |p| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
s.set_write_buffering(WriteBuffering::Unbuffered).unwrap();
for r in 0..ROUNDS_EACH {
append(&mut s, lo + r * ROUND, ROUND);
}
drop(s);
});
rec.assert_positioned(1, 0);
assert_eq!(
rec.super_blocks, 0,
"the window must publish *into* a live super block, not allocate one"
);
rec.replay_every_prefix(
dir.path(),
|p| appended_prefix_is_intact(p, lo, hi),
|p| appended_all_the_way(p, hi),
);
}
const HEADER_PAD: usize = 4096;
#[test]
fn publishing_a_dimension_in_a_wide_header_survives_a_crash_at_every_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wide_header.h5");
warmed_base_padded(&path, true, HEADER_PAD);
let rec = Recording::of("wide-header", &path, |p| {
let mut s = WriteEngine::open_with_locking(p, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
for r in 0..ROUNDS {
append(&mut s, WARMUP + r * ROUND, ROUND);
}
s.finalize_persist().unwrap();
drop(s);
});
rec.assert_publishes_across_a_gather_page();
let hi = WARMUP + ROUNDS * ROUND;
rec.replay_every_prefix(
dir.path(),
|p| appended_prefix_is_intact(p, WARMUP, hi),
|p| appended_all_the_way(p, hi),
);
}
#[test]
fn a_publish_costs_the_same_whether_or_not_it_spans_a_page() {
use crate::image::WriteBuffering;
for mode in [None, Some(WriteBuffering::Unbuffered)] {
let mut counts = Vec::new();
for pad in [0usize, 2000, 3800, 3900, 4200, 8300] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pad.h5");
warmed_base_padded(&path, true, pad);
let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
if let Some(mode) = mode {
s.set_write_buffering(mode).unwrap();
}
let before = s.issued_write_order().len();
append(&mut s, WARMUP, ROUND);
let made = s.issued_write_order()[before..].to_vec();
drop(s);
let wide = made
.iter()
.any(|&(off, len)| off < GATHER_PAGE && off + len > GATHER_PAGE);
counts.push((pad, made.len(), wide));
}
assert!(
counts.iter().any(|&(_, _, w)| w) && counts.iter().any(|&(_, _, w)| !w),
"{mode:?}: every padding fell on the same side of the {GATHER_PAGE}-byte \
page, so this comparison holds nothing: {counts:?}"
);
let (_, first, _) = counts[0];
assert!(
counts.iter().all(|&(_, n, _)| n == first),
"{mode:?}: one append must cost the same writes at every header width, \
but cost {counts:?}"
);
}
}
#[test]
fn buffering_saves_a_constant_number_of_writes_not_one_per_append() {
use crate::image::WriteBuffering;
const SLACK: usize = 4;
for paged in [false, true] {
let cost = |rounds: i32, unbuffered: bool| -> usize {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("gap.h5");
warmed_base(&path, paged);
let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
if unbuffered {
s.set_write_buffering(WriteBuffering::Unbuffered).unwrap();
}
for i in 0..rounds {
append(&mut s, WARMUP + i * ROUND, ROUND);
}
let n = s.issued_write_order().len();
drop(s);
n
};
let (few, many) = (2i32, 24i32);
let gap = |rounds: i32| cost(rounds, true).saturating_sub(cost(rounds, false));
let (gap_few, gap_many) = (gap(few), gap(many));
assert!(
gap_many > 0,
"paged={paged}: unbuffered writing must cost more writes than gathered \
over {many} appends, but the two were equal — the buffering mode is \
not taking effect and nothing below is being measured"
);
assert!(
gap_many <= gap_few + SLACK,
"paged={paged}: buffering must save a constant number of writes, but saved \
{gap_few} over {few} appends and {gap_many} over {many} — a gap that grows \
with the appends is a publish that is still two writes"
);
}
}
#[test]
fn a_publish_writes_from_the_byte_it_changed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("from.h5");
warmed_base(&path, false);
let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
s.set_sync_policy(SyncPolicy::OnClose);
let written = |s: &WriteEngine| -> u64 { s.issued_write_order().iter().map(|&(_, n)| n).sum() };
let mut cost = Vec::new();
for i in 0..2 {
let before = written(&s);
append(&mut s, WARMUP + i * ROUND, ROUND);
cost.push(written(&s) - before);
}
drop(s);
assert!(
cost[1] < cost[0],
"an append that touches a later slot of the same block must write fewer \
bytes than one that touches an earlier slot, but the two cost {cost:?}"
);
}