#![allow(clippy::expect_used, clippy::panic)]
use std::path::Path;
use tempfile::{TempDir, tempdir};
use zeppelin_embed::ingest::wal_payload::{
PayloadError, UPSERT_V2_BATCH_MEMBER, encode_upsert_v2, encode_upsert_v2_batch_member,
};
use zeppelin_embed::ingest::{
DeleteBatch, DocId, DocumentVersion, IngestBatch, IngestDocument, Revision,
};
use zeppelin_embed::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
use zeppelin_embed::lifecycle::lock::STORE_LOCK_FILE;
use zeppelin_embed::lifecycle::{DocumentFields, OpenOptions, Store, StoreError};
use zeppelin_embed::vfs::StdVfs;
use zeppelin_embed::wal::record::RecordError;
use zeppelin_embed::wal::replay::{CorruptionLocation, CorruptionReason, ReplayTerminator, replay};
use zeppelin_embed::wal::{LogSeq, WalRecoveryError, WalWriter};
const IDS: [u128; 6] = [1, 2, 3, 4, 5, 9];
type State = Vec<Option<u64>>;
fn document(id: u128, revision: u64) -> IngestDocument {
IngestDocument::new(
DocumentVersion::new(DocId::new(id), Revision::new(revision)),
vec![id as f32, 1.0],
)
.with_text(format!("note {id} revision {revision}"))
}
fn upsert(store: &Store, documents: &[(u128, u64)]) {
store
.ingest(IngestBatch::new(
documents
.iter()
.map(|&(id, revision)| document(id, revision))
.collect(),
))
.expect("commit upsert batch");
}
fn state(store: &Store) -> State {
let ids = IDS.map(DocId::new);
store
.get_documents(&ids, DocumentFields::NONE)
.expect("read documents")
.into_iter()
.map(|document| document.map(|document| document.revision.get()))
.collect()
}
fn wal_len(directory: &Path) -> usize {
std::fs::metadata(directory.join("wal.ze"))
.expect("WAL metadata")
.len() as usize
}
fn torn_copy(source: &Path, wal: &[u8], cut: usize) -> TempDir {
let copy = tempdir().expect("torn copy directory");
for entry in std::fs::read_dir(source).expect("list store") {
let entry = entry.expect("store entry");
let name = entry.file_name();
if name == STORE_LOCK_FILE || name == "wal.ze" {
continue;
}
std::fs::copy(entry.path(), copy.path().join(&name)).expect("copy store file");
}
std::fs::write(copy.path().join("wal.ze"), &wal[..cut]).expect("write torn WAL");
copy
}
#[test]
fn every_wal_tear_across_multi_document_batches_recovers_whole_batches() {
let directory = tempdir().expect("store directory");
let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
upsert(&store, &[(1, 1), (2, 1)]);
let mut ends = vec![wal_len(directory.path())];
let mut states = vec![state(&store)];
upsert(&store, &[(1, 2), (3, 1), (4, 1), (5, 1)]);
ends.push(wal_len(directory.path()));
states.push(state(&store));
store
.delete(DeleteBatch::new(vec![DocId::new(2), DocId::new(3)]))
.expect("commit delete batch");
ends.push(wal_len(directory.path()));
states.push(state(&store));
upsert(&store, &[(4, 2), (5, 2)]);
ends.push(wal_len(directory.path()));
states.push(state(&store));
store.close().expect("close source store");
let wal = std::fs::read(directory.path().join("wal.ze")).expect("read WAL");
let mut outcomes = [0_usize; 3];
for cut in ends[0]..=ends[ends.len() - 1] {
let whole = ends
.iter()
.rposition(|end| *end <= cut)
.expect("baseline end");
let expected = &states[whole];
let torn = torn_copy(directory.path(), &wal, cut);
let clean_end = matches!(replay(&wal[..cut]).terminator, ReplayTerminator::CleanEnd);
match Store::open(torn.path(), OpenOptions::read_only()) {
Ok(reader) => {
assert!(
clean_end,
"cut {cut}: read-only open accepted a torn record"
);
assert_eq!(&state(&reader), expected, "cut {cut}: read-only state");
reader.close().expect("close reader");
}
Err(error) => {
assert!(
!clean_end,
"cut {cut}: read-only open refused a clean WAL: {error}"
);
assert!(
matches!(
error,
StoreError::WalRecovery(WalRecoveryError::CorruptAt {
reason: CorruptionReason::Record {
location: CorruptionLocation::Tail,
error: RecordError::HeaderTruncated { .. }
| RecordError::BodyTruncated { .. },
},
..
})
),
"cut {cut}: read-only refusal was {error:?}"
);
}
}
let writer = Store::open(torn.path(), OpenOptions::default())
.unwrap_or_else(|error| panic!("cut {cut}: writable open failed: {error}"));
assert_eq!(&state(&writer), expected, "cut {cut}: recovered state");
upsert(&writer, &[(9, 1), (1, 9)]);
writer.close().expect("close writer");
let reopened = Store::open(torn.path(), OpenOptions::default())
.unwrap_or_else(|error| panic!("cut {cut}: reopen after new writes failed: {error}"));
let mut after = expected.clone();
after[0] = Some(9);
after[5] = Some(1);
assert_eq!(state(&reopened), after, "cut {cut}: state after new writes");
reopened.close().expect("close reopened");
outcomes[usize::from(clean_end) + usize::from(cut == ends[whole])] += 1;
}
eprintln!(
"batch_atomicity cuts={} torn_record={} inner_record_boundary={} batch_boundary={}",
ends[ends.len() - 1] - ends[0] + 1,
outcomes[0],
outcomes[1],
outcomes[2]
);
assert!(
outcomes.iter().all(|count| *count > 0),
"every cut class ran"
);
}
fn member_wal(directory: &Path, members: &[(u32, u32, u128)]) {
let policy = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::None).expect("policy");
let writer = WalWriter::create(&StdVfs, &directory.join("wal.ze"), LogSeq::new(1), policy)
.expect("create WAL");
for &(index, count, id) in members {
let body = encode_upsert_v2(&document(id, 1)).expect("encode upsert");
let payload = encode_upsert_v2_batch_member(index, count, &body).expect("frame member");
writer
.commit(UPSERT_V2_BATCH_MEMBER, &payload)
.expect("commit member");
}
}
#[test]
fn a_batch_member_that_continues_nothing_fails_open_loudly() {
for (members, orphan) in [
(vec![(1, 2, 7)], (1, 2)),
(vec![(0, 2, 7), (1, 3, 8)], (1, 3)),
(vec![(0, 2, 7), (1, 2, 8), (2, 3, 9)], (2, 3)),
] {
let directory = tempdir().expect("store directory");
member_wal(directory.path(), &members);
let error = Store::open(directory.path(), OpenOptions::default())
.err()
.expect("an orphan member must refuse open");
assert!(
matches!(
error,
StoreError::WalMutation {
op: UPSERT_V2_BATCH_MEMBER,
source: PayloadError::OrphanBatchMember { index, count },
..
} if (index, count) == orphan
),
"{members:?}: {error:?}"
);
}
}
#[test]
fn a_complete_hand_framed_batch_opens_and_a_cut_one_is_absent() {
let complete = tempdir().expect("complete directory");
member_wal(complete.path(), &[(0, 2, 7), (1, 2, 8)]);
let store = Store::open(complete.path(), OpenOptions::read_only()).expect("open complete");
let found = store
.get_documents(&[DocId::new(7), DocId::new(8)], DocumentFields::NONE)
.expect("read complete batch");
assert!(found.iter().all(Option::is_some), "complete batch visible");
store.close().expect("close complete");
let cut = tempdir().expect("cut directory");
member_wal(cut.path(), &[(0, 3, 7), (1, 3, 8), (0, 2, 5), (1, 2, 6)]);
let store = Store::open(cut.path(), OpenOptions::read_only()).expect("open cut");
let found = store
.get_documents(&[5, 6, 7, 8].map(DocId::new), DocumentFields::NONE)
.expect("read after cut batch")
.iter()
.map(Option::is_some)
.collect::<Vec<_>>();
assert_eq!(
found,
[true, true, false, false],
"cut batch absent, next whole"
);
store.close().expect("close cut");
}
#[test]
fn a_torn_final_record_longer_than_any_group_is_not_cut() {
let directory = tempdir().expect("store directory");
let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
upsert(&store, &[(1, 1)]);
store.close().expect("close store");
let path = directory.path().join("wal.ze");
let mut wal = std::fs::read(&path).expect("read WAL");
wal.extend_from_slice(&(32_u32 * 1024 * 1024).to_le_bytes());
wal.extend_from_slice(&2_u64.to_le_bytes());
wal.extend_from_slice(&7_u16.to_le_bytes());
wal.extend_from_slice(&[0x5a; 64]);
std::fs::write(&path, &wal).expect("write damaged WAL");
let error = Store::open(directory.path(), OpenOptions::default())
.err()
.expect("an impossible record length must refuse open");
assert!(
matches!(
error,
StoreError::WalRecovery(WalRecoveryError::CorruptAt {
reason: CorruptionReason::Record {
error: RecordError::BodyTruncated { .. },
..
},
..
})
),
"{error:?}"
);
assert_eq!(
std::fs::read(&path).expect("reread WAL"),
wal,
"WAL untouched"
);
}