use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use candystore::{
CandyStore, CandyTypedDeque, CandyTypedList, CandyTypedStore, Config, Error, Result,
};
use tempfile::tempdir;
const FIXTURE: &str = "tests/fixtures/v1_store";
const NUM_KV: u32 = 120;
const BIG_LEN: usize = 10_000;
const TOTAL_ENTRIES: usize = 174;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE)
}
fn fixture_config() -> Config {
Config {
max_data_file_size: 4096,
compaction_throughput_bytes_per_sec: 0,
checkpoint_interval: None,
checkpoint_delta_bytes: None,
..Config::default()
}
}
fn kv_key(i: u32) -> String {
format!("kv{i:04}")
}
fn expected_kv() -> BTreeMap<Vec<u8>, Vec<u8>> {
let mut expected = BTreeMap::new();
for i in 0..NUM_KV {
let value = if i < 40 {
format!("updated-{i}")
} else {
format!("value-{i}").repeat(2)
};
if !(60..80).contains(&i) {
expected.insert(kv_key(i).into_bytes(), value.into_bytes());
}
}
expected.insert(b"written-by-fixture".to_vec(), b"ok".to_vec());
expected
}
fn populate(db: &Arc<CandyStore>) -> Result<()> {
for i in 0..NUM_KV {
db.set(kv_key(i), format!("value-{i}").repeat(2))?;
}
for i in 0..40 {
db.set(kv_key(i), format!("updated-{i}"))?;
}
for i in 60..80 {
db.remove(kv_key(i))?;
}
db.set("written-by-fixture", "ok")?;
for i in 0..20 {
db.set_in_list("list", &format!("item{i:03}"), &format!("lv{i}"))?;
}
db.remove_from_list("list", "item005")?;
for i in 0..15 {
db.push_to_queue_tail("queue", &format!("q{i}"))?;
}
db.pop_queue_head("queue")?;
db.set_big("big", &vec![7u8; BIG_LEN])?;
let typed = CandyTypedStore::<u32, String>::new(Arc::clone(db));
typed.set(&42u32, &"typed".to_string())?;
let typed_big = CandyTypedStore::<u32, Vec<u8>>::new(Arc::clone(db));
typed_big.set_big(&7u32, &vec![9u8; BIG_LEN])?;
let typed_list = CandyTypedList::<String, u32, String>::new(Arc::clone(db));
typed_list.set(&"tl".to_string(), &1u32, &"one".to_string())?;
typed_list.set(&"tl".to_string(), &2u32, &"two".to_string())?;
let typed_deque = CandyTypedDeque::<String, u64>::new(Arc::clone(db));
typed_deque.push_tail(&"td".to_string(), &10u64)?;
typed_deque.push_head(&"td".to_string(), &5u64)?;
Ok(())
}
fn verify(db: &Arc<CandyStore>) -> Result<()> {
verify_inner(db, true)
}
fn verify_inner(db: &Arc<CandyStore>, check_count: bool) -> Result<()> {
let expected = expected_kv();
let actual: BTreeMap<Vec<u8>, Vec<u8>> = db.iter_items().collect::<Result<_>>()?;
assert_eq!(actual, expected);
if check_count {
assert_eq!(db.num_items(), TOTAL_ENTRIES);
}
let list: Vec<_> = db.iter_list("list").collect::<Result<_>>()?;
assert_eq!(list.len(), 19);
assert_eq!(list[0], (b"item000".to_vec(), b"lv0".to_vec()));
assert_eq!(list[5], (b"item006".to_vec(), b"lv6".to_vec()));
assert_eq!(db.list_len("list")?, 19);
let queue: Vec<_> = db.iter_queue("queue").collect::<Result<_>>()?;
assert_eq!(queue.len(), 14);
assert_eq!(queue[0].1, b"q1".to_vec());
assert_eq!(db.queue_len("queue")?, 14);
assert_eq!(db.get_big("big")?, Some(vec![7u8; BIG_LEN]));
let typed = CandyTypedStore::<u32, String>::new(Arc::clone(db));
assert_eq!(typed.get(&42u32)?, Some("typed".to_string()));
let typed_big = CandyTypedStore::<u32, Vec<u8>>::new(Arc::clone(db));
assert_eq!(typed_big.get_big(&7u32)?, Some(vec![9u8; BIG_LEN]));
let typed_list = CandyTypedList::<String, u32, String>::new(Arc::clone(db));
let items: Vec<_> = typed_list.iter(&"tl".to_string()).collect::<Result<_>>()?;
assert_eq!(items, vec![(1, "one".to_string()), (2, "two".to_string())]);
let typed_deque = CandyTypedDeque::<String, u64>::new(Arc::clone(db));
let items: Vec<_> = typed_deque
.iter(&"td".to_string())
.map(|r| r.map(|(_, v)| v))
.collect::<Result<_>>()?;
assert_eq!(items, vec![5, 10]);
Ok(())
}
fn copy_fixture(dst: &Path) -> Result<()> {
for entry in std::fs::read_dir(fixture_dir()).map_err(Error::IOError)? {
let entry = entry.map_err(Error::IOError)?;
std::fs::copy(entry.path(), dst.join(entry.file_name())).map_err(Error::IOError)?;
}
Ok(())
}
fn header_prefixes(dir: &Path) -> Result<BTreeMap<String, Vec<u8>>> {
let mut prefixes = BTreeMap::new();
for entry in std::fs::read_dir(dir).map_err(Error::IOError)? {
let entry = entry.map_err(Error::IOError)?;
let name = entry.file_name().to_string_lossy().into_owned();
if name == "index" || name.starts_with("data_") {
let bytes = std::fs::read(entry.path()).map_err(Error::IOError)?;
prefixes.insert(name, bytes[..12].to_vec());
}
}
Ok(prefixes)
}
#[test]
fn fixture_store_opens_without_rebuild_or_migration() -> Result<()> {
let dir = tempdir().unwrap();
copy_fixture(dir.path())?;
let headers_before = header_prefixes(dir.path())?;
assert!(
headers_before.len() > 2,
"fixture should contain an index and several data files"
);
let db = Arc::new(CandyStore::open(dir.path(), fixture_config())?);
let stats = db.stats();
assert_eq!(
stats.num_rebuilt_entries, 0,
"a cleanly closed fixture must not be replayed"
);
assert_eq!(stats.num_rebuild_purged_bytes, 0);
assert_eq!(
header_prefixes(dir.path())?,
headers_before,
"index/data headers changed on open: the fixture was migrated or recreated"
);
verify(&db)?;
db.set("post-open", "v")?;
db.set_in_list("list", "item999", "new")?;
drop(db);
let db = Arc::new(CandyStore::open(dir.path(), fixture_config())?);
assert_eq!(db.get("post-open")?, Some(b"v".to_vec()));
assert_eq!(db.list_len("list")?, 20);
assert_eq!(db.num_items(), TOTAL_ENTRIES + 3);
Ok(())
}
#[test]
fn fixture_generator_round_trips_on_current_build() -> Result<()> {
let dir = tempdir().unwrap();
{
let db = Arc::new(CandyStore::open(dir.path(), fixture_config())?);
populate(&db)?;
verify(&db)?;
}
let db = Arc::new(CandyStore::open(dir.path(), fixture_config())?);
verify(&db)
}
#[test]
#[ignore]
fn regenerate_fixture() -> Result<()> {
let dir = fixture_dir();
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(Error::IOError)?;
{
let db = Arc::new(CandyStore::open(&dir, fixture_config())?);
populate(&db)?;
verify_inner(&db, false)?;
assert!(
db.stats().num_data_files > 2,
"fixture should span rotations"
);
}
std::fs::remove_file(dir.join(".lockfile")).map_err(Error::IOError)?;
Ok(())
}