forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
12 kB
372 lines
1use std::path::Path;
2
3use proptest::prelude::*;
4use rayon::prelude::*;
5use tranquil_store::metastore::recovery::{
6 BacklinkMutation, CommitMutationSet, RecordMutationDelete, RecordMutationUpsert,
7};
8use tranquil_store::metastore::{Metastore, MetastoreConfig};
9use tranquil_store::{sim_proptest_cases, sim_seed_range};
10use tranquil_types::{CidLink, Did, Handle};
11use uuid::Uuid;
12
13const NAMES: &[&str] = &["olaren", "teq", "nel", "lyna", "bailey"];
14
15fn test_config() -> MetastoreConfig {
16 MetastoreConfig {
17 cache_size_bytes: 16 * 1024 * 1024,
18 }
19}
20
21fn open_metastore(path: &Path) -> Metastore {
22 Metastore::open(path, test_config()).unwrap()
23}
24
25fn test_did(seed: u64) -> Did {
26 let name = NAMES[(seed as usize) % NAMES.len()];
27 Did::from(format!("did:plc:{name}{seed}"))
28}
29
30fn test_handle(seed: u64) -> Handle {
31 let name = NAMES[(seed as usize) % NAMES.len()];
32 Handle::new(format!("{name}{seed}.test")).unwrap()
33}
34
35fn test_cid_link(seed: u8) -> CidLink {
36 let digest: [u8; 32] = std::array::from_fn(|i| seed.wrapping_add(i as u8));
37 let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap();
38 let c = cid::Cid::new_v1(0x71, mh);
39 CidLink::from_cid(&c)
40}
41
42fn test_uuid(seed: u64) -> Uuid {
43 Uuid::from_u128(seed as u128 | 0x4000_0000_0000_0000_8000_0000_0000_0000)
44}
45
46fn arb_mutation_set() -> impl Strategy<Value = CommitMutationSet> {
47 let arb_upsert = (
48 "[a-z\\.]{5,20}",
49 "[a-z0-9]{3,10}",
50 prop::collection::vec(any::<u8>(), 4..36),
51 )
52 .prop_map(|(collection, rkey, cid_bytes)| RecordMutationUpsert {
53 collection,
54 rkey,
55 cid_bytes,
56 });
57
58 let arb_delete = ("[a-z\\.]{5,20}", "[a-z0-9]{3,10}")
59 .prop_map(|(collection, rkey)| RecordMutationDelete { collection, rkey });
60
61 let arb_backlink = (
62 "at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}",
63 0u8..4,
64 "at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}",
65 )
66 .prop_map(|(uri, path, link_to)| BacklinkMutation { uri, path, link_to });
67
68 (
69 prop::collection::vec(any::<u8>(), 0..64),
70 "[a-z0-9]{1,16}",
71 prop::collection::vec(arb_upsert, 0..20),
72 prop::collection::vec(arb_delete, 0..20),
73 prop::collection::vec(prop::collection::vec(any::<u8>(), 4..36), 0..20),
74 prop::collection::vec(prop::collection::vec(any::<u8>(), 4..36), 0..20),
75 prop::collection::vec(arb_backlink, 0..5),
76 prop::collection::vec("at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}", 0..5),
77 )
78 .prop_map(
79 |(
80 new_root_cid,
81 new_rev,
82 record_upserts,
83 record_deletes,
84 block_inserts,
85 block_deletes,
86 backlink_adds,
87 backlink_remove_uris,
88 )| {
89 CommitMutationSet {
90 new_root_cid,
91 new_rev,
92 record_upserts,
93 record_deletes,
94 block_inserts,
95 block_deletes,
96 backlink_adds,
97 backlink_remove_uris,
98 }
99 },
100 )
101}
102
103proptest! {
104 #![proptest_config(ProptestConfig::with_cases(sim_proptest_cases()))]
105
106 #[test]
107 fn mutation_set_roundtrip_fuzz(ms in arb_mutation_set()) {
108 let serialized = ms.serialize().unwrap();
109 let recovered = CommitMutationSet::deserialize(&serialized).unwrap();
110 prop_assert_eq!(recovered, ms);
111 }
112
113 #[test]
114 fn mutation_set_rejects_corrupt_version(ms in arb_mutation_set()) {
115 let mut bytes = ms.serialize().unwrap();
116 bytes[0] = 0xFF;
117 prop_assert!(CommitMutationSet::deserialize(&bytes).is_none());
118 }
119
120 #[test]
121 fn mutation_set_truncation_detected(
122 ms in arb_mutation_set(),
123 truncate_at in 0usize..64,
124 ) {
125 let bytes = ms.serialize().unwrap();
126 let cut = truncate_at.min(bytes.len().saturating_sub(1));
127 match cut {
128 0 => prop_assert!(CommitMutationSet::deserialize(&bytes[..0]).is_none()),
129 n => {
130 let truncated = &bytes[..n];
131 match CommitMutationSet::deserialize(truncated) {
132 None => {}
133 Some(recovered) => prop_assert_eq!(recovered, ms),
134 }
135 }
136 }
137 }
138}
139
140#[test]
141fn metastore_survives_abrupt_drop() {
142 sim_seed_range().into_par_iter().for_each(|seed| {
143 let dir = tempfile::TempDir::new().unwrap();
144 let user_count = (seed % 5) + 1;
145
146 let user_ids: Vec<Uuid> = (0..user_count).map(|i| test_uuid(seed * 100 + i)).collect();
147
148 {
149 let ms = open_metastore(dir.path());
150 let repo_ops = ms.repo_ops();
151 let db = ms.database();
152
153 user_ids.iter().enumerate().for_each(|(i, &uid)| {
154 let idx = seed * 100 + i as u64;
155 let did = test_did(idx);
156 let handle = test_handle(idx);
157 let cid = test_cid_link((idx & 0xFF) as u8);
158 let rev = format!("rev{idx}");
159 repo_ops
160 .create_repo(db, uid, &did, &handle, &cid, &rev)
161 .unwrap();
162 });
163 }
164
165 {
166 let ms = open_metastore(dir.path());
167 let repo_ops = ms.repo_ops();
168
169 user_ids.iter().enumerate().for_each(|(i, &uid)| {
170 let result = repo_ops.get_repo_meta(uid).unwrap();
171 assert!(
172 result.is_some(),
173 "seed={seed} user {i} repo_meta missing after abrupt drop"
174 );
175 let (_, meta) = result.unwrap();
176 let expected_rev = format!("rev{}", seed * 100 + i as u64);
177 assert_eq!(
178 meta.repo_rev, expected_rev,
179 "seed={seed} user {i} rev mismatch"
180 );
181 });
182 }
183 });
184}
185
186#[test]
187fn metastore_multi_crash_cycle() {
188 sim_seed_range().into_par_iter().for_each(|seed| {
189 let dir = tempfile::TempDir::new().unwrap();
190 let cycles = (seed % 4) + 2;
191 let mut expected_repos: Vec<(Uuid, u64)> = Vec::new();
192
193 (0..cycles).for_each(|cycle| {
194 let new_per_cycle = (seed.wrapping_add(cycle) % 3) + 1;
195
196 {
197 let ms = open_metastore(dir.path());
198
199 expected_repos.iter().for_each(|(uid, idx)| {
200 let meta = ms.repo_ops().get_repo_meta(*uid).unwrap();
201 assert!(
202 meta.is_some(),
203 "seed={seed} cycle={cycle} user idx={idx} missing before new writes"
204 );
205 });
206
207 let repo_ops = ms.repo_ops();
208 let db = ms.database();
209 (0..new_per_cycle).for_each(|i| {
210 let idx = seed * 1000 + cycle * 100 + i;
211 let uid = test_uuid(idx);
212 let did = test_did(idx);
213 let handle = test_handle(idx);
214 let cid = test_cid_link((idx & 0xFF) as u8);
215 repo_ops
216 .create_repo(db, uid, &did, &handle, &cid, &format!("rev{idx}"))
217 .unwrap();
218 expected_repos.push((uid, idx));
219 });
220 }
221 });
222
223 {
224 let ms = open_metastore(dir.path());
225 expected_repos.iter().for_each(|(uid, idx)| {
226 let meta = ms.repo_ops().get_repo_meta(*uid).unwrap();
227 assert!(
228 meta.is_some(),
229 "seed={seed} final verify: user idx={idx} missing"
230 );
231 });
232 }
233 });
234}
235
236#[test]
237fn metastore_persisted_survives_unpersisted_lost() {
238 sim_seed_range().into_par_iter().for_each(|seed| {
239 let dir = tempfile::TempDir::new().unwrap();
240 let batch_size = (seed % 5) + 2;
241
242 {
243 let ms = open_metastore(dir.path());
244 let db = ms.database();
245 let repo_ops = ms.repo_ops();
246
247 (0..batch_size).for_each(|i| {
248 let idx = seed * 100 + i;
249 repo_ops
250 .create_repo(
251 db,
252 test_uuid(idx),
253 &test_did(idx),
254 &test_handle(idx),
255 &test_cid_link((idx & 0xFF) as u8),
256 &format!("rev{idx}"),
257 )
258 .unwrap();
259 });
260
261 ms.persist().unwrap();
262
263 let extra_idx = seed * 100 + batch_size;
264 repo_ops
265 .create_repo(
266 db,
267 test_uuid(extra_idx),
268 &test_did(extra_idx),
269 &test_handle(extra_idx),
270 &test_cid_link((extra_idx & 0xFF) as u8),
271 &format!("rev{extra_idx}"),
272 )
273 .unwrap();
274 }
275
276 {
277 let ms = open_metastore(dir.path());
278 let repo_ops = ms.repo_ops();
279
280 (0..batch_size).for_each(|i| {
281 let idx = seed * 100 + i;
282 let meta = repo_ops.get_repo_meta(test_uuid(idx)).unwrap();
283 assert!(
284 meta.is_some(),
285 "seed={seed} persisted user idx={idx} must survive crash"
286 );
287 });
288 }
289 });
290}
291
292#[test]
293fn metastore_user_hashes_reload_after_crash() {
294 sim_seed_range().into_par_iter().for_each(|seed| {
295 let dir = tempfile::TempDir::new().unwrap();
296 let count = (seed % 5) + 1;
297 let name = NAMES[(seed as usize) % NAMES.len()];
298
299 let entries: Vec<(Uuid, u64)> = (0..count)
300 .map(|i| {
301 let idx = seed * 100 + i;
302 (test_uuid(idx), idx)
303 })
304 .collect();
305
306 {
307 let ms = open_metastore(dir.path());
308 let db = ms.database();
309 let repo_ops = ms.repo_ops();
310
311 entries.iter().for_each(|&(uid, idx)| {
312 repo_ops
313 .create_repo(
314 db,
315 uid,
316 &test_did(idx),
317 &test_handle(idx),
318 &test_cid_link((idx & 0xFF) as u8),
319 &format!("rev{idx}"),
320 )
321 .unwrap();
322 });
323 }
324
325 {
326 let ms = open_metastore(dir.path());
327
328 entries.iter().for_each(|&(uid, idx)| {
329 let hash = ms.user_hashes().get(&uid);
330 assert!(
331 hash.is_some(),
332 "seed={seed} name={name} user_hash for idx={idx} not reloaded after crash"
333 );
334 });
335 }
336 });
337}
338
339#[test]
340fn metastore_handle_lookup_survives_crash() {
341 sim_seed_range().into_par_iter().for_each(|seed| {
342 let dir = tempfile::TempDir::new().unwrap();
343 let idx = seed;
344 let uid = test_uuid(idx);
345 let did = test_did(idx);
346 let handle = test_handle(idx);
347
348 {
349 let ms = open_metastore(dir.path());
350 ms.repo_ops()
351 .create_repo(
352 ms.database(),
353 uid,
354 &did,
355 &handle,
356 &test_cid_link((idx & 0xFF) as u8),
357 &format!("rev{idx}"),
358 )
359 .unwrap();
360 }
361
362 {
363 let ms = open_metastore(dir.path());
364 let resolved = ms.repo_ops().lookup_handle(&handle).unwrap();
365 assert!(
366 resolved.is_some(),
367 "seed={seed} handle '{}' not resolvable after crash",
368 handle.as_str()
369 );
370 }
371 });
372}