mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-21 08:46:04 +00:00
890 lines
29 KiB
Rust
890 lines
29 KiB
Rust
mod common;
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use bytes::Bytes;
|
|
use cid::Cid;
|
|
use common::{advance_epoch, compact_all_sealed};
|
|
use futures::StreamExt;
|
|
use jacquard_common::types::string::Did;
|
|
use jacquard_common::types::tid::Ticker;
|
|
use jacquard_repo::car::{parse_car_bytes, write_car_bytes};
|
|
use jacquard_repo::commit::Commit;
|
|
use jacquard_repo::mst::Mst;
|
|
use jacquard_repo::repo::CommitData;
|
|
use jacquard_repo::storage::BlockStore;
|
|
use multihash::Multihash;
|
|
use sha2::{Digest, Sha256};
|
|
use tranquil_store::blockstore::{
|
|
BlockStoreConfig, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore,
|
|
};
|
|
|
|
const DAG_CBOR_CODEC: u64 = 0x71;
|
|
const SHA2_256_CODE: u64 = 0x12;
|
|
|
|
fn test_config(dir: &std::path::Path) -> BlockStoreConfig {
|
|
BlockStoreConfig {
|
|
data_dir: dir.join("data"),
|
|
index_dir: dir.join("index"),
|
|
max_file_size: DEFAULT_MAX_FILE_SIZE,
|
|
group_commit: GroupCommitConfig::default(),
|
|
shard_count: 1,
|
|
}
|
|
}
|
|
|
|
fn make_record(value: &str) -> Vec<u8> {
|
|
serde_ipld_dagcbor::to_vec(&BTreeMap::from([
|
|
("$type", "app.bsky.feed.post"),
|
|
("text", value),
|
|
]))
|
|
.unwrap()
|
|
}
|
|
|
|
fn compute_cid(data: &[u8]) -> Cid {
|
|
let hash = Sha256::digest(data);
|
|
let multihash = Multihash::wrap(SHA2_256_CODE, &hash).unwrap();
|
|
Cid::new_v1(DAG_CBOR_CODEC, multihash)
|
|
}
|
|
|
|
fn test_signing_key() -> k256::ecdsa::SigningKey {
|
|
k256::ecdsa::SigningKey::random(&mut rand::thread_rng())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn mst_insert_commit_and_car_round_trip() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let store = TranquilBlockStore::open(test_config(dir.path())).unwrap();
|
|
let storage = Arc::new(store.clone());
|
|
let mut mst = Mst::new(storage.clone());
|
|
|
|
let records: Vec<(String, Cid, Vec<u8>)> = (0..100u32)
|
|
.map(|i| {
|
|
let data = make_record(&format!("post number {i}"));
|
|
let cid = compute_cid(&data);
|
|
(format!("app.bsky.feed.post/{i:010}"), cid, data)
|
|
})
|
|
.collect();
|
|
|
|
let mut record_blocks = BTreeMap::new();
|
|
for (key, cid, data) in &records {
|
|
record_blocks.insert(*cid, Bytes::from(data.clone()));
|
|
storage.put(data).await.unwrap();
|
|
mst = mst.add(key, *cid).await.unwrap();
|
|
}
|
|
|
|
let mst_root = mst.persist().await.unwrap();
|
|
|
|
futures::stream::iter(&records)
|
|
.for_each(|(key, expected_cid, _)| {
|
|
let mst = &mst;
|
|
async move {
|
|
let found = mst.get(key).await.unwrap();
|
|
assert_eq!(found, Some(*expected_cid), "record {key} missing from MST");
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let did = Did::new("did:plc:testuser123").unwrap();
|
|
let mut ticker = Ticker::new();
|
|
let rev = ticker.next(None);
|
|
|
|
let commit = Commit::new_unsigned(did, mst_root, rev.clone(), None)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_cbor = commit.to_cbor().unwrap();
|
|
let commit_cid = compute_cid(&commit_cbor);
|
|
let commit_bytes = Bytes::from(commit_cbor);
|
|
|
|
let empty_mst = Mst::new(storage.clone());
|
|
let diff = empty_mst.diff(&mst).await.unwrap();
|
|
|
|
let mut all_blocks = diff.new_mst_blocks.clone();
|
|
all_blocks.insert(commit_cid, commit_bytes);
|
|
all_blocks.extend(record_blocks);
|
|
|
|
let commit_data = CommitData {
|
|
cid: commit_cid,
|
|
rev,
|
|
since: None,
|
|
prev: None,
|
|
data: mst_root,
|
|
prev_data: None,
|
|
blocks: all_blocks.clone(),
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: Vec::new(),
|
|
};
|
|
|
|
store.apply_commit(commit_data).await.unwrap();
|
|
|
|
let car_bytes = write_car_bytes(commit_cid, all_blocks).await.unwrap();
|
|
let parsed = parse_car_bytes(&car_bytes).await.unwrap();
|
|
|
|
assert_eq!(parsed.root, commit_cid);
|
|
assert!(parsed.blocks.contains_key(&commit_cid));
|
|
assert!(parsed.blocks.contains_key(&mst_root));
|
|
|
|
records.iter().for_each(|(_, record_cid, _)| {
|
|
assert!(
|
|
parsed.blocks.contains_key(record_cid),
|
|
"record block {record_cid} missing from CAR"
|
|
);
|
|
});
|
|
|
|
let parsed_commit = Commit::from_cbor(parsed.blocks.get(&commit_cid).unwrap()).unwrap();
|
|
assert_eq!(*parsed_commit.data(), mst_root);
|
|
|
|
let loaded_mst = Mst::load(storage.clone(), mst_root, None);
|
|
futures::stream::iter(&records)
|
|
.for_each(|(key, expected_cid, _)| {
|
|
let loaded_mst = &loaded_mst;
|
|
async move {
|
|
let found = loaded_mst.get(key).await.unwrap();
|
|
assert_eq!(
|
|
found,
|
|
Some(*expected_cid),
|
|
"record {key} missing after reload"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn mst_create_update_delete_with_refcounts() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let store = TranquilBlockStore::open(test_config(dir.path())).unwrap();
|
|
let storage = Arc::new(store.clone());
|
|
|
|
let record_a_v1 = make_record("version 1 of record A");
|
|
let record_a_v2 = make_record("version 2 of record A");
|
|
let record_b = make_record("record B to be deleted");
|
|
let record_c = make_record("record C stays forever");
|
|
let record_shared = make_record("shared content");
|
|
|
|
let cid_a_v1 = storage.put(&record_a_v1).await.unwrap();
|
|
let cid_a_v2 = compute_cid(&record_a_v2);
|
|
let cid_b = storage.put(&record_b).await.unwrap();
|
|
let cid_c = storage.put(&record_c).await.unwrap();
|
|
let cid_shared = storage.put(&record_shared).await.unwrap();
|
|
|
|
let mut mst = Mst::new(storage.clone());
|
|
mst = mst.add("app.bsky.feed.post/aaaa", cid_a_v1).await.unwrap();
|
|
mst = mst.add("app.bsky.feed.post/bbbb", cid_b).await.unwrap();
|
|
mst = mst.add("app.bsky.feed.post/cccc", cid_c).await.unwrap();
|
|
mst = mst
|
|
.add("app.bsky.feed.post/dddd", cid_shared)
|
|
.await
|
|
.unwrap();
|
|
mst = mst
|
|
.add("app.bsky.feed.post/eeee", cid_shared)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mst_root_v1 = mst.persist().await.unwrap();
|
|
|
|
let signing_key = test_signing_key();
|
|
let mut ticker = Ticker::new();
|
|
let rev1 = ticker.next(None);
|
|
|
|
let commit_v1 = Commit::new_unsigned(
|
|
Did::new("did:plc:testuser123").unwrap(),
|
|
mst_root_v1,
|
|
rev1.clone(),
|
|
None,
|
|
)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_v1_cbor = commit_v1.to_cbor().unwrap();
|
|
let commit_v1_cid = compute_cid(&commit_v1_cbor);
|
|
|
|
let empty_mst = Mst::new(storage.clone());
|
|
let diff_v1 = empty_mst.diff(&mst).await.unwrap();
|
|
|
|
let mut blocks_v1 = diff_v1.new_mst_blocks.clone();
|
|
blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor));
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_v1_cid,
|
|
rev: rev1.clone(),
|
|
since: None,
|
|
prev: None,
|
|
data: mst_root_v1,
|
|
prev_data: None,
|
|
blocks: blocks_v1,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let old_mst = mst.clone();
|
|
mst = mst.add("app.bsky.feed.post/aaaa", cid_a_v2).await.unwrap();
|
|
mst = mst.delete("app.bsky.feed.post/bbbb").await.unwrap();
|
|
mst = mst.delete("app.bsky.feed.post/eeee").await.unwrap();
|
|
|
|
let mst_root_v2 = mst.persist().await.unwrap();
|
|
|
|
let diff_v2 = old_mst.diff(&mst).await.unwrap();
|
|
|
|
let rev2 = ticker.next(Some(rev1.clone()));
|
|
let commit_v2 = Commit::new_unsigned(
|
|
Did::new("did:plc:testuser123").unwrap(),
|
|
mst_root_v2,
|
|
rev2.clone(),
|
|
Some(commit_v1_cid),
|
|
)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_v2_cbor = commit_v2.to_cbor().unwrap();
|
|
let commit_v2_cid = compute_cid(&commit_v2_cbor);
|
|
|
|
let mut blocks_v2 = diff_v2.new_mst_blocks.clone();
|
|
blocks_v2.insert(commit_v2_cid, Bytes::from(commit_v2_cbor));
|
|
blocks_v2.insert(cid_a_v2, Bytes::from(record_a_v2.clone()));
|
|
|
|
let mut deleted: Vec<Cid> = diff_v2.removed_mst_blocks.clone();
|
|
deleted.extend(diff_v2.removed_cids.iter());
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_v2_cid,
|
|
rev: rev2,
|
|
since: Some(rev1),
|
|
prev: Some(commit_v1_cid),
|
|
data: mst_root_v2,
|
|
prev_data: Some(mst_root_v1),
|
|
blocks: blocks_v2,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: deleted,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let retrieved_a_v2 = store.get(&cid_a_v2).await.unwrap();
|
|
assert!(retrieved_a_v2.is_some(), "updated record A v2 should exist");
|
|
assert_eq!(&retrieved_a_v2.unwrap()[..], &record_a_v2);
|
|
|
|
assert!(
|
|
store.get(&cid_a_v1).await.unwrap().is_some(),
|
|
"cid_a_v1 data should still exist, tombstoned but not GC'd"
|
|
);
|
|
assert!(
|
|
store.get(&cid_b).await.unwrap().is_some(),
|
|
"cid_b data should still exist, tombstoned but not GC'd"
|
|
);
|
|
|
|
assert!(
|
|
store.has(&cid_c).await.unwrap(),
|
|
"untouched record C should still exist"
|
|
);
|
|
let retrieved_c = store.get(&cid_c).await.unwrap().unwrap();
|
|
assert_eq!(&retrieved_c[..], &record_c);
|
|
|
|
assert!(
|
|
store.get(&cid_shared).await.unwrap().is_some(),
|
|
"shared-content block data should still exist, tombstoned but not GC'd"
|
|
);
|
|
|
|
let loaded_mst = Mst::load(storage.clone(), mst_root_v2, None);
|
|
let expected_entries: Vec<(&str, Option<Cid>)> = vec![
|
|
("app.bsky.feed.post/aaaa", Some(cid_a_v2)),
|
|
("app.bsky.feed.post/bbbb", None),
|
|
("app.bsky.feed.post/cccc", Some(cid_c)),
|
|
("app.bsky.feed.post/dddd", Some(cid_shared)),
|
|
("app.bsky.feed.post/eeee", None),
|
|
];
|
|
|
|
futures::stream::iter(expected_entries)
|
|
.for_each(|(key, expected)| {
|
|
let loaded_mst = &loaded_mst;
|
|
async move {
|
|
assert_eq!(loaded_mst.get(key).await.unwrap(), expected);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn mst_survives_gc_after_structural_mutations() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let store = TranquilBlockStore::open(BlockStoreConfig {
|
|
data_dir: dir.path().join("data"),
|
|
index_dir: dir.path().join("index"),
|
|
max_file_size: 64 * 1024,
|
|
group_commit: GroupCommitConfig::default(),
|
|
shard_count: 1,
|
|
})
|
|
.unwrap();
|
|
let storage = Arc::new(store.clone());
|
|
|
|
let record_count = 5_000u32;
|
|
let records: Vec<(String, Vec<u8>)> = (0..record_count)
|
|
.map(|i| {
|
|
let key = format!("app.bsky.feed.post/{i:010}");
|
|
let data = make_record(&format!("post {i} padding {}", "x".repeat(64)));
|
|
(key, data)
|
|
})
|
|
.collect();
|
|
|
|
let mut mst = Mst::new(storage.clone());
|
|
let mut record_cids: BTreeMap<String, Cid> = BTreeMap::new();
|
|
|
|
for (key, data) in &records {
|
|
let cid = storage.put(data).await.unwrap();
|
|
record_cids.insert(key.clone(), cid);
|
|
mst = mst.add(key, cid).await.unwrap();
|
|
}
|
|
|
|
let mst_root_v1 = mst.persist().await.unwrap();
|
|
|
|
let signing_key = test_signing_key();
|
|
let did = Did::new("did:plc:testgcmst").unwrap();
|
|
let mut ticker = Ticker::new();
|
|
let rev1 = ticker.next(None);
|
|
|
|
let commit_v1 = Commit::new_unsigned(did.clone(), mst_root_v1, rev1.clone(), None)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_v1_cbor = commit_v1.to_cbor().unwrap();
|
|
let commit_v1_cid = compute_cid(&commit_v1_cbor);
|
|
|
|
let empty_mst = Mst::new(storage.clone());
|
|
let diff_v1 = empty_mst.diff(&mst).await.unwrap();
|
|
let mut blocks_v1 = diff_v1.new_mst_blocks.clone();
|
|
blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor));
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_v1_cid,
|
|
rev: rev1.clone(),
|
|
since: None,
|
|
prev: None,
|
|
data: mst_root_v1,
|
|
prev_data: None,
|
|
blocks: blocks_v1,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let delete_indices: Vec<u32> = (0..record_count)
|
|
.filter(|i| i % 3 == 0 || i % 7 == 0)
|
|
.collect();
|
|
let keep_indices: Vec<u32> = (0..record_count)
|
|
.filter(|i| i % 3 != 0 && i % 7 != 0)
|
|
.collect();
|
|
|
|
let old_mst = mst.clone();
|
|
for &i in &delete_indices {
|
|
let key = format!("app.bsky.feed.post/{i:010}");
|
|
mst = mst.delete(&key).await.unwrap();
|
|
}
|
|
|
|
let mst_root_v2 = mst.persist().await.unwrap();
|
|
let diff_v2 = old_mst.diff(&mst).await.unwrap();
|
|
|
|
let rev2 = ticker.next(Some(rev1.clone()));
|
|
let commit_v2 =
|
|
Commit::new_unsigned(did.clone(), mst_root_v2, rev2.clone(), Some(commit_v1_cid))
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_v2_cbor = commit_v2.to_cbor().unwrap();
|
|
let commit_v2_cid = compute_cid(&commit_v2_cbor);
|
|
|
|
let mut blocks_v2 = diff_v2.new_mst_blocks.clone();
|
|
blocks_v2.insert(commit_v2_cid, Bytes::from(commit_v2_cbor));
|
|
|
|
let mut deleted: Vec<Cid> = diff_v2.removed_mst_blocks.clone();
|
|
deleted.extend(diff_v2.removed_cids.iter());
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_v2_cid,
|
|
rev: rev2.clone(),
|
|
since: Some(rev1.clone()),
|
|
prev: Some(commit_v1_cid),
|
|
data: mst_root_v2,
|
|
prev_data: Some(mst_root_v1),
|
|
blocks: blocks_v2,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: deleted,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let gc_store = store.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
advance_epoch(&gc_store);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
advance_epoch(&gc_store);
|
|
compact_all_sealed(&gc_store);
|
|
advance_epoch(&gc_store);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
compact_all_sealed(&gc_store);
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let post_gc_mst = Mst::load(storage.clone(), mst_root_v2, None);
|
|
|
|
let surviving_leaves = post_gc_mst.leaves().await.unwrap();
|
|
assert_eq!(
|
|
surviving_leaves.len(),
|
|
keep_indices.len(),
|
|
"leaf count mismatch after GC: expected {} surviving records, got {}",
|
|
keep_indices.len(),
|
|
surviving_leaves.len()
|
|
);
|
|
|
|
futures::stream::iter(&keep_indices)
|
|
.for_each(|&i| {
|
|
let post_gc_mst = &post_gc_mst;
|
|
let record_cids = &record_cids;
|
|
let storage = &storage;
|
|
async move {
|
|
let key = format!("app.bsky.feed.post/{i:010}");
|
|
let expected_cid = record_cids[&key];
|
|
let found = post_gc_mst
|
|
.get(&key)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("MST traversal failed for {key} after GC: {e}"));
|
|
assert_eq!(
|
|
found,
|
|
Some(expected_cid),
|
|
"record {key} missing from MST after GC compaction"
|
|
);
|
|
|
|
let block = storage
|
|
.get(&expected_cid)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("block read failed for {key} (cid={expected_cid}) after GC: {e}"));
|
|
assert!(
|
|
block.is_some(),
|
|
"record block for {key} (cid={expected_cid}) was collected by GC despite being reachable"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
|
|
futures::stream::iter(&delete_indices)
|
|
.for_each(|&i| {
|
|
let post_gc_mst = &post_gc_mst;
|
|
async move {
|
|
let key = format!("app.bsky.feed.post/{i:010}");
|
|
let found = post_gc_mst.get(&key).await.unwrap();
|
|
assert_eq!(
|
|
found, None,
|
|
"deleted record {key} still present in MST after GC"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let all_node_cids = post_gc_mst.collect_node_cids().await.unwrap();
|
|
futures::stream::iter(all_node_cids)
|
|
.for_each(|node_cid| {
|
|
let storage = &storage;
|
|
async move {
|
|
let block = storage.get(&node_cid).await.unwrap();
|
|
assert!(
|
|
block.is_some(),
|
|
"MST internal node {node_cid} was collected by GC despite being reachable from root"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn mst_survives_multiple_mutation_gc_cycles() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let store = TranquilBlockStore::open(BlockStoreConfig {
|
|
data_dir: dir.path().join("data"),
|
|
index_dir: dir.path().join("index"),
|
|
max_file_size: 64 * 1024,
|
|
group_commit: GroupCommitConfig::default(),
|
|
shard_count: 1,
|
|
})
|
|
.unwrap();
|
|
let storage = Arc::new(store.clone());
|
|
|
|
let collections = [
|
|
"app.bsky.feed.post",
|
|
"app.bsky.feed.like",
|
|
"app.bsky.feed.repost",
|
|
"app.bsky.graph.follow",
|
|
"app.bsky.graph.block",
|
|
];
|
|
let per_collection = 600u32;
|
|
let records: Vec<(String, Vec<u8>)> = collections
|
|
.iter()
|
|
.flat_map(|col| {
|
|
(0..per_collection).map(move |i| {
|
|
let key = format!("{col}/{i:010}");
|
|
let data = make_record(&format!("{col} record {i} padding {}", "y".repeat(64)));
|
|
(key, data)
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let mut mst = Mst::new(storage.clone());
|
|
let mut live_cids: BTreeMap<String, Cid> = BTreeMap::new();
|
|
|
|
for (key, data) in &records {
|
|
let cid = storage.put(data).await.unwrap();
|
|
live_cids.insert(key.clone(), cid);
|
|
mst = mst.add(key, cid).await.unwrap();
|
|
}
|
|
|
|
let mst_root = mst.persist().await.unwrap();
|
|
|
|
let signing_key = test_signing_key();
|
|
let did = Did::new("did:plc:testcycles").unwrap();
|
|
let mut ticker = Ticker::new();
|
|
let rev = ticker.next(None);
|
|
|
|
let commit = Commit::new_unsigned(did.clone(), mst_root, rev.clone(), None)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_cbor = commit.to_cbor().unwrap();
|
|
let commit_cid = compute_cid(&commit_cbor);
|
|
|
|
let empty_mst = Mst::new(storage.clone());
|
|
let diff = empty_mst.diff(&mst).await.unwrap();
|
|
let mut blocks = diff.new_mst_blocks.clone();
|
|
blocks.insert(commit_cid, Bytes::from(commit_cbor));
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_cid,
|
|
rev: rev.clone(),
|
|
since: None,
|
|
prev: None,
|
|
data: mst_root,
|
|
prev_data: None,
|
|
blocks,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut prev_rev = rev;
|
|
let mut prev_cid = commit_cid;
|
|
let mut prev_root = mst_root;
|
|
|
|
let cycles = 15u32;
|
|
let mut cycle_seed = 0u32;
|
|
|
|
for cycle in 0..cycles {
|
|
let old_mst = mst.clone();
|
|
|
|
let delete_keys: Vec<String> = live_cids
|
|
.keys()
|
|
.enumerate()
|
|
.filter(|(idx, _)| {
|
|
let hash = (*idx as u32)
|
|
.wrapping_mul(2654435761)
|
|
.wrapping_add(cycle_seed);
|
|
hash.is_multiple_of(5)
|
|
})
|
|
.map(|(_, k)| k.clone())
|
|
.collect();
|
|
|
|
for key in &delete_keys {
|
|
mst = mst.delete(key).await.unwrap();
|
|
live_cids.remove(key);
|
|
}
|
|
|
|
let add_count = delete_keys.len().min(200);
|
|
let new_records: Vec<(String, Vec<u8>)> = (0..add_count)
|
|
.map(|i| {
|
|
cycle_seed = cycle_seed.wrapping_add(1);
|
|
let key = format!("app.bsky.feed.post/new_{cycle}_{i:06}");
|
|
let data = make_record(&format!("new record cycle {cycle} item {i}"));
|
|
(key, data)
|
|
})
|
|
.collect();
|
|
|
|
for (key, data) in &new_records {
|
|
let cid = storage.put(data).await.unwrap();
|
|
live_cids.insert(key.clone(), cid);
|
|
mst = mst.add(key, cid).await.unwrap();
|
|
}
|
|
|
|
let new_root = mst.persist().await.unwrap();
|
|
let diff = old_mst.diff(&mst).await.unwrap();
|
|
|
|
let rev = ticker.next(Some(prev_rev.clone()));
|
|
let commit = Commit::new_unsigned(did.clone(), new_root, rev.clone(), Some(prev_cid))
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_cbor = commit.to_cbor().unwrap();
|
|
let new_commit_cid = compute_cid(&commit_cbor);
|
|
|
|
let mut commit_blocks = diff.new_mst_blocks.clone();
|
|
commit_blocks.insert(new_commit_cid, Bytes::from(commit_cbor));
|
|
new_records.iter().for_each(|(_, data)| {
|
|
let cid = compute_cid(data);
|
|
commit_blocks.insert(cid, Bytes::from(data.clone()));
|
|
});
|
|
|
|
let mut deleted: Vec<Cid> = diff.removed_mst_blocks.clone();
|
|
deleted.extend(diff.removed_cids.iter());
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: new_commit_cid,
|
|
rev: rev.clone(),
|
|
since: Some(prev_rev.clone()),
|
|
prev: Some(prev_cid),
|
|
data: new_root,
|
|
prev_data: Some(prev_root),
|
|
blocks: commit_blocks,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: deleted,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let gc_store = store.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
advance_epoch(&gc_store);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
advance_epoch(&gc_store);
|
|
compact_all_sealed(&gc_store);
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
prev_rev = rev;
|
|
prev_cid = new_commit_cid;
|
|
prev_root = new_root;
|
|
cycle_seed = cycle_seed.wrapping_add(7);
|
|
}
|
|
|
|
let final_mst = Mst::load(storage.clone(), prev_root, None);
|
|
|
|
let final_leaves = final_mst.leaves().await.unwrap();
|
|
assert_eq!(
|
|
final_leaves.len(),
|
|
live_cids.len(),
|
|
"after {cycles} mutation+GC cycles: expected {} leaves, got {}",
|
|
live_cids.len(),
|
|
final_leaves.len()
|
|
);
|
|
|
|
futures::stream::iter(live_cids.iter())
|
|
.for_each(|(key, expected_cid)| {
|
|
let final_mst = &final_mst;
|
|
let storage = &storage;
|
|
let expected_cid = *expected_cid;
|
|
async move {
|
|
let found = final_mst.get(key.as_str()).await.unwrap_or_else(|e| {
|
|
panic!("MST traversal failed for {key} after {cycles} GC cycles: {e}")
|
|
});
|
|
assert_eq!(
|
|
found,
|
|
Some(expected_cid),
|
|
"record {key} missing from MST after {cycles} mutation+GC cycles"
|
|
);
|
|
|
|
let block = storage.get(&expected_cid).await.unwrap();
|
|
assert!(
|
|
block.is_some(),
|
|
"record block for {key} (cid={expected_cid}) was collected by GC"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let all_node_cids = final_mst.collect_node_cids().await.unwrap();
|
|
futures::stream::iter(all_node_cids)
|
|
.for_each(|node_cid| {
|
|
let storage = &storage;
|
|
async move {
|
|
let block = storage.get(&node_cid).await.unwrap();
|
|
assert!(
|
|
block.is_some(),
|
|
"MST internal node {node_cid} was collected by GC after {cycles} cycles"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn mst_survives_gc_with_split_batches_and_tiny_files() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let store = TranquilBlockStore::open(BlockStoreConfig {
|
|
data_dir: dir.path().join("data"),
|
|
index_dir: dir.path().join("index"),
|
|
max_file_size: 4096,
|
|
group_commit: GroupCommitConfig::default(),
|
|
shard_count: 1,
|
|
})
|
|
.unwrap();
|
|
let storage = Arc::new(store.clone());
|
|
|
|
let record_count = 50u32;
|
|
let records: Vec<(String, Vec<u8>)> = (0..record_count)
|
|
.map(|i| {
|
|
let key = format!("app.bsky.feed.post/{i:010}");
|
|
let data = make_record(&format!("post {i} padding {}", "x".repeat(64)));
|
|
(key, data)
|
|
})
|
|
.collect();
|
|
|
|
let mut mst = Mst::new(storage.clone());
|
|
let mut record_cids: BTreeMap<String, Cid> = BTreeMap::new();
|
|
|
|
for (key, data) in &records {
|
|
let cid = storage.put(data).await.unwrap();
|
|
record_cids.insert(key.clone(), cid);
|
|
mst = mst.add(key, cid).await.unwrap();
|
|
}
|
|
|
|
let mst_root_v1 = mst.persist().await.unwrap();
|
|
|
|
let signing_key = test_signing_key();
|
|
let did = Did::new("did:plc:testsplitbatch").unwrap();
|
|
let mut ticker = Ticker::new();
|
|
let rev1 = ticker.next(None);
|
|
|
|
let commit_v1 = Commit::new_unsigned(did.clone(), mst_root_v1, rev1.clone(), None)
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_v1_cbor = commit_v1.to_cbor().unwrap();
|
|
let commit_v1_cid = compute_cid(&commit_v1_cbor);
|
|
|
|
let empty_mst = Mst::new(storage.clone());
|
|
let diff_v1 = empty_mst.diff(&mst).await.unwrap();
|
|
let mut blocks_v1 = diff_v1.new_mst_blocks.clone();
|
|
blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor));
|
|
|
|
store
|
|
.apply_commit(CommitData {
|
|
cid: commit_v1_cid,
|
|
rev: rev1.clone(),
|
|
since: None,
|
|
prev: None,
|
|
data: mst_root_v1,
|
|
prev_data: None,
|
|
blocks: blocks_v1,
|
|
relevant_blocks: BTreeMap::new(),
|
|
deleted_cids: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut prev_rev = rev1;
|
|
let mut prev_cid = commit_v1_cid;
|
|
let mut prev_root = mst_root_v1;
|
|
|
|
for cycle in 0..10u32 {
|
|
let delete_keys: Vec<String> = record_cids
|
|
.keys()
|
|
.enumerate()
|
|
.filter(|(idx, _)| {
|
|
let hash = (*idx as u32).wrapping_mul(2654435761).wrapping_add(cycle);
|
|
hash % 4 == 0
|
|
})
|
|
.map(|(_, k)| k.clone())
|
|
.collect();
|
|
|
|
for key in &delete_keys {
|
|
mst = mst.delete(key).await.unwrap();
|
|
record_cids.remove(key);
|
|
}
|
|
|
|
let add_count = delete_keys.len().min(10);
|
|
let new_records: Vec<(String, Vec<u8>)> = (0..add_count)
|
|
.map(|i| {
|
|
let key = format!("app.bsky.feed.post/cyc{cycle}_{i:06}");
|
|
let data = make_record(&format!("cycle {cycle} record {i}"));
|
|
(key, data)
|
|
})
|
|
.collect();
|
|
|
|
for (key, data) in &new_records {
|
|
let cid = storage.put(data).await.unwrap();
|
|
record_cids.insert(key.clone(), cid);
|
|
mst = mst.add(key, cid).await.unwrap();
|
|
}
|
|
|
|
let new_root = mst.persist().await.unwrap();
|
|
|
|
let old_settled = Mst::load(storage.clone(), prev_root, None);
|
|
let new_settled = Mst::load(storage.clone(), new_root, None);
|
|
let (old_nodes, new_nodes, old_leaves, new_leaves) = tokio::try_join!(
|
|
old_settled.collect_node_cids(),
|
|
new_settled.collect_node_cids(),
|
|
old_settled.leaves(),
|
|
new_settled.leaves(),
|
|
)
|
|
.unwrap();
|
|
let old_set: std::collections::BTreeSet<Cid> = old_nodes.into_iter().collect();
|
|
let new_set: std::collections::BTreeSet<Cid> = new_nodes.into_iter().collect();
|
|
let old_leaf_set: std::collections::BTreeSet<Cid> =
|
|
old_leaves.iter().map(|(_, c)| *c).collect();
|
|
let new_leaf_set: std::collections::BTreeSet<Cid> =
|
|
new_leaves.iter().map(|(_, c)| *c).collect();
|
|
let obsolete: Vec<Cid> = std::iter::once(prev_cid)
|
|
.chain(old_set.difference(&new_set).copied())
|
|
.chain(old_leaf_set.difference(&new_leaf_set).copied())
|
|
.collect();
|
|
|
|
let rev = ticker.next(Some(prev_rev.clone()));
|
|
let commit = Commit::new_unsigned(did.clone(), new_root, rev.clone(), Some(prev_cid))
|
|
.sign(&signing_key)
|
|
.unwrap();
|
|
let commit_cbor = commit.to_cbor().unwrap();
|
|
let new_commit_cid = compute_cid(&commit_cbor);
|
|
|
|
storage.put(&commit_cbor).await.unwrap();
|
|
|
|
store.decrement_refs(&obsolete).await.unwrap();
|
|
|
|
let gc_store = store.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
advance_epoch(&gc_store);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
advance_epoch(&gc_store);
|
|
compact_all_sealed(&gc_store);
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let verify_mst = Mst::load(storage.clone(), new_root, None);
|
|
let verify_nodes = verify_mst
|
|
.collect_node_cids()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("cycle {cycle}: MST node walk failed after GC: {e}"));
|
|
|
|
futures::stream::iter(verify_nodes)
|
|
.for_each(|node_cid| {
|
|
let storage = &storage;
|
|
async move {
|
|
let block = storage.get(&node_cid).await.unwrap();
|
|
assert!(
|
|
block.is_some(),
|
|
"cycle {cycle}: MST node {node_cid} missing after GC"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
|
|
prev_rev = rev;
|
|
prev_cid = new_commit_cid;
|
|
prev_root = new_root;
|
|
}
|
|
|
|
let final_mst = Mst::load(storage.clone(), prev_root, None);
|
|
let final_leaves = final_mst.leaves().await.unwrap();
|
|
assert_eq!(final_leaves.len(), record_cids.len());
|
|
}
|