use std::error::Error;
use std::io;
use std::path::Path;
use haematite::{Database, DatabaseConfig, DatabaseError, ReadOnlyDatabase};
type TestResult = Result<(), Box<dyn Error>>;
fn config_for(path: &Path) -> DatabaseConfig {
DatabaseConfig {
data_dir: path.to_path_buf(),
shard_count: 2,
distributed: None,
executor_threads: None,
}
}
fn boxed_error(message: &'static str) -> Box<dyn Error> {
Box::new(io::Error::other(message))
}
fn require_open_failure(
result: Result<Database, DatabaseError>,
context: &'static str,
) -> Result<DatabaseError, Box<dyn Error>> {
match result {
Ok(_database) => Err(boxed_error(context)),
Err(error) => Ok(error),
}
}
#[test]
fn second_writer_open_fails_loudly_with_data_dir_locked() -> TestResult {
let dir = tempfile::tempdir()?;
let writer = Database::create(config_for(dir.path()))?;
let error = require_open_failure(
Database::open(dir.path()),
"a second writer open over a live writer must fail",
)?;
assert!(
matches!(error, DatabaseError::DataDirLocked { .. }),
"expected DataDirLocked, got: {error}"
);
assert!(
error.to_string().contains("writer.lock"),
"the loud failure must name the lockfile: {error}"
);
writer.put(b"still-mine".to_vec(), b"value".to_vec())?;
writer.commit()?;
assert_eq!(writer.get(b"still-mine")?, Some(b"value".to_vec()));
Ok(())
}
#[test]
fn create_over_a_live_writer_fails_and_leaves_the_dir_intact() -> TestResult {
let dir = tempfile::tempdir()?;
let writer = Database::create(config_for(dir.path()))?;
let config_before = std::fs::read(dir.path().join("config.json"))?;
let mut clobbering = config_for(dir.path());
clobbering.shard_count = 7;
let error = require_open_failure(
Database::create(clobbering),
"a second create over a live writer must fail",
)?;
assert!(
matches!(error, DatabaseError::DataDirLocked { .. }),
"expected DataDirLocked, got: {error}"
);
let config_after = std::fs::read(dir.path().join("config.json"))?;
assert_eq!(
config_before, config_after,
"a losing create must not clobber the live writer's config"
);
drop(writer);
Ok(())
}
#[test]
fn dropping_the_writer_releases_the_lock_for_reopen() -> TestResult {
let dir = tempfile::tempdir()?;
let first = Database::create(config_for(dir.path()))?;
first.put(b"persisted".to_vec(), b"across-reopen".to_vec())?;
first.commit()?;
drop(first);
let second = Database::open(dir.path())?;
assert_eq!(second.get(b"persisted")?, Some(b"across-reopen".to_vec()));
drop(second);
let third = Database::open(dir.path())?;
assert_eq!(third.get(b"persisted")?, Some(b"across-reopen".to_vec()));
Ok(())
}
#[test]
fn read_only_observer_succeeds_alongside_a_live_writer() -> TestResult {
let dir = tempfile::tempdir()?;
let writer = Database::create(config_for(dir.path()))?;
writer.put(b"committed-key".to_vec(), b"committed-value".to_vec())?;
writer.commit()?;
writer.put(b"buffered-key".to_vec(), b"uncommitted".to_vec())?;
let observer = ReadOnlyDatabase::open(dir.path())?;
assert_eq!(observer.shard_count(), 2);
assert_eq!(
observer.get(b"committed-key")?,
Some(b"committed-value".to_vec())
);
assert_eq!(
observer.get(b"buffered-key")?,
None,
"an observer must never see uncommitted buffered writes"
);
writer.commit()?;
assert_eq!(
observer.get(b"buffered-key")?,
Some(b"uncommitted".to_vec())
);
drop(writer);
let successor = Database::open(dir.path())?;
assert_eq!(
observer.get(b"committed-key")?,
Some(b"committed-value".to_vec()),
"the observer keeps reading across a writer handover"
);
drop(successor);
Ok(())
}
#[test]
fn observer_range_reads_committed_entries_and_hides_tombstones() -> TestResult {
let dir = tempfile::tempdir()?;
let writer = Database::create(config_for(dir.path()))?;
let target_shard = writer.shard_for(b"range:");
let mut keys: Vec<Vec<u8>> = Vec::new();
let mut candidate = 0_u64;
while keys.len() < 3 {
let key = format!("range:{candidate:04}").into_bytes();
if writer.shard_for(&key) == target_shard {
keys.push(key);
}
candidate = candidate.saturating_add(1);
assert!(candidate < 10_000, "failed to find enough shard-local keys");
}
for key in &keys {
writer.put(key.clone(), b"live".to_vec())?;
}
writer.commit()?;
writer.delete(keys[1].clone())?;
writer.commit()?;
let observer = ReadOnlyDatabase::open(dir.path())?;
let entries = observer.range(b"range:", b"range:\xff")?;
let expected: Vec<(Vec<u8>, Vec<u8>)> = vec![
(keys[0].clone(), b"live".to_vec()),
(keys[2].clone(), b"live".to_vec()),
];
assert_eq!(
entries, expected,
"a committed tombstone must read as absent to the observer"
);
assert_eq!(observer.get(&keys[1])?, None);
assert!(observer.range(b"zz", b"aa")?.is_empty());
drop(writer);
Ok(())
}
#[test]
fn observer_reads_uncommitted_and_unmaterialised_shards_as_empty() -> TestResult {
let dir = tempfile::tempdir()?;
let writer = Database::create(config_for(dir.path()))?;
let observer = ReadOnlyDatabase::open(dir.path())?;
assert_eq!(observer.get(b"anything")?, None);
assert!(observer.range(b"a", b"z")?.is_empty());
assert_eq!(observer.committed_root(0)?, None);
assert_eq!(observer.committed_root(1)?, None);
assert!(
observer.committed_root(2).is_err(),
"shard id at shard_count must be rejected"
);
writer.put(b"first".to_vec(), b"commit".to_vec())?;
writer.commit()?;
let shard = observer.shard_for(b"first");
assert!(
observer.committed_root(shard)?.is_some(),
"a committed shard must expose its committed root"
);
Ok(())
}
#[test]
fn observer_open_on_a_non_database_dir_fails() -> TestResult {
let dir = tempfile::tempdir()?;
match ReadOnlyDatabase::open(dir.path()) {
Ok(_observer) => Err(boxed_error(
"opening an observer over a non-database dir must fail",
)),
Err(error) => {
assert!(!error.to_string().is_empty());
Ok(())
}
}
}