Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tranquil-store): blockstore

Lewis: May this revision serve well! <lu5a@proton.me>

+9771 -406
+259 -4
Cargo.lock
··· 1041 1041 ] 1042 1042 1043 1043 [[package]] 1044 + name = "bit-set" 1045 + version = "0.8.0" 1046 + source = "registry+https://github.com/rust-lang/crates.io-index" 1047 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" 1048 + dependencies = [ 1049 + "bit-vec", 1050 + ] 1051 + 1052 + [[package]] 1053 + name = "bit-vec" 1054 + version = "0.8.0" 1055 + source = "registry+https://github.com/rust-lang/crates.io-index" 1056 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 1057 + 1058 + [[package]] 1044 1059 name = "bitflags" 1045 1060 version = "2.11.0" 1046 1061 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1275 1290 ] 1276 1291 1277 1292 [[package]] 1293 + name = "byteview" 1294 + version = "0.10.1" 1295 + source = "registry+https://github.com/rust-lang/crates.io-index" 1296 + checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" 1297 + 1298 + [[package]] 1278 1299 name = "cbc" 1279 1300 version = "0.1.2" 1280 1301 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1498 1519 ] 1499 1520 1500 1521 [[package]] 1522 + name = "compare" 1523 + version = "0.0.6" 1524 + source = "registry+https://github.com/rust-lang/crates.io-index" 1525 + checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" 1526 + 1527 + [[package]] 1501 1528 name = "compression-codecs" 1502 1529 version = "0.4.37" 1503 1530 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1716 1743 ] 1717 1744 1718 1745 [[package]] 1746 + name = "crossbeam-skiplist" 1747 + version = "0.1.3" 1748 + source = "registry+https://github.com/rust-lang/crates.io-index" 1749 + checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" 1750 + dependencies = [ 1751 + "crossbeam-epoch", 1752 + "crossbeam-utils", 1753 + ] 1754 + 1755 + [[package]] 1719 1756 name = "crossbeam-utils" 1720 1757 version = "0.8.21" 1721 1758 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2213 2250 ] 2214 2251 2215 2252 [[package]] 2253 + name = "enum_dispatch" 2254 + version = "0.3.13" 2255 + source = "registry+https://github.com/rust-lang/crates.io-index" 2256 + checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" 2257 + dependencies = [ 2258 + "once_cell", 2259 + "proc-macro2", 2260 + "quote", 2261 + "syn 2.0.117", 2262 + ] 2263 + 2264 + [[package]] 2216 2265 name = "equivalent" 2217 2266 version = "1.0.2" 2218 2267 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2344 2393 version = "0.5.7" 2345 2394 source = "registry+https://github.com/rust-lang/crates.io-index" 2346 2395 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 2396 + 2397 + [[package]] 2398 + name = "fjall" 2399 + version = "3.1.2" 2400 + source = "registry+https://github.com/rust-lang/crates.io-index" 2401 + checksum = "1a9530ff159bc3ad3a15da746da0f6e95375c2ac64708cbb85ec1ebd26761a84" 2402 + dependencies = [ 2403 + "byteorder-lite", 2404 + "byteview", 2405 + "dashmap", 2406 + "flume 0.12.0", 2407 + "log", 2408 + "lsm-tree", 2409 + "lz4_flex", 2410 + "tempfile", 2411 + "xxhash-rust", 2412 + ] 2347 2413 2348 2414 [[package]] 2349 2415 name = "flate2" ··· 2364 2430 dependencies = [ 2365 2431 "futures-core", 2366 2432 "futures-sink", 2433 + "nanorand", 2434 + "spin 0.9.8", 2435 + ] 2436 + 2437 + [[package]] 2438 + name = "flume" 2439 + version = "0.12.0" 2440 + source = "registry+https://github.com/rust-lang/crates.io-index" 2441 + checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" 2442 + dependencies = [ 2367 2443 "spin 0.9.8", 2368 2444 ] 2369 2445 ··· 3364 3440 checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" 3365 3441 dependencies = [ 3366 3442 "byteorder-lite", 3367 - "quick-error", 3443 + "quick-error 2.0.1", 3368 3444 ] 3369 3445 3370 3446 [[package]] ··· 3419 3495 ] 3420 3496 3421 3497 [[package]] 3498 + name = "interval-heap" 3499 + version = "0.0.5" 3500 + source = "registry+https://github.com/rust-lang/crates.io-index" 3501 + checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" 3502 + dependencies = [ 3503 + "compare", 3504 + ] 3505 + 3506 + [[package]] 3422 3507 name = "inventory" 3423 3508 version = "0.3.22" 3424 3509 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3458 3543 3459 3544 [[package]] 3460 3545 name = "iri-string" 3461 - version = "0.7.10" 3546 + version = "0.7.11" 3462 3547 source = "registry+https://github.com/rust-lang/crates.io-index" 3463 - checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" 3548 + checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" 3464 3549 dependencies = [ 3465 3550 "memchr", 3466 3551 "serde", ··· 4053 4138 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 4054 4139 4055 4140 [[package]] 4141 + name = "lsm-tree" 4142 + version = "3.1.2" 4143 + source = "registry+https://github.com/rust-lang/crates.io-index" 4144 + checksum = "9d67f95fd716870329c30aaeedf87f23d426564e6ce46efa045a91444faf2a19" 4145 + dependencies = [ 4146 + "byteorder-lite", 4147 + "byteview", 4148 + "crossbeam-skiplist", 4149 + "enum_dispatch", 4150 + "interval-heap", 4151 + "log", 4152 + "lz4_flex", 4153 + "quick_cache", 4154 + "rustc-hash", 4155 + "self_cell", 4156 + "sfa", 4157 + "tempfile", 4158 + "varint-rs", 4159 + "xxhash-rust", 4160 + ] 4161 + 4162 + [[package]] 4163 + name = "lz4_flex" 4164 + version = "0.13.0" 4165 + source = "registry+https://github.com/rust-lang/crates.io-index" 4166 + checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" 4167 + dependencies = [ 4168 + "twox-hash", 4169 + ] 4170 + 4171 + [[package]] 4056 4172 name = "match-lookup" 4057 4173 version = "0.1.2" 4058 4174 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4263 4379 "wasm-bindgen", 4264 4380 "wasm-bindgen-futures", 4265 4381 "web-time", 4382 + ] 4383 + 4384 + [[package]] 4385 + name = "nanorand" 4386 + version = "0.7.0" 4387 + source = "registry+https://github.com/rust-lang/crates.io-index" 4388 + checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 4389 + dependencies = [ 4390 + "getrandom 0.2.17", 4266 4391 ] 4267 4392 4268 4393 [[package]] ··· 4988 5113 ] 4989 5114 4990 5115 [[package]] 5116 + name = "proptest" 5117 + version = "1.10.0" 5118 + source = "registry+https://github.com/rust-lang/crates.io-index" 5119 + checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" 5120 + dependencies = [ 5121 + "bit-set", 5122 + "bit-vec", 5123 + "bitflags", 5124 + "num-traits", 5125 + "rand 0.9.2", 5126 + "rand_chacha 0.9.0", 5127 + "rand_xorshift", 5128 + "regex-syntax 0.8.10", 5129 + "rusty-fork", 5130 + "tempfile", 5131 + "unarray", 5132 + ] 5133 + 5134 + [[package]] 4991 5135 name = "prost" 4992 5136 version = "0.13.5" 4993 5137 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5181 5325 5182 5326 [[package]] 5183 5327 name = "quick-error" 5328 + version = "1.2.3" 5329 + source = "registry+https://github.com/rust-lang/crates.io-index" 5330 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 5331 + 5332 + [[package]] 5333 + name = "quick-error" 5184 5334 version = "2.0.1" 5185 5335 source = "registry+https://github.com/rust-lang/crates.io-index" 5186 5336 checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" ··· 5195 5345 ] 5196 5346 5197 5347 [[package]] 5348 + name = "quick_cache" 5349 + version = "0.6.21" 5350 + source = "registry+https://github.com/rust-lang/crates.io-index" 5351 + checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" 5352 + dependencies = [ 5353 + "equivalent", 5354 + "hashbrown 0.16.1", 5355 + ] 5356 + 5357 + [[package]] 5198 5358 name = "quinn" 5199 5359 version = "0.11.9" 5200 5360 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5345 5505 version = "0.10.0" 5346 5506 source = "registry+https://github.com/rust-lang/crates.io-index" 5347 5507 checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" 5508 + 5509 + [[package]] 5510 + name = "rand_xorshift" 5511 + version = "0.4.0" 5512 + source = "registry+https://github.com/rust-lang/crates.io-index" 5513 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 5514 + dependencies = [ 5515 + "rand_core 0.9.5", 5516 + ] 5348 5517 5349 5518 [[package]] 5350 5519 name = "rand_xoshiro" ··· 5772 5941 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 5773 5942 5774 5943 [[package]] 5944 + name = "rusty-fork" 5945 + version = "0.3.1" 5946 + source = "registry+https://github.com/rust-lang/crates.io-index" 5947 + checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" 5948 + dependencies = [ 5949 + "fnv", 5950 + "quick-error 1.2.3", 5951 + "tempfile", 5952 + "wait-timeout", 5953 + ] 5954 + 5955 + [[package]] 5775 5956 name = "ryu" 5776 5957 version = "1.0.23" 5777 5958 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5882 6063 "core-foundation-sys", 5883 6064 "libc", 5884 6065 ] 6066 + 6067 + [[package]] 6068 + name = "self_cell" 6069 + version = "1.2.2" 6070 + source = "registry+https://github.com/rust-lang/crates.io-index" 6071 + checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" 5885 6072 5886 6073 [[package]] 5887 6074 name = "semver" ··· 6058 6245 ] 6059 6246 6060 6247 [[package]] 6248 + name = "sfa" 6249 + version = "1.0.0" 6250 + source = "registry+https://github.com/rust-lang/crates.io-index" 6251 + checksum = "a1296838937cab56cd6c4eeeb8718ec777383700c33f060e2869867bd01d1175" 6252 + dependencies = [ 6253 + "byteorder-lite", 6254 + "log", 6255 + "xxhash-rust", 6256 + ] 6257 + 6258 + [[package]] 6061 6259 name = "sha1" 6062 6260 version = "0.10.6" 6063 6261 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6486 6684 dependencies = [ 6487 6685 "atoi", 6488 6686 "chrono", 6489 - "flume", 6687 + "flume 0.11.1", 6490 6688 "futures-channel", 6491 6689 "futures-core", 6492 6690 "futures-executor", ··· 7619 7817 ] 7620 7818 7621 7819 [[package]] 7820 + name = "tranquil-store" 7821 + version = "0.4.7" 7822 + dependencies = [ 7823 + "async-trait", 7824 + "bytes", 7825 + "cid", 7826 + "fjall", 7827 + "flume 0.11.1", 7828 + "futures", 7829 + "jacquard-common", 7830 + "jacquard-repo", 7831 + "k256", 7832 + "memmap2", 7833 + "multihash", 7834 + "parking_lot", 7835 + "postcard", 7836 + "proptest", 7837 + "rand 0.8.5", 7838 + "serde", 7839 + "serde_ipld_dagcbor", 7840 + "sha2", 7841 + "sqlx", 7842 + "tempfile", 7843 + "tokio", 7844 + "tracing", 7845 + "tranquil-repo", 7846 + "xxhash-rust", 7847 + ] 7848 + 7849 + [[package]] 7622 7850 name = "tranquil-sync" 7623 7851 version = "0.4.7" 7624 7852 dependencies = [ ··· 7697 7925 ] 7698 7926 7699 7927 [[package]] 7928 + name = "twox-hash" 7929 + version = "2.1.2" 7930 + source = "registry+https://github.com/rust-lang/crates.io-index" 7931 + checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" 7932 + 7933 + [[package]] 7700 7934 name = "typed-path" 7701 7935 version = "0.12.3" 7702 7936 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7707 7941 version = "1.19.0" 7708 7942 source = "registry+https://github.com/rust-lang/crates.io-index" 7709 7943 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 7944 + 7945 + [[package]] 7946 + name = "unarray" 7947 + version = "0.1.4" 7948 + source = "registry+https://github.com/rust-lang/crates.io-index" 7949 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 7710 7950 7711 7951 [[package]] 7712 7952 name = "unicase" ··· 7904 8144 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 7905 8145 7906 8146 [[package]] 8147 + name = "varint-rs" 8148 + version = "2.2.0" 8149 + source = "registry+https://github.com/rust-lang/crates.io-index" 8150 + checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" 8151 + 8152 + [[package]] 7907 8153 name = "vcpkg" 7908 8154 version = "0.2.15" 7909 8155 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7926 8172 version = "0.8.0" 7927 8173 source = "registry+https://github.com/rust-lang/crates.io-index" 7928 8174 checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" 8175 + 8176 + [[package]] 8177 + name = "wait-timeout" 8178 + version = "0.2.1" 8179 + source = "registry+https://github.com/rust-lang/crates.io-index" 8180 + checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" 8181 + dependencies = [ 8182 + "libc", 8183 + ] 7929 8184 7930 8185 [[package]] 7931 8186 name = "want"
+2
Cargo.toml
··· 22 22 "crates/tranquil-api", 23 23 "crates/tranquil-lexicon", 24 24 "crates/tranquil-signal", 25 + "crates/tranquil-store", 25 26 ] 26 27 27 28 [workspace.package] ··· 51 52 tranquil-oauth-server = { path = "crates/tranquil-oauth-server" } 52 53 tranquil-api = { path = "crates/tranquil-api" } 53 54 tranquil-signal = { path = "crates/tranquil-signal" } 55 + tranquil-store = { path = "crates/tranquil-store" } 54 56 55 57 presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844ae51c3a9fa49cf80a7816a31a425", default-features = false } 56 58
+2 -1
crates/tranquil-api/src/actor/preferences.rs
··· 188 188 .collect(); 189 189 190 190 if state 191 - .repos.infra 191 + .repos 192 + .infra 192 193 .replace_namespace_preferences(user_id, APP_BSKY_NAMESPACE, prefs_to_save) 193 194 .await 194 195 .is_err()
+4 -2
crates/tranquil-api/src/admin/account/delete.rs
··· 19 19 ) -> Result<Json<EmptyResponse>, ApiError> { 20 20 let did = &input.did; 21 21 let (user_id, handle) = state 22 - .repos.user 22 + .repos 23 + .user 23 24 .get_id_and_handle_by_did(did) 24 25 .await 25 26 .log_db_err("in delete_account")? ··· 27 28 .map(|row| (row.id, row.handle))?; 28 29 29 30 state 30 - .repos.user 31 + .repos 32 + .user 31 33 .admin_delete_account_complete(user_id, did) 32 34 .await 33 35 .log_db_err("deleting account")?;
+4 -2
crates/tranquil-api/src/admin/account/email.rs
··· 31 31 return Err(ApiError::InvalidRequest("content is required".into())); 32 32 } 33 33 let user = state 34 - .repos.user 34 + .repos 35 + .user 35 36 .get_by_did(&input.recipient_did) 36 37 .await 37 38 .log_db_err("in send_email")? ··· 45 46 .clone() 46 47 .unwrap_or_else(|| format!("Message from {}", hostname)); 47 48 let result = state 48 - .repos.infra 49 + .repos 50 + .infra 49 51 .enqueue_comms( 50 52 Some(user_id), 51 53 tranquil_db_traits::CommsChannel::Email,
+18 -9
crates/tranquil-api/src/admin/account/info.rs
··· 69 69 Query(params): Query<GetAccountInfoParams>, 70 70 ) -> Result<Json<AccountInfo>, ApiError> { 71 71 let account = state 72 - .repos.infra 72 + .repos 73 + .infra 73 74 .get_admin_account_info_by_did(&params.did) 74 75 .await 75 76 .log_db_err("in get_account_info")? ··· 98 99 99 100 async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> { 100 101 let code = state 101 - .repos.infra 102 + .repos 103 + .infra 102 104 .get_invite_code_used_by_user(user_id) 103 105 .await 104 106 .ok()??; ··· 111 113 user_id: uuid::Uuid, 112 114 ) -> Option<Vec<InviteCodeInfo>> { 113 115 let invite_codes = state 114 - .repos.infra 116 + .repos 117 + .infra 115 118 .get_invites_created_by_user(user_id) 116 119 .await 117 120 .ok()?; ··· 123 126 let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect(); 124 127 125 128 let uses = state 126 - .repos.infra 129 + .repos 130 + .infra 127 131 .get_invite_code_uses_batch(&code_strings) 128 132 .await 129 133 .ok()?; ··· 157 161 let info = state.repos.infra.get_invite_code_info(code).await.ok()??; 158 162 159 163 let uses = state 160 - .repos.infra 164 + .repos 165 + .infra 161 166 .get_invite_code_uses(code) 162 167 .await 163 168 .ok() ··· 197 202 198 203 let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect(); 199 204 let accounts = state 200 - .repos.infra 205 + .repos 206 + .infra 201 207 .get_admin_account_infos_by_dids(&dids_typed) 202 208 .await 203 209 .log_db_err("fetching account infos")?; ··· 205 211 let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect(); 206 212 207 213 let all_invite_codes = state 208 - .repos.infra 214 + .repos 215 + .infra 209 216 .get_invite_codes_by_users(&user_ids) 210 217 .await 211 218 .unwrap_or_default(); ··· 217 224 218 225 let all_invite_uses = if !all_codes.is_empty() { 219 226 state 220 - .repos.infra 227 + .repos 228 + .infra 221 229 .get_invite_code_uses_batch(&all_codes) 222 230 .await 223 231 .unwrap_or_default() ··· 226 234 }; 227 235 228 236 let invited_by_map: HashMap<uuid::Uuid, String> = state 229 - .repos.infra 237 + .repos 238 + .infra 230 239 .get_invite_code_uses_by_users(&user_ids) 231 240 .await 232 241 .unwrap_or_default()
+2 -1
crates/tranquil-api/src/admin/account/search.rs
··· 55 55 let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); 56 56 let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok()); 57 57 let rows = state 58 - .repos.user 58 + .repos 59 + .user 59 60 .search_accounts( 60 61 cursor_did.as_ref(), 61 62 email_filter.as_deref(),
+10 -5
crates/tranquil-api/src/admin/account/update.rs
··· 30 30 .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; 31 31 32 32 match state 33 - .repos.user 33 + .repos 34 + .user 34 35 .admin_update_email(&account_did, email) 35 36 .await 36 37 { ··· 73 74 }; 74 75 let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten(); 75 76 let user_id = state 76 - .repos.user 77 + .repos 78 + .user 77 79 .get_id_by_did(did) 78 80 .await 79 81 .ok() ··· 81 83 .ok_or(ApiError::AccountNotFound)?; 82 84 let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; 83 85 if let Ok(true) = state 84 - .repos.user 86 + .repos 87 + .user 85 88 .check_handle_exists(&handle_for_check, user_id) 86 89 .await 87 90 { 88 91 return Err(ApiError::HandleTaken); 89 92 } 90 93 match state 91 - .repos.user 94 + .repos 95 + .user 92 96 .admin_update_handle(did, &handle_for_check) 93 97 .await 94 98 { ··· 149 153 let password_hash = crate::common::hash_or_internal_error(password)?; 150 154 151 155 match state 152 - .repos.user 156 + .repos 157 + .user 153 158 .admin_update_password(did, &password_hash) 154 159 .await 155 160 {
+31 -15
crates/tranquil-api/src/admin/config.rs
··· 53 53 ]; 54 54 55 55 let rows = state 56 - .repos.infra 56 + .repos 57 + .infra 57 58 .get_server_configs(keys) 58 59 .await 59 60 .log_db_err("fetching server config")?; ··· 86 87 )); 87 88 } 88 89 state 89 - .repos.infra 90 + .repos 91 + .infra 90 92 .upsert_server_config("server_name", trimmed) 91 93 .await 92 94 .log_db_err("upserting server_name")?; ··· 95 97 if let Some(ref color) = req.primary_color { 96 98 if color.is_empty() { 97 99 state 98 - .repos.infra 100 + .repos 101 + .infra 99 102 .delete_server_config("primary_color") 100 103 .await 101 104 .log_db_err("deleting primary_color")?; 102 105 } else if is_valid_hex_color(color) { 103 106 state 104 - .repos.infra 107 + .repos 108 + .infra 105 109 .upsert_server_config("primary_color", color) 106 110 .await 107 111 .log_db_err("upserting primary_color")?; ··· 115 119 if let Some(ref color) = req.primary_color_dark { 116 120 if color.is_empty() { 117 121 state 118 - .repos.infra 122 + .repos 123 + .infra 119 124 .delete_server_config("primary_color_dark") 120 125 .await 121 126 .log_db_err("deleting primary_color_dark")?; 122 127 } else if is_valid_hex_color(color) { 123 128 state 124 - .repos.infra 129 + .repos 130 + .infra 125 131 .upsert_server_config("primary_color_dark", color) 126 132 .await 127 133 .log_db_err("upserting primary_color_dark")?; ··· 135 141 if let Some(ref color) = req.secondary_color { 136 142 if color.is_empty() { 137 143 state 138 - .repos.infra 144 + .repos 145 + .infra 139 146 .delete_server_config("secondary_color") 140 147 .await 141 148 .log_db_err("deleting secondary_color")?; 142 149 } else if is_valid_hex_color(color) { 143 150 state 144 - .repos.infra 151 + .repos 152 + .infra 145 153 .upsert_server_config("secondary_color", color) 146 154 .await 147 155 .log_db_err("upserting secondary_color")?; ··· 155 163 if let Some(ref color) = req.secondary_color_dark { 156 164 if color.is_empty() { 157 165 state 158 - .repos.infra 166 + .repos 167 + .infra 159 168 .delete_server_config("secondary_color_dark") 160 169 .await 161 170 .log_db_err("deleting secondary_color_dark")?; 162 171 } else if is_valid_hex_color(color) { 163 172 state 164 - .repos.infra 173 + .repos 174 + .infra 165 175 .upsert_server_config("secondary_color_dark", color) 166 176 .await 167 177 .log_db_err("upserting secondary_color_dark")?; ··· 174 184 175 185 if let Some(ref logo_cid) = req.logo_cid { 176 186 let old_logo_cid = state 177 - .repos.infra 187 + .repos 188 + .infra 178 189 .get_server_config("logo_cid") 179 190 .await 180 191 .ok() ··· 189 200 if let Some(old_cid_str) = should_delete_old { 190 201 match CidLink::new(old_cid_str) { 191 202 Ok(old_cid) => { 192 - if let Ok(Some(storage_key)) = 193 - state.repos.infra.get_blob_storage_key_by_cid(&old_cid).await 203 + if let Ok(Some(storage_key)) = state 204 + .repos 205 + .infra 206 + .get_blob_storage_key_by_cid(&old_cid) 207 + .await 194 208 { 195 209 if let Err(e) = state.blob_store.delete(&storage_key).await { 196 210 error!("Failed to delete old logo blob from storage: {:?}", e); ··· 211 225 212 226 if logo_cid.is_empty() { 213 227 state 214 - .repos.infra 228 + .repos 229 + .infra 215 230 .delete_server_config("logo_cid") 216 231 .await 217 232 .log_db_err("deleting logo_cid")?; 218 233 } else { 219 234 state 220 - .repos.infra 235 + .repos 236 + .infra 221 237 .upsert_server_config("logo_cid", logo_cid) 222 238 .await 223 239 .log_db_err("upserting logo_cid")?;
+12 -6
crates/tranquil-api/src/admin/invite.rs
··· 32 32 let accounts_typed: Vec<tranquil_types::Did> = 33 33 accounts.iter().filter_map(|a| a.parse().ok()).collect(); 34 34 if let Err(e) = state 35 - .repos.infra 35 + .repos 36 + .infra 36 37 .disable_invite_codes_by_account(&accounts_typed) 37 38 .await 38 39 { ··· 87 88 }; 88 89 89 90 let codes_rows = state 90 - .repos.infra 91 + .repos 92 + .infra 91 93 .list_invite_codes(params.cursor.as_deref(), limit, sort_order) 92 94 .await 93 95 .log_db_err("fetching invite codes")?; ··· 96 98 let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect(); 97 99 98 100 let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state 99 - .repos.infra 101 + .repos 102 + .infra 100 103 .get_user_dids_by_ids(&user_ids) 101 104 .await 102 105 .unwrap_or_default() ··· 108 111 } else { 109 112 common::group_invite_uses_by_code( 110 113 state 111 - .repos.infra 114 + .repos 115 + .infra 112 116 .get_invite_code_uses_batch(&code_strings) 113 117 .await 114 118 .unwrap_or_default(), ··· 168 172 .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; 169 173 170 174 match state 171 - .repos.user 175 + .repos 176 + .user 172 177 .set_invites_disabled(&account_did, true) 173 178 .await 174 179 { ··· 200 205 .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; 201 206 202 207 match state 203 - .repos.user 208 + .repos 209 + .user 204 210 .set_invites_disabled(&account_did, false) 205 211 .await 206 212 {
+6 -3
crates/tranquil-api/src/admin/status.rs
··· 172 172 None 173 173 }; 174 174 state 175 - .repos.user 175 + .repos 176 + .user 176 177 .set_user_takedown(&did, takedown_ref) 177 178 .await 178 179 .map_err(|e| { ··· 249 250 None 250 251 }; 251 252 state 252 - .repos.repo 253 + .repos 254 + .repo 253 255 .set_record_takedown(&cid, takedown_ref) 254 256 .await 255 257 .map_err(|e| { ··· 282 284 None 283 285 }; 284 286 state 285 - .repos.blob 287 + .repos 288 + .blob 286 289 .update_blob_takedown(&cid, takedown_ref) 287 290 .await 288 291 .map_err(|e| {
+32 -16
crates/tranquil-api/src/delegation.rs
··· 24 24 auth: Auth<Active>, 25 25 ) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> { 26 26 let controllers = state 27 - .repos.delegation 27 + .repos 28 + .delegation 28 29 .get_delegations_for_account(&auth.did) 29 30 .await 30 31 .map_err(|e| { ··· 100 101 101 102 if resolved.is_local 102 103 && state 103 - .repos.delegation 104 + .repos 105 + .delegation 104 106 .is_delegated_account(&input.controller_did) 105 107 .await 106 108 .unwrap_or(false) ··· 111 113 } 112 114 113 115 match state 114 - .repos.delegation 116 + .repos 117 + .delegation 115 118 .create_delegation( 116 119 can_add.did(), 117 120 &input.controller_did, ··· 122 125 { 123 126 Ok(_) => { 124 127 let _ = state 125 - .repos.delegation 128 + .repos 129 + .delegation 126 130 .log_delegation_action( 127 131 can_add.did(), 128 132 can_add.did(), ··· 159 163 Json(input): Json<RemoveControllerInput>, 160 164 ) -> Result<Json<SuccessResponse>, ApiError> { 161 165 match state 162 - .repos.delegation 166 + .repos 167 + .delegation 163 168 .revoke_delegation(&auth.did, &input.controller_did, &auth.did) 164 169 .await 165 170 { 166 171 Ok(true) => { 167 172 let revoked_app_passwords = state 168 - .repos.session 173 + .repos 174 + .session 169 175 .delete_app_passwords_by_controller(&auth.did, &input.controller_did) 170 176 .await 171 177 .unwrap_or(0) ··· 173 179 .unwrap_or(0usize); 174 180 175 181 let revoked_oauth_tokens = state 176 - .repos.oauth 182 + .repos 183 + .oauth 177 184 .revoke_tokens_for_controller(&auth.did, &input.controller_did) 178 185 .await 179 186 .unwrap_or(0); 180 187 181 188 let _ = state 182 - .repos.delegation 189 + .repos 190 + .delegation 183 191 .log_delegation_action( 184 192 &auth.did, 185 193 &auth.did, ··· 218 226 Json(input): Json<UpdateControllerScopesInput>, 219 227 ) -> Result<Json<SuccessResponse>, ApiError> { 220 228 match state 221 - .repos.delegation 229 + .repos 230 + .delegation 222 231 .update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes) 223 232 .await 224 233 { 225 234 Ok(true) => { 226 235 let _ = state 227 - .repos.delegation 236 + .repos 237 + .delegation 228 238 .log_delegation_action( 229 239 &auth.did, 230 240 &auth.did, ··· 255 265 auth: Auth<Active>, 256 266 ) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> { 257 267 let accounts = state 258 - .repos.delegation 268 + .repos 269 + .delegation 259 270 .get_accounts_controlled_by(&auth.did) 260 271 .await 261 272 .map_err(|e| { ··· 287 298 let offset = params.offset.max(0); 288 299 289 300 let entries = state 290 - .repos.delegation 301 + .repos 302 + .delegation 291 303 .get_audit_log_for_account(&auth.did, limit, offset) 292 304 .await 293 305 .map_err(|e| { ··· 296 308 })?; 297 309 298 310 let total = state 299 - .repos.delegation 311 + .repos 312 + .delegation 300 313 .count_audit_log_entries(&auth.did) 301 314 .await 302 315 .unwrap_or_default(); ··· 388 401 }; 389 402 390 403 let user_id = match state 391 - .repos.user 404 + .repos 405 + .user 392 406 .create_delegated_account(&create_input) 393 407 .await 394 408 { ··· 407 421 408 422 if let Some(validated) = validated_invite_code 409 423 && let Err(e) = state 410 - .repos.infra 424 + .repos 425 + .infra 411 426 .record_invite_code_use(&validated, user_id) 412 427 .await 413 428 { ··· 424 439 .await; 425 440 426 441 let _ = state 427 - .repos.delegation 442 + .repos 443 + .delegation 428 444 .log_delegation_action( 429 445 &did, 430 446 &auth.did,
+2 -1
crates/tranquil-api/src/discord_webhook.rs
··· 169 169 ); 170 170 171 171 match state 172 - .repos.user 172 + .repos 173 + .user 173 174 .store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref()) 174 175 .await 175 176 {
+12 -4
crates/tranquil-api/src/identity/account.rs
··· 67 67 new_email: email.clone(), 68 68 }; 69 69 match state 70 - .repos.user 70 + .repos 71 + .user 71 72 .reactivate_migration_account(&reactivate_input) 72 73 .await 73 74 { 74 75 Ok(reactivated) => { 75 76 info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); 76 77 let secret_key_bytes = match state 77 - .repos.user 78 + .repos 79 + .user 78 80 .get_user_key_by_id(reactivated.user_id) 79 81 .await 80 82 { ··· 399 401 Err(_) => return ApiError::InvalidHandle(None).into_response(), 400 402 }; 401 403 let handle_available = match state 402 - .repos.user 404 + .repos 405 + .user 403 406 .check_handle_available_for_new_account(&handle_typed) 404 407 .await 405 408 { ··· 522 525 birthdate_pref, 523 526 }; 524 527 525 - let create_result = match state.repos.user.create_password_account(&create_input).await { 528 + let create_result = match state 529 + .repos 530 + .user 531 + .create_password_account(&create_input) 532 + .await 533 + { 526 534 Ok(r) => r, 527 535 Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { 528 536 return ApiError::HandleNotAvailable(None).into_response();
+16 -8
crates/tranquil-api/src/identity/did.rs
··· 165 165 Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(), 166 166 }; 167 167 let user = match state 168 - .repos.user 168 + .repos 169 + .user 169 170 .get_user_for_did_doc_build(&expected_did_typed) 170 171 .await 171 172 { ··· 182 183 let did = expected_did; 183 184 184 185 let overrides = state 185 - .repos.user 186 + .repos 187 + .user 186 188 .get_did_web_overrides(user_id) 187 189 .await 188 190 .ok() ··· 218 220 } 219 221 }; 220 222 let user = match state 221 - .repos.user 223 + .repos 224 + .user 222 225 .get_did_web_info_by_handle(&current_handle_typed) 223 226 .await 224 227 { ··· 246 249 } 247 250 248 251 let overrides = state 249 - .repos.user 252 + .repos 253 + .user 250 254 .get_did_web_overrides(user_id) 251 255 .await 252 256 .ok() ··· 468 472 auth: Auth<NotTakendown>, 469 473 ) -> Result<Json<GetRecommendedDidCredentialsOutput>, ApiError> { 470 474 let handle = state 471 - .repos.user 475 + .repos 476 + .user 472 477 .get_handle_by_did(&auth.did) 473 478 .await 474 479 .log_db_err("fetching handle for DID credentials")? ··· 539 544 ) 540 545 .await?; 541 546 let user_row = state 542 - .repos.user 547 + .repos 548 + .user 543 549 .get_id_and_handle_by_did(&did) 544 550 .await 545 551 .log_db_err("fetching user for handle update")? ··· 661 667 .parse() 662 668 .map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?; 663 669 let handle_exists = state 664 - .repos.user 670 + .repos 671 + .user 665 672 .check_handle_exists(&handle_typed, user_id) 666 673 .await 667 674 .log_db_err("checking handle existence")?; ··· 669 676 return Err(ApiError::HandleTaken); 670 677 } 671 678 state 672 - .repos.user 679 + .repos 680 + .user 673 681 .update_handle(user_id, &handle_typed) 674 682 .await 675 683 .map_err(|e| {
+4 -2
crates/tranquil-api/src/identity/plc/request.rs
··· 20 20 tranquil_pds::oauth::scopes::IdentityAttr::Wildcard, 21 21 )?; 22 22 let user_id = state 23 - .repos.user 23 + .repos 24 + .user 24 25 .get_id_by_did(&auth.did) 25 26 .await 26 27 .log_db_err("fetching user id")? ··· 30 31 let plc_token = generate_plc_token(); 31 32 let expires_at = Utc::now() + Duration::minutes(10); 32 33 state 33 - .repos.infra 34 + .repos 35 + .infra 34 36 .insert_plc_token(user_id, &plc_token, expires_at) 35 37 .await 36 38 .log_db_err("creating PLC token")?;
+6 -3
crates/tranquil-api/src/identity/plc/sign.rs
··· 55 55 })?; 56 56 57 57 let user_id = state 58 - .repos.user 58 + .repos 59 + .user 59 60 .get_id_by_did(did) 60 61 .await 61 62 .log_db_err("fetching user id")? 62 63 .ok_or(ApiError::AccountNotFound)?; 63 64 64 65 let token_expiry = state 65 - .repos.infra 66 + .repos 67 + .infra 66 68 .get_plc_token_expiry(user_id, token) 67 69 .await 68 70 .log_db_err("fetching PLC token expiry")? ··· 73 75 return Err(ApiError::ExpiredToken(Some("Token has expired".into()))); 74 76 } 75 77 let key_row = state 76 - .repos.user 78 + .repos 79 + .user 77 80 .get_user_key_by_id(user_id) 78 81 .await 79 82 .log_db_err("fetching user key")?
+6 -3
crates/tranquil-api/src/identity/plc/submit.rs
··· 38 38 let hostname = &tranquil_config::get().server.hostname; 39 39 let public_url = format!("https://{}", hostname); 40 40 let user = state 41 - .repos.user 41 + .repos 42 + .user 42 43 .get_id_and_handle_by_did(did) 43 44 .await 44 45 .log_db_err("fetching user")? 45 46 .ok_or(ApiError::AccountNotFound)?; 46 47 47 48 let key_row = state 48 - .repos.user 49 + .repos 50 + .user 49 51 .get_user_key_by_id(user.id) 50 52 .await 51 53 .log_db_err("fetching user key")? ··· 128 130 .map_err(ApiError::from)?; 129 131 130 132 match state 131 - .repos.repo 133 + .repos 134 + .repo 132 135 .insert_identity_event(did, Some(&user.handle)) 133 136 .await 134 137 {
+4 -2
crates/tranquil-api/src/identity/provision.rs
··· 136 136 match signing_key_did { 137 137 Some(key_did) => { 138 138 let key = state 139 - .repos.infra 139 + .repos 140 + .infra 140 141 .get_reserved_signing_key(key_did) 141 142 .await 142 143 .map_err(|e| { ··· 295 296 app_password_name: None, 296 297 }; 297 298 state 298 - .repos.session 299 + .repos 300 + .session 299 301 .create_session(&session_data) 300 302 .await 301 303 .map_err(|e| {
+30 -15
crates/tranquil-api/src/notification_prefs.rs
··· 26 26 auth: Auth<Active>, 27 27 ) -> Result<Json<NotificationPrefsOutput>, ApiError> { 28 28 let prefs = state 29 - .repos.user 29 + .repos 30 + .user 30 31 .get_notification_prefs(&auth.did) 31 32 .await 32 33 .log_db_err("get notification prefs")? ··· 65 66 auth: Auth<Active>, 66 67 ) -> Result<Json<GetNotificationHistoryOutput>, ApiError> { 67 68 let user_id = state 68 - .repos.user 69 + .repos 70 + .user 69 71 .get_id_by_did(&auth.did) 70 72 .await 71 73 .log_db_err("get user id by did")? 72 74 .ok_or(ApiError::AccountNotFound)?; 73 75 74 76 let rows = state 75 - .repos.infra 77 + .repos 78 + .infra 76 79 .get_notification_history(user_id, 50) 77 80 .await 78 81 .log_db_err("get notification history")?; ··· 165 168 hostname, encoded_token, encoded_identifier 166 169 ); 167 170 let prefs = state 168 - .repos.user 171 + .repos 172 + .user 169 173 .get_comms_prefs(user_id) 170 174 .await 171 175 .ok() ··· 185 189 ); 186 190 let recipient = match channel { 187 191 CommsChannel::Telegram => state 188 - .repos.user 192 + .repos 193 + .user 189 194 .get_telegram_chat_id(user_id) 190 195 .await 191 196 .ok() ··· 195 200 _ => identifier.to_string(), 196 201 }; 197 202 state 198 - .repos.infra 203 + .repos 204 + .infra 199 205 .enqueue_comms( 200 206 Some(user_id), 201 207 channel, ··· 238 244 } 239 245 match channel { 240 246 CommsChannel::Discord => state 241 - .repos.user 247 + .repos 248 + .user 242 249 .clear_discord(user_id) 243 250 .await 244 251 .log_db_err("clear discord")?, 245 252 CommsChannel::Telegram => state 246 - .repos.user 253 + .repos 254 + .user 247 255 .clear_telegram(user_id) 248 256 .await 249 257 .log_db_err("clear telegram")?, 250 258 CommsChannel::Signal => state 251 - .repos.user 259 + .repos 260 + .user 252 261 .clear_signal(user_id) 253 262 .await 254 263 .log_db_err("clear signal")?, ··· 281 290 282 291 match channel { 283 292 CommsChannel::Discord => state 284 - .repos.user 293 + .repos 294 + .user 285 295 .set_unverified_discord(user_id, &clean) 286 296 .await 287 297 .log_db_err("set unverified discord")?, 288 298 CommsChannel::Telegram => state 289 - .repos.user 299 + .repos 300 + .user 290 301 .set_unverified_telegram(user_id, &clean) 291 302 .await 292 303 .log_db_err("set unverified telegram")?, 293 304 CommsChannel::Signal => state 294 - .repos.user 305 + .repos 306 + .user 295 307 .set_unverified_signal(user_id, &clean) 296 308 .await 297 309 .log_db_err("set unverified signal")?, ··· 313 325 Json(input): Json<UpdateNotificationPrefsInput>, 314 326 ) -> Result<Json<UpdateNotificationPrefsOutput>, ApiError> { 315 327 let user_row = state 316 - .repos.user 328 + .repos 329 + .user 317 330 .get_id_handle_email_by_did(&auth.did) 318 331 .await 319 332 .log_db_err("get user by did")? ··· 324 337 let current_email = user_row.email; 325 338 326 339 let current_prefs = state 327 - .repos.user 340 + .repos 341 + .user 328 342 .get_notification_prefs(&auth.did) 329 343 .await 330 344 .log_db_err("get notification prefs for update")? ··· 347 361 348 362 if input.preferred_channel.is_some() { 349 363 state 350 - .repos.user 364 + .repos 365 + .user 351 366 .update_preferred_comms_channel(&auth.did, effective_channel) 352 367 .await 353 368 .log_db_err("update preferred channel")?;
+12 -6
crates/tranquil-api/src/repo/blob.rs
··· 66 66 }; 67 67 68 68 if state 69 - .repos.user 69 + .repos 70 + .user 70 71 .is_account_migrated(&did) 71 72 .await 72 73 .unwrap_or(false) ··· 78 79 get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream"); 79 80 80 81 let user_id = state 81 - .repos.user 82 + .repos 83 + .user 82 84 .get_id_by_did(&did) 83 85 .await 84 86 .log_db_err("fetching user id for blob upload")? ··· 143 145 ); 144 146 145 147 match state 146 - .repos.blob 148 + .repos 149 + .blob 147 150 .insert_blob( 148 151 &cid_link, 149 152 &mime_type, ··· 177 180 178 181 if let Some(ref controller) = controller_did 179 182 && let Err(e) = state 180 - .repos.delegation 183 + .repos 184 + .delegation 181 185 .log_delegation_action( 182 186 &did, 183 187 controller, ··· 236 240 ) -> Result<Json<ListMissingBlobsOutput>, ApiError> { 237 241 let did = &auth.did; 238 242 let user = state 239 - .repos.user 243 + .repos 244 + .user 240 245 .get_by_did(did) 241 246 .await 242 247 .log_db_err("fetching user")? ··· 245 250 let limit = params.limit.unwrap_or(500).clamp(1, 1000); 246 251 let cursor = params.cursor.as_deref(); 247 252 let missing = state 248 - .repos.blob 253 + .repos 254 + .blob 249 255 .list_missing_blobs(user.id, cursor, limit + 1) 250 256 .await 251 257 .log_db_err("fetching missing blobs")?;
+14 -7
crates/tranquil-api/src/repo/import.rs
··· 34 34 } 35 35 let did = &auth.did; 36 36 let user = state 37 - .repos.user 37 + .repos 38 + .user 38 39 .get_by_did(did) 39 40 .await 40 41 .log_db_err("fetching user")? ··· 44 45 } 45 46 let user_id = user.id; 46 47 let expected_root_cid = state 47 - .repos.repo 48 + .repos 49 + .repo 48 50 .get_repo_root_cid_by_user_id(user_id) 49 51 .await 50 52 .map_err(|e| { ··· 232 234 blob_refs.into_iter().unzip(); 233 235 234 236 match state 235 - .repos.blob 237 + .repos 238 + .blob 236 239 .insert_record_blobs(user_id, &record_uris, &blob_cids) 237 240 .await 238 241 { ··· 248 251 } 249 252 } 250 253 let key_row = state 251 - .repos.user 254 + .repos 255 + .user 252 256 .get_user_with_key_by_did(did) 253 257 .await 254 258 .map_err(|e| { ··· 289 293 })?; 290 294 let new_root_cid_link = CidLink::from(&new_root_cid); 291 295 state 292 - .repos.repo 296 + .repos 297 + .repo 293 298 .update_repo_root(user_id, &new_root_cid_link, &new_rev_str) 294 299 .await 295 300 .map_err(|e| { ··· 299 304 let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect(); 300 305 all_block_cids.push(new_root_cid.to_bytes()); 301 306 state 302 - .repos.repo 307 + .repos 308 + .repo 303 309 .insert_user_blocks(user_id, &all_block_cids, &new_rev_str) 304 310 .await 305 311 .map_err(|e| { ··· 322 328 "birthDate": "1998-05-06T00:00:00.000Z" 323 329 }); 324 330 if let Err(e) = state 325 - .repos.infra 331 + .repos 332 + .infra 326 333 .insert_account_preference_if_not_exists( 327 334 user_id, 328 335 "app.bsky.actor.defs#personalDetailsPref",
+2 -1
crates/tranquil-api/src/repo/meta.rs
··· 23 23 Err(e) => return e.into_response(), 24 24 }; 25 25 let collections = state 26 - .repos.repo 26 + .repos 27 + .repo 27 28 .list_collections(resolved.user_id) 28 29 .await 29 30 .unwrap_or_default();
+2 -1
crates/tranquil-api/src/repo/record/batch.rs
··· 304 304 require_verified_or_delegated(&state, batch_proof.user()).await?; 305 305 306 306 let user_id: uuid::Uuid = state 307 - .repos.user 307 + .repos 308 + .user 308 309 .get_id_by_did(&did) 309 310 .await 310 311 .log_db_err("fetching user for batch write")?
+4 -2
crates/tranquil-api/src/repo/record/delete.rs
··· 101 101 102 102 let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey); 103 103 if let Err(e) = state 104 - .repos.backlink 104 + .repos 105 + .backlink 105 106 .remove_backlinks_by_uri(&deleted_uri) 106 107 .await 107 108 { ··· 130 131 let _write_lock = state.repo_write_locks.lock(user_id).await; 131 132 132 133 let root_cid_str = state 133 - .repos.repo 134 + .repos 135 + .repo 134 136 .get_repo_root_cid_by_user_id(user_id) 135 137 .await 136 138 .map_err(|e| CommitError::DatabaseError(e.to_string()))?
+4 -2
crates/tranquil-api/src/repo/record/read.rs
··· 65 65 Err(e) => return e.into_response(), 66 66 }; 67 67 let record_row = state 68 - .repos.repo 68 + .repos 69 + .repo 69 70 .get_record_cid(user_id, &input.collection, &input.rkey) 70 71 .await; 71 72 let record_cid_link = match record_row { ··· 139 140 .as_ref() 140 141 .and_then(|c| c.parse::<tranquil_pds::types::Rkey>().ok()); 141 142 let rows = match state 142 - .repos.repo 143 + .repos 144 + .repo 143 145 .list_records( 144 146 user_id, 145 147 &input.collection,
+9 -3
crates/tranquil-api/src/repo/record/write.rs
··· 46 46 let _account_verified = require_verified_or_delegated(state, user).await?; 47 47 48 48 let user_id = state 49 - .repos.user 49 + .repos 50 + .user 50 51 .get_id_by_did(principal_did.as_did()) 51 52 .await 52 53 .log_db_err("fetching user for repo write")? ··· 128 129 129 130 if !backlinks.is_empty() { 130 131 let conflicts = state 131 - .repos.backlink 132 + .repos 133 + .backlink 132 134 .get_backlink_conflicts(user_id, &input.collection, &backlinks) 133 135 .await 134 136 .log_db_err("checking backlink conflicts")?; ··· 244 246 let created_uri = AtUri::from_parts(&did, &input.collection, &rkey); 245 247 let backlinks = extract_backlinks(&created_uri, &input.record); 246 248 if !backlinks.is_empty() 247 - && let Err(e) = state.repos.backlink.add_backlinks(user_id, &backlinks).await 249 + && let Err(e) = state 250 + .repos 251 + .backlink 252 + .add_backlinks(user_id, &backlinks) 253 + .await 248 254 { 249 255 error!("Failed to add backlinks for {}: {}", created_uri, e); 250 256 }
+41 -14
crates/tranquil-api/src/server/account_status.rs
··· 41 41 ) -> Result<Json<CheckAccountStatusOutput>, ApiError> { 42 42 let did = &auth.did; 43 43 let user_id = state 44 - .repos.user 44 + .repos 45 + .user 45 46 .get_id_by_did(did) 46 47 .await 47 48 .log_db_err("fetching user ID for account status")? 48 49 .ok_or(ApiError::InternalError(None))?; 49 50 let is_active = state 50 - .repos.user 51 + .repos 52 + .user 51 53 .is_account_active_by_did(did) 52 54 .await 53 55 .ok() ··· 58 60 .map(|r| (r.repo_root_cid.to_string(), r.repo_rev)) 59 61 .unwrap_or_else(|| (String::new(), None)); 60 62 let block_count: i64 = state 61 - .repos.repo 63 + .repos 64 + .repo 62 65 .count_user_blocks(user_id) 63 66 .await 64 67 .unwrap_or(0); ··· 82 85 }; 83 86 let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0); 84 87 let imported_blobs: i64 = state 85 - .repos.blob 88 + .repos 89 + .blob 86 90 .count_blobs_by_user(user_id) 87 91 .await 88 92 .unwrap_or(0); 89 93 let expected_blobs: i64 = state 90 - .repos.blob 94 + .repos 95 + .blob 91 96 .count_distinct_record_blobs(user_id) 92 97 .await 93 98 .unwrap_or(0); ··· 339 344 did_validation_start.elapsed() 340 345 ); 341 346 342 - let handle = state.repos.user.get_handle_by_did(&did).await.ok().flatten(); 347 + let handle = state 348 + .repos 349 + .user 350 + .get_handle_by_did(&did) 351 + .await 352 + .ok() 353 + .flatten(); 343 354 info!( 344 355 "[MIGRATION] activateAccount: Activating account did={} handle={:?}", 345 356 did, handle ··· 406 417 info!("[MIGRATION] activateAccount: Identity event sequenced successfully"); 407 418 } 408 419 let repo_root = state 409 - .repos.repo 420 + .repos 421 + .repo 410 422 .get_repo_root_by_did(&did) 411 423 .await 412 424 .ok() ··· 480 492 481 493 let did = auth.did.clone(); 482 494 483 - let handle = state.repos.user.get_handle_by_did(&did).await.ok().flatten(); 495 + let handle = state 496 + .repos 497 + .user 498 + .get_handle_by_did(&did) 499 + .await 500 + .ok() 501 + .flatten(); 484 502 485 - let result = state.repos.user.deactivate_account(&did, delete_after).await; 503 + let result = state 504 + .repos 505 + .user 506 + .deactivate_account(&did, delete_after) 507 + .await; 486 508 487 509 match result { 488 510 Ok(true) => { ··· 518 540 let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 519 541 520 542 let user_id = state 521 - .repos.user 543 + .repos 544 + .user 522 545 .get_id_by_did(session_mfa.did()) 523 546 .await 524 547 .ok() ··· 527 550 let confirmation_token = Uuid::new_v4().to_string(); 528 551 let expires_at = Utc::now() + Duration::minutes(15); 529 552 state 530 - .repos.infra 553 + .repos 554 + .infra 531 555 .create_deletion_request(&confirmation_token, session_mfa.did(), expires_at) 532 556 .await 533 557 .log_db_err("creating deletion token")?; ··· 572 596 return Err(ApiError::InvalidToken(Some("token is required".into()))); 573 597 } 574 598 let user = state 575 - .repos.user 599 + .repos 600 + .user 576 601 .get_user_for_deletion(did) 577 602 .await 578 603 .map_err(|e| { ··· 595 620 ))); 596 621 } 597 622 let deletion_request = state 598 - .repos.infra 623 + .repos 624 + .infra 599 625 .get_deletion_request(token) 600 626 .await 601 627 .map_err(|e| { ··· 615 641 return Err(ApiError::ExpiredToken(None)); 616 642 } 617 643 state 618 - .repos.user 644 + .repos 645 + .user 619 646 .delete_account_complete(user_id, did) 620 647 .await 621 648 .map_err(|e| {
+22 -11
crates/tranquil-api/src/server/app_password.rs
··· 31 31 auth: Auth<Permissive>, 32 32 ) -> Result<Json<ListAppPasswordsOutput>, ApiError> { 33 33 let user = state 34 - .repos.user 34 + .repos 35 + .user 35 36 .get_by_did(&auth.did) 36 37 .await 37 38 .log_db_err("getting user")? 38 39 .ok_or(ApiError::AccountNotFound)?; 39 40 40 41 let rows = state 41 - .repos.session 42 + .repos 43 + .session 42 44 .list_app_passwords(user.id) 43 45 .await 44 46 .log_db_err("listing app passwords")?; ··· 83 85 Json(input): Json<CreateAppPasswordInput>, 84 86 ) -> Result<Json<CreateAppPasswordOutput>, ApiError> { 85 87 let user = state 86 - .repos.user 88 + .repos 89 + .user 87 90 .get_by_did(&auth.did) 88 91 .await 89 92 .log_db_err("getting user")? ··· 95 98 } 96 99 97 100 if state 98 - .repos.session 101 + .repos 102 + .session 99 103 .get_app_password_by_name(user.id, name) 100 104 .await 101 105 .log_db_err("checking app password")? ··· 106 110 107 111 let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did { 108 112 let grant = state 109 - .repos.delegation 113 + .repos 114 + .delegation 110 115 .get_delegation(&auth.did, controller) 111 116 .await 112 117 .ok() ··· 149 154 }; 150 155 151 156 state 152 - .repos.session 157 + .repos 158 + .session 153 159 .create_app_password(&create_data) 154 160 .await 155 161 .log_db_err("creating app password")?; 156 162 157 163 if let Some(ref controller) = controller_did { 158 164 let _ = state 159 - .repos.delegation 165 + .repos 166 + .delegation 160 167 .log_delegation_action( 161 168 &auth.did, 162 169 controller, ··· 192 199 Json(input): Json<RevokeAppPasswordInput>, 193 200 ) -> Result<Json<EmptyResponse>, ApiError> { 194 201 let user = state 195 - .repos.user 202 + .repos 203 + .user 196 204 .get_by_did(&auth.did) 197 205 .await 198 206 .log_db_err("getting user")? ··· 204 212 } 205 213 206 214 let sessions_to_invalidate = state 207 - .repos.session 215 + .repos 216 + .session 208 217 .get_session_jtis_by_app_password(&auth.did, name) 209 218 .await 210 219 .unwrap_or_default(); 211 220 212 221 state 213 - .repos.session 222 + .repos 223 + .session 214 224 .delete_sessions_by_app_password(&auth.did, name) 215 225 .await 216 226 .log_db_err("revoking sessions for app password")?; ··· 225 235 .await; 226 236 227 237 state 228 - .repos.session 238 + .repos 239 + .session 229 240 .delete_app_password(user.id, name) 230 241 .await 231 242 .log_db_err("revoking app password")?;
+24 -11
crates/tranquil-api/src/server/email.rs
··· 61 61 auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?; 62 62 63 63 let user = state 64 - .repos.user 64 + .repos 65 + .user 65 66 .get_email_info_by_did(&auth.did) 66 67 .await 67 68 .log_db_err("getting email info")? ··· 141 142 142 143 let did = &auth.did; 143 144 let user = state 144 - .repos.user 145 + .repos 146 + .user 145 147 .get_email_info_by_did(did) 146 148 .await 147 149 .log_db_err("getting email info")? ··· 185 187 } 186 188 187 189 state 188 - .repos.user 190 + .repos 191 + .user 189 192 .set_email_verified(user.id, true) 190 193 .await 191 194 .log_db_err("confirming email")?; ··· 212 215 213 216 let did = &auth.did; 214 217 let user = state 215 - .repos.user 218 + .repos 219 + .user 216 220 .get_email_info_by_did(did) 217 221 .await 218 222 .log_db_err("getting email info")? ··· 259 263 } 260 264 261 265 state 262 - .repos.infra 266 + .repos 267 + .infra 263 268 .upsert_account_preference(user_id, "email_auth_factor", json!(email_auth_factor)) 264 269 .await 265 270 .map_err(|e| { ··· 342 347 } 343 348 344 349 state 345 - .repos.user 350 + .repos 351 + .user 346 352 .update_email(user_id, &new_email) 347 353 .await 348 354 .log_db_err("updating email")?; ··· 370 376 } 371 377 372 378 if let Err(e) = state 373 - .repos.infra 379 + .repos 380 + .infra 374 381 .upsert_account_preference( 375 382 user_id, 376 383 "email_auth_factor", ··· 396 403 Json(input): Json<CheckEmailVerifiedInput>, 397 404 ) -> Result<Json<VerifiedResponse>, ApiError> { 398 405 let verified = state 399 - .repos.user 406 + .repos 407 + .user 400 408 .check_email_verified_by_identifier(&input.identifier) 401 409 .await 402 410 .map_err(|e| { ··· 420 428 Json(input): Json<CheckChannelVerifiedInput>, 421 429 ) -> Result<Json<VerifiedResponse>, ApiError> { 422 430 let verified = state 423 - .repos.user 431 + .repos 432 + .user 424 433 .check_channel_verified_by_did(&input.did, input.channel) 425 434 .await 426 435 .map_err(|e| { ··· 480 489 let mut pending = match get_pending_email_update(state.cache.as_ref(), &did).await { 481 490 Some(p) => p, 482 491 None => { 483 - warn!("authorize_email_update: no pending email update in cache for did={}", did); 492 + warn!( 493 + "authorize_email_update: no pending email update in cache for did={}", 494 + did 495 + ); 484 496 return ApiError::InvalidRequest("No pending email update found".into()) 485 497 .into_response(); 486 498 } ··· 558 570 } 559 571 560 572 let count = state 561 - .repos.user 573 + .repos 574 + .user 562 575 .count_accounts_by_email(&email) 563 576 .await 564 577 .map_err(|e| {
+6 -3
crates/tranquil-api/src/server/invite.rs
··· 40 40 let code = gen_invite_code(); 41 41 42 42 match state 43 - .repos.infra 43 + .repos 44 + .infra 44 45 .create_invite_code(&code, input.use_count, Some(&for_account)) 45 46 .await 46 47 { ··· 97 98 }; 98 99 99 100 let admin_user_id = state 100 - .repos.user 101 + .repos 102 + .user 101 103 .get_any_admin_user_id() 102 104 .await 103 105 .log_db_err("looking up admin user")? ··· 174 176 let include_used = params.include_used.unwrap_or(true); 175 177 176 178 let codes_info = state 177 - .repos.infra 179 + .repos 180 + .infra 178 181 .get_invite_codes_for_account(&auth.did) 179 182 .await 180 183 .log_db_err("fetching invite codes")?;
+10 -5
crates/tranquil-api/src/server/migration.rs
··· 42 42 } 43 43 44 44 let user = state 45 - .repos.user 45 + .repos 46 + .user 46 47 .get_user_for_did_doc(&auth.did) 47 48 .await 48 49 .log_db_err("getting user")? ··· 97 98 let also_known_as: Option<Vec<String>> = input.also_known_as.clone(); 98 99 99 100 state 100 - .repos.user 101 + .repos 102 + .user 101 103 .upsert_did_web_overrides(user.id, verification_methods_json, also_known_as) 102 104 .await 103 105 .log_db_err("upserting did_web_overrides")?; ··· 105 107 if let Some(ref endpoint) = input.service_endpoint { 106 108 let endpoint_clean = endpoint.trim().trim_end_matches('/'); 107 109 state 108 - .repos.user 110 + .repos 111 + .user 109 112 .update_migrated_to_pds(&auth.did, endpoint_clean) 110 113 .await 111 114 .log_db_err("updating service endpoint")?; ··· 149 152 }; 150 153 151 154 let overrides = state 152 - .repos.user 155 + .repos 156 + .user 153 157 .get_did_web_overrides(user.id) 154 158 .await 155 159 .ok() ··· 193 197 } 194 198 195 199 let key_info = state 196 - .repos.user 200 + .repos 201 + .user 197 202 .get_user_key_by_id(user.id) 198 203 .await 199 204 .ok()
+30 -11
crates/tranquil-api/src/server/passkey_account.rs
··· 451 451 State(state): State<AppState>, 452 452 Json(input): Json<CompletePasskeySetupInput>, 453 453 ) -> Result<Json<CompletePasskeySetupOutput>, ApiError> { 454 - let user = match state.repos.user.get_user_for_passkey_setup(&input.did).await { 454 + let user = match state 455 + .repos 456 + .user 457 + .get_user_for_passkey_setup(&input.did) 458 + .await 459 + { 455 460 Ok(Some(u)) => u, 456 461 Ok(None) => { 457 462 return Err(ApiError::AccountNotFound); ··· 484 489 let webauthn = &state.webauthn_config; 485 490 486 491 let reg_state = match state 487 - .repos.user 492 + .repos 493 + .user 488 494 .load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration) 489 495 .await 490 496 { ··· 530 536 } 531 537 }; 532 538 if let Err(e) = state 533 - .repos.user 539 + .repos 540 + .user 534 541 .save_passkey( 535 542 &input.did, 536 543 &credential_id, ··· 559 566 } 560 567 561 568 let _ = state 562 - .repos.user 569 + .repos 570 + .user 563 571 .delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration) 564 572 .await; 565 573 ··· 577 585 State(state): State<AppState>, 578 586 Json(input): Json<StartPasskeyRegistrationInput>, 579 587 ) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> { 580 - let user = match state.repos.user.get_user_for_passkey_setup(&input.did).await { 588 + let user = match state 589 + .repos 590 + .user 591 + .get_user_for_passkey_setup(&input.did) 592 + .await 593 + { 581 594 Ok(Some(u)) => u, 582 595 Ok(None) => { 583 596 return Err(ApiError::AccountNotFound); ··· 610 623 let webauthn = &state.webauthn_config; 611 624 612 625 let existing_passkeys = state 613 - .repos.user 626 + .repos 627 + .user 614 628 .get_passkeys_for_user(&input.did) 615 629 .await 616 630 .unwrap_or_default(); ··· 643 657 } 644 658 }; 645 659 if let Err(e) = state 646 - .repos.user 660 + .repos 661 + .user 647 662 .save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json) 648 663 .await 649 664 { ··· 682 697 NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles); 683 698 684 699 let user = match state 685 - .repos.user 700 + .repos 701 + .user 686 702 .get_user_for_passkey_recovery(identifier, normalized_handle.as_str()) 687 703 .await 688 704 { ··· 697 713 let expires_at = Utc::now() + Duration::hours(1); 698 714 699 715 if let Err(e) = state 700 - .repos.user 716 + .repos 717 + .user 701 718 .set_recovery_token(&user.did, &recovery_token_hash, expires_at) 702 719 .await 703 720 { ··· 771 788 password_hash, 772 789 }; 773 790 let result = match state 774 - .repos.user 791 + .repos 792 + .user 775 793 .recover_passkey_account(&recover_input) 776 794 .await 777 795 { ··· 789 807 let actual_channel = 790 808 tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); 791 809 if let Err(e) = state 792 - .repos.user 810 + .repos 811 + .user 793 812 .set_channel_verified(&input.did, actual_channel) 794 813 .await 795 814 {
+16 -8
crates/tranquil-api/src/server/passkeys.rs
··· 28 28 let webauthn = &state.webauthn_config; 29 29 30 30 let handle = state 31 - .repos.user 31 + .repos 32 + .user 32 33 .get_handle_by_did(&auth.did) 33 34 .await 34 35 .log_db_err("fetching user")? 35 36 .ok_or(ApiError::AccountNotFound)?; 36 37 37 38 let existing_passkeys = state 38 - .repos.user 39 + .repos 40 + .user 39 41 .get_passkeys_for_user(&auth.did) 40 42 .await 41 43 .log_db_err("fetching existing passkeys")?; ··· 60 62 })?; 61 63 62 64 state 63 - .repos.user 65 + .repos 66 + .user 64 67 .save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json) 65 68 .await 66 69 .log_db_err("saving registration state")?; ··· 94 97 let webauthn = &state.webauthn_config; 95 98 96 99 let reg_state_json = state 97 - .repos.user 100 + .repos 101 + .user 98 102 .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) 99 103 .await 100 104 .log_db_err("loading registration state")? ··· 125 129 })?; 126 130 127 131 let passkey_id = state 128 - .repos.user 132 + .repos 133 + .user 129 134 .save_passkey( 130 135 &auth.did, 131 136 passkey.cred_id(), ··· 136 141 .log_db_err("saving passkey")?; 137 142 138 143 if let Err(e) = state 139 - .repos.user 144 + .repos 145 + .user 140 146 .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) 141 147 .await 142 148 { ··· 177 183 auth: Auth<Active>, 178 184 ) -> Result<Json<ListPasskeysOutput>, ApiError> { 179 185 let passkeys = state 180 - .repos.user 186 + .repos 187 + .user 181 188 .get_passkeys_for_user(&auth.did) 182 189 .await 183 190 .log_db_err("fetching passkeys")?; ··· 243 250 let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; 244 251 245 252 match state 246 - .repos.user 253 + .repos 254 + .user 247 255 .update_passkey_name(id, &auth.did, &input.friendly_name) 248 256 .await 249 257 {
+24 -12
crates/tranquil-api/src/server/password.rs
··· 50 50 }; 51 51 52 52 let user_id = match state 53 - .repos.user 53 + .repos 54 + .user 54 55 .get_id_by_email_or_handle(normalized, normalized_handle.as_str()) 55 56 .await 56 57 { ··· 72 73 let code = generate_reset_code(); 73 74 let expires_at = Utc::now() + Duration::minutes(10); 74 75 if let Err(e) = state 75 - .repos.user 76 + .repos 77 + .user 76 78 .set_password_reset_code(user_id, &code, expires_at) 77 79 .await 78 80 { ··· 153 155 } 154 156 let password_hash = crate::common::hash_password_async(&password).await?; 155 157 let result = match state 156 - .repos.user 158 + .repos 159 + .user 157 160 .reset_password_with_sessions(user_id, &password_hash) 158 161 .await 159 162 { ··· 180 183 let actual_channel = 181 184 tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); 182 185 if let Err(e) = state 183 - .repos.user 186 + .repos 187 + .user 184 188 .set_channel_verified(&user.did, actual_channel) 185 189 .await 186 190 { ··· 225 229 let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?; 226 230 227 231 let user = state 228 - .repos.user 232 + .repos 233 + .user 229 234 .get_id_and_password_hash_by_did(password_mfa.did()) 230 235 .await 231 236 .log_db_err("in change_password")? ··· 234 239 let new_hash = crate::common::hash_password_async(&input.new_password).await?; 235 240 236 241 state 237 - .repos.user 242 + .repos 243 + .user 238 244 .update_password_hash(user.id, &new_hash) 239 245 .await 240 246 .log_db_err("updating password")?; ··· 248 254 auth: Auth<Active>, 249 255 ) -> Result<Json<HasPasswordResponse>, ApiError> { 250 256 let has = state 251 - .repos.user 257 + .repos 258 + .user 252 259 .has_password_by_did(&auth.did) 253 260 .await 254 261 .log_db_err("checking password status")? ··· 265 272 let reauth_mfa = require_reauth_window(&state, &auth).await?; 266 273 267 274 let has_passkeys = state 268 - .repos.user 275 + .repos 276 + .user 269 277 .has_passkeys(reauth_mfa.did()) 270 278 .await 271 279 .unwrap_or(false); ··· 276 284 } 277 285 278 286 let user = state 279 - .repos.user 287 + .repos 288 + .user 280 289 .get_password_info_by_did(reauth_mfa.did()) 281 290 .await 282 291 .log_db_err("getting password info")? ··· 289 298 } 290 299 291 300 state 292 - .repos.user 301 + .repos 302 + .user 293 303 .remove_user_password(user.id) 294 304 .await 295 305 .log_db_err("removing password")?; ··· 322 332 let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did); 323 333 324 334 let user = state 325 - .repos.user 335 + .repos 336 + .user 326 337 .get_password_info_by_did(did) 327 338 .await 328 339 .log_db_err("getting password info")? ··· 337 348 let new_hash = crate::common::hash_password_async(&new_password).await?; 338 349 339 350 state 340 - .repos.user 351 + .repos 352 + .user 341 353 .set_new_user_password(user.id, &new_hash) 342 354 .await 343 355 .log_db_err("setting password")?;
+18 -9
crates/tranquil-api/src/server/reauth.rs
··· 33 33 auth: Auth<Active>, 34 34 ) -> Result<Json<ReauthStatusOutput>, ApiError> { 35 35 let last_reauth_at = state 36 - .repos.session 36 + .repos 37 + .session 37 38 .get_last_reauth_at(&auth.did) 38 39 .await 39 40 .log_db_err("getting last reauth")?; ··· 66 67 Json(input): Json<PasswordReauthInput>, 67 68 ) -> Result<Json<ReauthOutput>, ApiError> { 68 69 let password_hash = state 69 - .repos.user 70 + .repos 71 + .user 70 72 .get_password_hash_by_did(&auth.did) 71 73 .await 72 74 .log_db_err("fetching password hash")? ··· 76 78 77 79 if !password_valid { 78 80 let app_password_hashes = state 79 - .repos.session 81 + .repos 82 + .session 80 83 .get_app_password_hashes_by_did(&auth.did) 81 84 .await 82 85 .unwrap_or_default(); ··· 146 149 auth: Auth<Active>, 147 150 ) -> Result<Json<PasskeyReauthStartOutput>, ApiError> { 148 151 let stored_passkeys = state 149 - .repos.user 152 + .repos 153 + .user 150 154 .get_passkeys_for_user(&auth.did) 151 155 .await 152 156 .log_db_err("getting passkeys")?; ··· 179 183 })?; 180 184 181 185 state 182 - .repos.user 186 + .repos 187 + .user 183 188 .save_webauthn_challenge( 184 189 &auth.did, 185 190 WebauthnChallengeType::Authentication, ··· 204 209 Json(input): Json<PasskeyReauthFinishInput>, 205 210 ) -> Result<Json<ReauthOutput>, ApiError> { 206 211 let auth_state_json = state 207 - .repos.user 212 + .repos 213 + .user 208 214 .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) 209 215 .await 210 216 .log_db_err("loading authentication state")? ··· 232 238 233 239 let cred_id_bytes = auth_result.cred_id().as_ref(); 234 240 match state 235 - .repos.user 241 + .repos 242 + .user 236 243 .update_passkey_counter( 237 244 cred_id_bytes, 238 245 i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), ··· 242 249 Ok(false) => { 243 250 warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key"); 244 251 let _ = state 245 - .repos.user 252 + .repos 253 + .user 246 254 .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) 247 255 .await; 248 256 return Err(ApiError::PasskeyCounterAnomaly); ··· 254 262 } 255 263 256 264 let _ = state 257 - .repos.user 265 + .repos 266 + .user 258 267 .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) 259 268 .await; 260 269
+2 -1
crates/tranquil-api/src/server/signing_key.rs
··· 44 44 let expires_at = Utc::now() + Duration::hours(24); 45 45 let private_bytes: &[u8] = &private_key_bytes; 46 46 match state 47 - .repos.infra 47 + .repos 48 + .infra 48 49 .reserve_signing_key( 49 50 input.did.as_ref(), 50 51 &public_key_did_key,
+18 -7
crates/tranquil-api/src/server/totp.rs
··· 41 41 let secret = generate_totp_secret(); 42 42 43 43 let handle = state 44 - .repos.user 44 + .repos 45 + .user 45 46 .get_handle_by_did(&auth.did) 46 47 .await 47 48 .log_db_err("fetching handle")? ··· 61 62 })?; 62 63 63 64 state 64 - .repos.user 65 + .repos 66 + .user 65 67 .upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION) 66 68 .await 67 69 .log_db_err("storing TOTP secret")?; ··· 139 141 })?; 140 142 141 143 state 142 - .repos.user 144 + .repos 145 + .user 143 146 .enable_totp_with_backup_codes(&auth.did, &backup_hashes) 144 147 .await 145 148 .log_db_err("enabling TOTP")?; ··· 173 176 let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?; 174 177 175 178 state 176 - .repos.user 179 + .repos 180 + .user 177 181 .delete_totp_and_backup_codes(totp_mfa.did()) 178 182 .await 179 183 .log_db_err("deleting TOTP")?; ··· 209 213 }; 210 214 211 215 let backup_count = state 212 - .repos.user 216 + .repos 217 + .user 213 218 .count_unused_backup_codes(&auth.did) 214 219 .await 215 220 .log_db_err("counting backup codes")?; ··· 259 264 })?; 260 265 261 266 state 262 - .repos.user 267 + .repos 268 + .user 263 269 .replace_backup_codes(totp_mfa.did(), &backup_hashes) 264 270 .await 265 271 .log_db_err("replacing backup codes")?; ··· 332 338 } 333 339 334 340 pub async fn has_totp_enabled(state: &AppState, did: &tranquil_pds::types::Did) -> bool { 335 - state.repos.user.has_totp_enabled(did).await.unwrap_or(false) 341 + state 342 + .repos 343 + .user 344 + .has_totp_enabled(did) 345 + .await 346 + .unwrap_or(false) 336 347 }
+10 -5
crates/tranquil-api/src/server/trusted_devices.rs
··· 72 72 auth: Auth<Active>, 73 73 ) -> Result<Json<ListTrustedDevicesOutput>, ApiError> { 74 74 let rows = state 75 - .repos.oauth 75 + .repos 76 + .oauth 76 77 .list_trusted_devices(&auth.did) 77 78 .await 78 79 .log_db_err("listing trusted devices")?; ··· 108 109 Json(input): Json<RevokeTrustedDeviceInput>, 109 110 ) -> Result<Json<SuccessResponse>, ApiError> { 110 111 match state 111 - .repos.oauth 112 + .repos 113 + .oauth 112 114 .device_belongs_to_user(&input.device_id, &auth.did) 113 115 .await 114 116 { ··· 123 125 } 124 126 125 127 state 126 - .repos.oauth 128 + .repos 129 + .oauth 127 130 .revoke_device_trust(&input.device_id) 128 131 .await 129 132 .log_db_err("revoking device trust")?; ··· 145 148 Json(input): Json<UpdateTrustedDeviceInput>, 146 149 ) -> Result<Json<SuccessResponse>, ApiError> { 147 150 match state 148 - .repos.oauth 151 + .repos 152 + .oauth 149 153 .device_belongs_to_user(&input.device_id, &auth.did) 150 154 .await 151 155 { ··· 160 164 } 161 165 162 166 state 163 - .repos.oauth 167 + .repos 168 + .oauth 164 169 .update_device_friendly_name(&input.device_id, input.friendly_name.as_deref()) 165 170 .await 166 171 .log_db_err("updating device friendly name")?;
+18 -9
crates/tranquil-api/src/server/verify_token.rs
··· 79 79 identifier: &str, 80 80 ) -> Result<Json<VerifyTokenOutput>, ApiError> { 81 81 let user = state 82 - .repos.user 82 + .repos 83 + .user 83 84 .get_verification_info(did) 84 85 .await 85 86 .log_db_err("during migration verification")? ··· 92 93 } 93 94 if !user.channel_verification.email { 94 95 state 95 - .repos.user 96 + .repos 97 + .user 96 98 .set_email_verified_flag(user.id) 97 99 .await 98 100 .log_db_err("updating email_verified status")?; ··· 118 120 identifier: &str, 119 121 ) -> Result<Json<VerifyTokenOutput>, ApiError> { 120 122 let user_id = state 121 - .repos.user 123 + .repos 124 + .user 122 125 .get_id_by_did(did) 123 126 .await 124 127 .log_db_err("fetching user id")? ··· 127 130 match channel { 128 131 CommsChannel::Email => { 129 132 let success = state 130 - .repos.user 133 + .repos 134 + .user 131 135 .verify_email_channel(user_id, identifier) 132 136 .await 133 137 .log_db_err("updating email channel")?; ··· 137 141 } 138 142 CommsChannel::Discord => { 139 143 state 140 - .repos.user 144 + .repos 145 + .user 141 146 .verify_discord_channel(user_id, identifier) 142 147 .await 143 148 .log_db_err("updating discord channel")?; 144 149 } 145 150 CommsChannel::Telegram => { 146 151 state 147 - .repos.user 152 + .repos 153 + .user 148 154 .verify_telegram_channel(user_id, identifier) 149 155 .await 150 156 .log_db_err("updating telegram channel")?; 151 157 } 152 158 CommsChannel::Signal => { 153 159 state 154 - .repos.user 160 + .repos 161 + .user 155 162 .verify_signal_channel(user_id, identifier) 156 163 .await 157 164 .log_db_err("updating signal channel")?; ··· 178 185 ) { 179 186 let recipient = match channel { 180 187 CommsChannel::Telegram => state 181 - .repos.user 188 + .repos 189 + .user 182 190 .get_telegram_chat_id(user_id) 183 191 .await 184 192 .ok() ··· 208 216 identifier: &str, 209 217 ) -> Result<Json<VerifyTokenOutput>, ApiError> { 210 218 let user = state 211 - .repos.user 219 + .repos 220 + .user 212 221 .get_verification_info(did) 213 222 .await 214 223 .log_db_err("during signup verification")?
+2 -1
crates/tranquil-api/src/telegram_webhook.rs
··· 71 71 "Received /start from Telegram user" 72 72 ); 73 73 match state 74 - .repos.user 74 + .repos 75 + .user 75 76 .store_telegram_chat_id(&username, from.id, handle.as_deref()) 76 77 .await 77 78 {
+28 -14
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
··· 50 50 ) -> Response { 51 51 let consent_request_id = RequestId::from(query.request_uri.clone()); 52 52 let request_data = match state 53 - .repos.oauth 53 + .repos 54 + .oauth 54 55 .get_authorization_request(&consent_request_id) 55 56 .await 56 57 { ··· 105 106 .and_then(|s| s.parse().ok()); 106 107 let delegation_grant = if let Some(ref ctrl_did) = controller_did_parsed { 107 108 state 108 - .repos.delegation 109 + .repos 110 + .delegation 109 111 .get_delegation(&did, ctrl_did) 110 112 .await 111 113 .ok() ··· 136 138 let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); 137 139 let consent_client_id = ClientId::from(request_data.parameters.client_id.clone()); 138 140 let preferences = state 139 - .repos.oauth 141 + .repos 142 + .oauth 140 143 .get_scope_preferences(&did, &consent_client_id) 141 144 .await 142 145 .unwrap_or_default(); ··· 206 209 .collect(); 207 210 208 211 let account_handle = state 209 - .repos.user 212 + .repos 213 + .user 210 214 .get_handle_by_did(&did) 211 215 .await 212 216 .ok() ··· 216 220 let (is_delegation, controller_did_resp, controller_handle, delegation_level) = 217 221 if let Some(ref ctrl_did) = controller_did_parsed { 218 222 let ctrl_handle = state 219 - .repos.user 223 + .repos 224 + .user 220 225 .get_handle_by_did(ctrl_did) 221 226 .await 222 227 .ok() ··· 273 278 ); 274 279 let consent_post_request_id = RequestId::from(form.request_uri.clone()); 275 280 let request_data = match state 276 - .repos.oauth 281 + .repos 282 + .oauth 277 283 .get_authorization_request(&consent_post_request_id) 278 284 .await 279 285 { ··· 302 308 }, 303 309 Err(_) => { 304 310 let _ = state 305 - .repos.oauth 311 + .repos 312 + .oauth 306 313 .delete_authorization_request(&consent_post_request_id) 307 314 .await; 308 315 return json_error( ··· 327 334 328 335 let delegation_grant = match controller_did_parsed.as_ref() { 329 336 Some(ctrl_did) => state 330 - .repos.delegation 337 + .repos 338 + .delegation 331 339 .get_delegation(&did, ctrl_did) 332 340 .await 333 341 .ok() ··· 397 405 .collect(); 398 406 let consent_post_client_id = ClientId::from(request_data.parameters.client_id.clone()); 399 407 let _ = state 400 - .repos.oauth 408 + .repos 409 + .oauth 401 410 .upsert_scope_preferences(&did, &consent_post_client_id, &preferences) 402 411 .await; 403 412 } 404 413 if let Err(e) = state 405 - .repos.oauth 414 + .repos 415 + .oauth 406 416 .update_request_scope(&consent_post_request_id, &approved_scope_str) 407 417 .await 408 418 { ··· 415 425 .map(|d| DeviceIdType::new(d.0.clone())); 416 426 let consent_post_code = AuthorizationCode::from(code.0.clone()); 417 427 if state 418 - .repos.oauth 428 + .repos 429 + .oauth 419 430 .update_authorization_request( 420 431 &consent_post_request_id, 421 432 &did, ··· 458 469 ) -> Response { 459 470 let request_id = RequestId::from(form.request_uri.clone()); 460 471 let request_data = match state 461 - .repos.oauth 472 + .repos 473 + .oauth 462 474 .get_authorization_request(&request_id) 463 475 .await 464 476 { ··· 499 511 let staleness = now - request_data.expires_at; 500 512 if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS { 501 513 let _ = state 502 - .repos.oauth 514 + .repos 515 + .oauth 503 516 .delete_authorization_request(&request_id) 504 517 .await; 505 518 return json_error( ··· 511 524 512 525 let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS); 513 526 match state 514 - .repos.oauth 527 + .repos 528 + .oauth 515 529 .extend_authorization_request_expiry(&request_id, new_expires_at) 516 530 .await 517 531 {
+56 -28
crates/tranquil-oauth-server/src/endpoints/authorize/login.rs
··· 25 25 }; 26 26 let request_id = RequestId::from(request_uri.clone()); 27 27 let request_data = match state 28 - .repos.oauth 28 + .repos 29 + .oauth 29 30 .get_authorization_request(&request_id) 30 31 .await 31 32 { ··· 61 62 }; 62 63 if request_data.expires_at < Utc::now() { 63 64 let _ = state 64 - .repos.oauth 65 + .repos 66 + .oauth 65 67 .delete_authorization_request(&request_id) 66 68 .await; 67 69 if wants_json(&headers) { ··· 104 106 tracing::info!(normalized = %normalized, "Normalized login_hint"); 105 107 106 108 match state 107 - .repos.user 109 + .repos 110 + .user 108 111 .get_login_check_by_handle_or_email(normalized.as_str()) 109 112 .await 110 113 { 111 114 Ok(Some(user)) => { 112 115 tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint"); 113 116 let is_delegated = state 114 - .repos.delegation 117 + .repos 118 + .delegation 115 119 .is_delegated_account(&user.did) 116 120 .await 117 121 .unwrap_or(false); ··· 121 125 if is_delegated { 122 126 tracing::info!("Redirecting to delegation auth"); 123 127 if let Err(e) = state 124 - .repos.oauth 128 + .repos 129 + .oauth 125 130 .set_request_did(&request_id, &user.did) 126 131 .await 127 132 { ··· 159 164 if !force_new_account 160 165 && let Some(device_id) = extract_device_cookie(&headers) 161 166 && let Ok(accounts) = state 162 - .repos.oauth 167 + .repos 168 + .oauth 163 169 .get_device_accounts(&device_id.clone()) 164 170 .await 165 171 && !accounts.is_empty() ··· 191 197 .ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?; 192 198 let request_id_json = RequestId::from(request_uri.clone()); 193 199 let request_data = state 194 - .repos.oauth 200 + .repos 201 + .oauth 195 202 .get_authorization_request(&request_id_json) 196 203 .await 197 204 .map_err(tranquil_pds::oauth::db_err_to_oauth)? 198 205 .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; 199 206 if request_data.expires_at < Utc::now() { 200 207 let _ = state 201 - .repos.oauth 208 + .repos 209 + .oauth 202 210 .delete_authorization_request(&request_id_json) 203 211 .await; 204 212 return Err(OAuthError::InvalidRequest( ··· 307 315 let json_response = wants_json(&headers); 308 316 let form_request_id = RequestId::from(form.request_uri.clone()); 309 317 let request_data = match state 310 - .repos.oauth 318 + .repos 319 + .oauth 311 320 .get_authorization_request(&form_request_id) 312 321 .await 313 322 { ··· 344 353 }; 345 354 if request_data.expires_at < Utc::now() { 346 355 let _ = state 347 - .repos.oauth 356 + .repos 357 + .oauth 348 358 .delete_authorization_request(&form_request_id) 349 359 .await; 350 360 if json_response { ··· 389 399 "Normalized username for lookup" 390 400 ); 391 401 let user = match state 392 - .repos.user 402 + .repos 403 + .user 393 404 .get_login_info_by_handle_or_email(normalized_username.as_str()) 394 405 .await 395 406 { ··· 411 422 } 412 423 if user.account_type.is_delegated() { 413 424 if state 414 - .repos.oauth 425 + .repos 426 + .oauth 415 427 .set_authorization_did(&form_request_id, &user.did, None) 416 428 .await 417 429 .is_err() ··· 439 451 440 452 if !user.password_required { 441 453 if state 442 - .repos.oauth 454 + .repos 455 + .oauth 443 456 .set_authorization_did(&form_request_id, &user.did, None) 444 457 .await 445 458 .is_err() ··· 522 535 } 523 536 } else { 524 537 if state 525 - .repos.oauth 538 + .repos 539 + .oauth 526 540 .set_authorization_did(&form_request_id, &user.did, None) 527 541 .await 528 542 .is_err() ··· 543 557 } 544 558 if user.two_factor_enabled { 545 559 let _ = state 546 - .repos.oauth 560 + .repos 561 + .oauth 547 562 .delete_2fa_challenge_by_request_uri(&form_request_id) 548 563 .await; 549 564 match state 550 - .repos.oauth 565 + .repos 566 + .oauth 551 567 .create_2fa_challenge(&user.did, &form_request_id) 552 568 .await 553 569 { ··· 602 618 last_seen_at: Utc::now(), 603 619 }; 604 620 if state 605 - .repos.oauth 621 + .repos 622 + .oauth 606 623 .create_device(&new_device_id_typed, &device_data) 607 624 .await 608 625 .is_ok() ··· 613 630 new_device_id_typed 614 631 }; 615 632 let _ = state 616 - .repos.oauth 633 + .repos 634 + .oauth 617 635 .upsert_account_device(&user.did, &final_device_id) 618 636 .await; 619 637 } 620 638 let set_auth_device_id = device_id.clone(); 621 639 if state 622 - .repos.oauth 640 + .repos 641 + .oauth 623 642 .set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref()) 624 643 .await 625 644 .is_err() ··· 673 692 let auth_post_device_id = device_id.clone(); 674 693 let auth_post_code = AuthorizationCode::from(code.0.clone()); 675 694 if state 676 - .repos.oauth 695 + .repos 696 + .oauth 677 697 .update_authorization_request( 678 698 &form_request_id, 679 699 &user.did, ··· 738 758 }; 739 759 let select_request_id = RequestId::from(form.request_uri.clone()); 740 760 let request_data = match state 741 - .repos.oauth 761 + .repos 762 + .oauth 742 763 .get_authorization_request(&select_request_id) 743 764 .await 744 765 { ··· 760 781 }; 761 782 if request_data.expires_at < Utc::now() { 762 783 let _ = state 763 - .repos.oauth 784 + .repos 785 + .oauth 764 786 .delete_authorization_request(&select_request_id) 765 787 .await; 766 788 return json_error( ··· 791 813 }; 792 814 let verify_device_id = device_id.clone(); 793 815 let account_valid = match state 794 - .repos.oauth 816 + .repos 817 + .oauth 795 818 .verify_account_on_device(&verify_device_id, &did) 796 819 .await 797 820 { ··· 851 874 .await; 852 875 if !device_is_trusted { 853 876 if state 854 - .repos.oauth 877 + .repos 878 + .oauth 855 879 .set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed)) 856 880 .await 857 881 .is_err() ··· 872 896 } 873 897 if user.two_factor_enabled { 874 898 let _ = state 875 - .repos.oauth 899 + .repos 900 + .oauth 876 901 .delete_2fa_challenge_by_request_uri(&select_request_id) 877 902 .await; 878 903 match state 879 - .repos.oauth 904 + .repos 905 + .oauth 880 906 .create_2fa_challenge(&did, &select_request_id) 881 907 .await 882 908 { ··· 915 941 } 916 942 let select_device_typed = device_id.clone(); 917 943 let _ = state 918 - .repos.oauth 944 + .repos 945 + .oauth 919 946 .upsert_account_device(&did, &select_device_typed) 920 947 .await; 921 948 922 949 if state 923 - .repos.oauth 950 + .repos 951 + .oauth 924 952 .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) 925 953 .await 926 954 .is_err()
+4 -3
crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs
··· 245 245 ) -> Response { 246 246 let deny_request_id = RequestId::from(form.request_uri.clone()); 247 247 let request_data = match state 248 - .repos.oauth 248 + .repos 249 + .oauth 249 250 .get_authorization_request(&deny_request_id) 250 251 .await 251 252 { ··· 272 273 } 273 274 }; 274 275 let _ = state 275 - .repos.oauth 276 + .repos 277 + .oauth 276 278 .delete_authorization_request(&deny_request_id) 277 279 .await; 278 280 let redirect_uri = &request_data.parameters.redirect_uri; ··· 294 296 pub struct AuthorizeDenyForm { 295 297 pub request_uri: String, 296 298 } 297 - 298 299 299 300 mod consent; 300 301 mod login;
+58 -29
crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs
··· 20 20 BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); 21 21 22 22 let user = state 23 - .repos.user 23 + .repos 24 + .user 24 25 .get_login_check_by_handle_or_email(bare_identifier.as_str()) 25 26 .await; 26 27 ··· 52 53 NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); 53 54 54 55 let user = state 55 - .repos.user 56 + .repos 57 + .user 56 58 .get_login_check_by_handle_or_email(normalized_identifier.as_str()) 57 59 .await; 58 60 ··· 68 70 let totp = tranquil_api::server::has_totp_enabled(&state, &u.did).await; 69 71 let has_pw = u.password_hash.is_some(); 70 72 let has_controllers = state 71 - .repos.delegation 73 + .repos 74 + .delegation 72 75 .is_delegated_account(&u.did) 73 76 .await 74 77 .unwrap_or(false); ··· 113 116 ) -> Response { 114 117 let passkey_start_request_id = RequestId::from(form.request_uri.clone()); 115 118 let request_data = match state 116 - .repos.oauth 119 + .repos 120 + .oauth 117 121 .get_authorization_request(&passkey_start_request_id) 118 122 .await 119 123 { ··· 142 146 143 147 if request_data.expires_at < Utc::now() { 144 148 let _ = state 145 - .repos.oauth 149 + .repos 150 + .oauth 146 151 .delete_authorization_request(&passkey_start_request_id) 147 152 .await; 148 153 return ( ··· 160 165 NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); 161 166 162 167 let user = match state 163 - .repos.user 168 + .repos 169 + .user 164 170 .get_login_info_by_handle_or_email(normalized_username.as_str()) 165 171 .await 166 172 { ··· 299 305 }; 300 306 301 307 if let Err(e) = state 302 - .repos.user 308 + .repos 309 + .user 303 310 .save_webauthn_challenge( 304 311 &user.did, 305 312 WebauthnChallengeType::Authentication, ··· 322 329 Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() { 323 330 Ok(delegated_did) if delegated_did != user.did => { 324 331 match state 325 - .repos.delegation 332 + .repos 333 + .delegation 326 334 .get_delegation(&delegated_did, &user.did) 327 335 .await 328 336 { ··· 359 367 "Passkey auth with delegated_did param - setting delegation flow" 360 368 ); 361 369 if state 362 - .repos.oauth 370 + .repos 371 + .oauth 363 372 .set_authorization_did(&passkey_start_request_id, &delegated_did, None) 364 373 .await 365 374 .is_err() ··· 367 376 return OAuthError::ServerError("An error occurred.".into()).into_response(); 368 377 } 369 378 if state 370 - .repos.oauth 379 + .repos 380 + .oauth 371 381 .set_controller_did(&passkey_start_request_id, &user.did) 372 382 .await 373 383 .is_err() ··· 381 391 "Passkey auth in delegation flow - preserving delegated DID" 382 392 ); 383 393 if state 384 - .repos.oauth 394 + .repos 395 + .oauth 385 396 .set_controller_did(&passkey_start_request_id, &user.did) 386 397 .await 387 398 .is_err() ··· 389 400 return OAuthError::ServerError("An error occurred.".into()).into_response(); 390 401 } 391 402 } else if state 392 - .repos.oauth 403 + .repos 404 + .oauth 393 405 .set_authorization_did(&passkey_start_request_id, &user.did, None) 394 406 .await 395 407 .is_err() ··· 415 427 ) -> Response { 416 428 let passkey_finish_request_id = RequestId::from(form.request_uri.clone()); 417 429 let request_data = match state 418 - .repos.oauth 430 + .repos 431 + .oauth 419 432 .get_authorization_request(&passkey_finish_request_id) 420 433 .await 421 434 { ··· 444 457 445 458 if request_data.expires_at < Utc::now() { 446 459 let _ = state 447 - .repos.oauth 460 + .repos 461 + .oauth 448 462 .delete_authorization_request(&passkey_finish_request_id) 449 463 .await; 450 464 return ( ··· 491 505 let passkey_owner_did = controller_did.as_ref().unwrap_or(&did); 492 506 493 507 let auth_state_json = match state 494 - .repos.user 508 + .repos 509 + .user 495 510 .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 496 511 .await 497 512 { ··· 570 585 }; 571 586 572 587 if let Err(e) = state 573 - .repos.user 588 + .repos 589 + .user 574 590 .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 575 591 .await 576 592 { ··· 580 596 if auth_result.needs_update() { 581 597 let cred_id_bytes = auth_result.cred_id().as_slice(); 582 598 match state 583 - .repos.user 599 + .repos 600 + .user 584 601 .update_passkey_counter( 585 602 cred_id_bytes, 586 603 i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), ··· 640 657 let passkey_final_device_id = device_id.clone(); 641 658 let passkey_final_code = AuthorizationCode::from(code.0.clone()); 642 659 if state 643 - .repos.oauth 660 + .repos 661 + .oauth 644 662 .update_authorization_request( 645 663 &passkey_finish_request_id, 646 664 &did, ··· 691 709 ) -> Response { 692 710 let auth_passkey_start_request_id = RequestId::from(query.request_uri.clone()); 693 711 let request_data = match state 694 - .repos.oauth 712 + .repos 713 + .oauth 695 714 .get_authorization_request(&auth_passkey_start_request_id) 696 715 .await 697 716 { ··· 720 739 721 740 if request_data.expires_at < Utc::now() { 722 741 let _ = state 723 - .repos.oauth 742 + .repos 743 + .oauth 724 744 .delete_authorization_request(&auth_passkey_start_request_id) 725 745 .await; 726 746 return ( ··· 822 842 }; 823 843 824 844 if let Err(e) = state 825 - .repos.user 845 + .repos 846 + .user 826 847 .save_webauthn_challenge(&did, WebauthnChallengeType::Authentication, &state_json) 827 848 .await 828 849 { ··· 858 879 let passkey_finish_request_id = RequestId::from(form.request_uri.clone()); 859 880 860 881 let request_data = match state 861 - .repos.oauth 882 + .repos 883 + .oauth 862 884 .get_authorization_request(&passkey_finish_request_id) 863 885 .await 864 886 { ··· 887 909 888 910 if request_data.expires_at < Utc::now() { 889 911 let _ = state 890 - .repos.oauth 912 + .repos 913 + .oauth 891 914 .delete_authorization_request(&passkey_finish_request_id) 892 915 .await; 893 916 return ( ··· 929 952 }; 930 953 931 954 let auth_state_json = match state 932 - .repos.user 955 + .repos 956 + .user 933 957 .load_webauthn_challenge(&did, WebauthnChallengeType::Authentication) 934 958 .await 935 959 { ··· 1003 1027 }; 1004 1028 1005 1029 let _ = state 1006 - .repos.user 1030 + .repos 1031 + .user 1007 1032 .delete_webauthn_challenge(&did, WebauthnChallengeType::Authentication) 1008 1033 .await; 1009 1034 1010 1035 match state 1011 - .repos.user 1036 + .repos 1037 + .user 1012 1038 .update_passkey_counter( 1013 1039 credential.id.as_ref(), 1014 1040 i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), ··· 1033 1059 } 1034 1060 1035 1061 let has_totp = state 1036 - .repos.user 1062 + .repos 1063 + .user 1037 1064 .has_totp_enabled(&did) 1038 1065 .await 1039 1066 .unwrap_or(false); ··· 1064 1091 }; 1065 1092 1066 1093 let _ = state 1067 - .repos.oauth 1094 + .repos 1095 + .oauth 1068 1096 .delete_2fa_challenge_by_request_uri(&passkey_finish_request_id) 1069 1097 .await; 1070 1098 match state 1071 - .repos.oauth 1099 + .repos 1100 + .oauth 1072 1101 .create_2fa_challenge(&did, &passkey_finish_request_id) 1073 1102 .await 1074 1103 {
+14 -7
crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs
··· 16 16 17 17 let request_id = RequestId::from(form.request_uri.clone()); 18 18 let request_data = match state 19 - .repos.oauth 19 + .repos 20 + .oauth 20 21 .get_authorization_request(&request_id) 21 22 .await 22 23 { ··· 50 51 51 52 if request_data.expires_at < Utc::now() { 52 53 let _ = state 53 - .repos.oauth 54 + .repos 55 + .oauth 54 56 .delete_authorization_request(&request_id) 55 57 .await; 56 58 return ( ··· 114 116 } 115 117 116 118 let password_hashes = match state 117 - .repos.session 119 + .repos 120 + .session 118 121 .get_app_password_hashes_by_did(&did) 119 122 .await 120 123 { ··· 202 205 } 203 206 204 207 if let Err(e) = state 205 - .repos.oauth 208 + .repos 209 + .oauth 206 210 .set_authorization_did(&request_id, &did, None) 207 211 .await 208 212 { ··· 257 261 let code = Code::generate(); 258 262 let auth_code = AuthorizationCode::from(code.0.clone()); 259 263 if let Err(e) = state 260 - .repos.oauth 264 + .repos 265 + .oauth 261 266 .update_authorization_request(&request_id, &did, None, &auth_code) 262 267 .await 263 268 { ··· 317 322 }; 318 323 319 324 if let Err(e) = state 320 - .repos.oauth 325 + .repos 326 + .oauth 321 327 .create_device(&device_typed, &device_data) 322 328 .await 323 329 { ··· 333 339 } 334 340 335 341 if let Err(e) = state 336 - .repos.oauth 342 + .repos 343 + .oauth 337 344 .upsert_account_device(did, &device_typed) 338 345 .await 339 346 {
+16 -8
crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs
··· 44 44 ); 45 45 } 46 46 let _request_data = match state 47 - .repos.oauth 47 + .repos 48 + .oauth 48 49 .get_authorization_request(&twofa_request_id) 49 50 .await 50 51 { ··· 88 89 }; 89 90 let twofa_post_request_id = RequestId::from(form.request_uri.clone()); 90 91 let request_data = match state 91 - .repos.oauth 92 + .repos 93 + .oauth 92 94 .get_authorization_request(&twofa_post_request_id) 93 95 .await 94 96 { ··· 110 112 }; 111 113 if request_data.expires_at < Utc::now() { 112 114 let _ = state 113 - .repos.oauth 115 + .repos 116 + .oauth 114 117 .delete_authorization_request(&twofa_post_request_id) 115 118 .await; 116 119 return json_error( ··· 120 123 ); 121 124 } 122 125 let challenge = state 123 - .repos.oauth 126 + .repos 127 + .oauth 124 128 .get_2fa_challenge(&twofa_post_request_id) 125 129 .await 126 130 .ok() ··· 162 166 let twofa_totp_device_id = device_id.clone(); 163 167 let twofa_totp_code = AuthorizationCode::from(code.0.clone()); 164 168 if state 165 - .repos.oauth 169 + .repos 170 + .oauth 166 171 .update_authorization_request( 167 172 &twofa_post_request_id, 168 173 &challenge.did, ··· 250 255 last_seen_at: Utc::now(), 251 256 }; 252 257 if state 253 - .repos.oauth 258 + .repos 259 + .oauth 254 260 .create_device(&new_device_id_typed, &device_data) 255 261 .await 256 262 .is_ok() ··· 262 268 } 263 269 }; 264 270 let _ = state 265 - .repos.oauth 271 + .repos 272 + .oauth 266 273 .upsert_account_device(&did, &trust_device_id) 267 274 .await; 268 275 let _ = ··· 305 312 let twofa_final_device_id = device_id.clone(); 306 313 let twofa_final_code = AuthorizationCode::from(code.0.clone()); 307 314 if state 308 - .repos.oauth 315 + .repos 316 + .oauth 309 317 .update_authorization_request( 310 318 &twofa_post_request_id, 311 319 &did,
+14 -7
crates/tranquil-oauth-server/src/endpoints/delegation.rs
··· 25 25 async fn get_auth_request(state: &AppState, request_uri: &str) -> Result<RequestData, Response> { 26 26 let request_id = RequestId::from(request_uri.to_string()); 27 27 match state 28 - .repos.oauth 28 + .repos 29 + .oauth 29 30 .get_authorization_request(&request_id) 30 31 .await 31 32 { ··· 43 44 controller_did: &Did, 44 45 ) -> Result<tranquil_db_traits::DelegationGrant, Response> { 45 46 match state 46 - .repos.delegation 47 + .repos 48 + .delegation 47 49 .get_delegation(delegated_did, controller_did) 48 50 .await 49 51 { ··· 65 67 user_agent: Option<&str>, 66 68 ) -> Response { 67 69 let _ = state 68 - .repos.delegation 70 + .repos 71 + .delegation 69 72 .log_delegation_action( 70 73 delegated_did, 71 74 controller_did, ··· 87 90 ) -> Result<(), Response> { 88 91 let request_id = RequestId::from(request_uri.to_string()); 89 92 state 90 - .repos.oauth 93 + .repos 94 + .oauth 91 95 .set_request_did(&request_id, delegated_did) 92 96 .await 93 97 .map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?; 94 98 state 95 - .repos.oauth 99 + .repos 100 + .oauth 96 101 .set_controller_did(&request_id, controller_did) 97 102 .await 98 103 .map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?; ··· 211 216 212 217 let is_cross_pds = form.auth_method.as_deref() == Some("cross_pds"); 213 218 let controller_local = state 214 - .repos.user 219 + .repos 220 + .user 215 221 .get_auth_info_by_did(&controller_did) 216 222 .await 217 223 .ok() ··· 562 568 } 563 569 564 570 let _ = state 565 - .repos.delegation 571 + .repos 572 + .delegation 566 573 .log_delegation_action( 567 574 delegated_did, 568 575 controller_did,
+2 -1
crates/tranquil-oauth-server/src/endpoints/par.rs
··· 115 115 }; 116 116 let request_id_typed = RequestIdType::from(request_id.0.clone()); 117 117 state 118 - .repos.oauth 118 + .repos 119 + .oauth 119 120 .create_authorization_request(&request_id_typed, &request_data) 120 121 .await 121 122 .map_err(tranquil_pds::oauth::db_err_to_oauth)?;
+16 -8
crates/tranquil-oauth-server/src/endpoints/token/grants.rs
··· 47 47 }; 48 48 let auth_code = AuthorizationCode::from(code); 49 49 let auth_request = state 50 - .repos.oauth 50 + .repos 51 + .oauth 51 52 .consume_authorization_request_by_code(&auth_code) 52 53 .await 53 54 .map_err(tranquil_pds::oauth::db_err_to_oauth)? ··· 104 105 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 105 106 let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; 106 107 if !state 107 - .repos.oauth 108 + .repos 109 + .oauth 108 110 .check_and_record_dpop_jti(&result.jti) 109 111 .await 110 112 .map_err(tranquil_pds::oauth::db_err_to_oauth)? ··· 140 142 .parse() 141 143 .map_err(|_| OAuthError::InvalidRequest("Invalid controller DID format".to_string()))?; 142 144 let grant = state 143 - .repos.delegation 145 + .repos 146 + .delegation 144 147 .get_delegation(&did_parsed, &controller_parsed) 145 148 .await 146 149 .ok() ··· 200 203 controller_did: controller_did.clone(), 201 204 }; 202 205 state 203 - .repos.oauth 206 + .repos 207 + .oauth 204 208 .create_token(&token_data) 205 209 .await 206 210 .map_err(tranquil_pds::oauth::db_err_to_oauth)?; ··· 320 324 "Refresh token reuse detected, revoking token family" 321 325 ); 322 326 state 323 - .repos.oauth 327 + .repos 328 + .oauth 324 329 .delete_token_family(original_token_id) 325 330 .await 326 331 .map_err(tranquil_pds::oauth::db_err_to_oauth)?; ··· 331 336 RefreshTokenLookup::Expired { db_id } => { 332 337 tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token has expired"); 333 338 state 334 - .repos.oauth 339 + .repos 340 + .oauth 335 341 .delete_token_family(db_id) 336 342 .await 337 343 .map_err(tranquil_pds::oauth::db_err_to_oauth)?; ··· 353 359 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 354 360 let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; 355 361 if !state 356 - .repos.oauth 362 + .repos 363 + .oauth 357 364 .check_and_record_dpop_jti(&result.jti) 358 365 .await 359 366 .map_err(tranquil_pds::oauth::db_err_to_oauth)? ··· 386 393 let new_expires_at = Utc::now() + Duration::days(refresh_expiry_days); 387 394 let new_refresh_typed = RefreshTokenType::from(new_refresh_token.0.clone()); 388 395 state 389 - .repos.oauth 396 + .repos 397 + .oauth 390 398 .rotate_token(db_id, &new_refresh_typed, new_expires_at) 391 399 .await 392 400 .map_err(tranquil_pds::oauth::db_err_to_oauth)?;
+6 -3
crates/tranquil-oauth-server/src/endpoints/token/introspect.rs
··· 24 24 if let Some(token) = &request.token { 25 25 let refresh_token = RefreshToken::from(token.clone()); 26 26 if let Some((db_id, _)) = state 27 - .repos.oauth 27 + .repos 28 + .oauth 28 29 .get_token_by_refresh_token(&refresh_token) 29 30 .await 30 31 .map_err(tranquil_pds::oauth::db_err_to_oauth)? 31 32 { 32 33 state 33 - .repos.oauth 34 + .repos 35 + .oauth 34 36 .delete_token_family(db_id) 35 37 .await 36 38 .map_err(tranquil_pds::oauth::db_err_to_oauth)?; 37 39 } else { 38 40 let token_id = TokenId::from(token.clone()); 39 41 state 40 - .repos.oauth 42 + .repos 43 + .oauth 41 44 .delete_token(&token_id) 42 45 .await 43 46 .map_err(tranquil_pds::oauth::db_err_to_oauth)?;
+53 -26
crates/tranquil-oauth-server/src/sso_endpoints.rs
··· 112 112 _ => { 113 113 let request_id = RequestId::new(request_uri.clone()); 114 114 let _request_data = state 115 - .repos.oauth 115 + .repos 116 + .oauth 116 117 .get_authorization_request(&request_id) 117 118 .await? 118 119 .ok_or(ApiError::InvalidRequest( ··· 135 136 })?; 136 137 137 138 state 138 - .repos.sso 139 + .repos 140 + .sso 139 141 .create_sso_auth_state( 140 142 &sso_state, 141 143 &request_uri, ··· 363 365 user_info: &tranquil_pds::sso::providers::SsoUserInfo, 364 366 ) -> Response { 365 367 let identity = match state 366 - .repos.sso 368 + .repos 369 + .sso 367 370 .get_external_identity_by_provider(provider, &user_info.provider_user_id) 368 371 .await 369 372 { ··· 371 374 Ok(None) => { 372 375 let token = generate_registration_token(); 373 376 if let Err(e) = state 374 - .repos.sso 377 + .repos 378 + .sso 375 379 .create_pending_registration( 376 380 &token, 377 381 request_uri, ··· 398 402 } 399 403 }; 400 404 401 - let is_verified = match state.repos.user.get_session_info_by_did(&identity.did).await { 405 + let is_verified = match state 406 + .repos 407 + .user 408 + .get_session_info_by_did(&identity.did) 409 + .await 410 + { 402 411 Ok(Some(info)) => info.channel_verification.has_any_verified(), 403 412 Ok(None) => { 404 413 tracing::error!("User not found for SSO login: {}", identity.did); ··· 423 432 } 424 433 425 434 if let Err(e) = state 426 - .repos.sso 435 + .repos 436 + .sso 427 437 .update_external_identity_login( 428 438 identity.id, 429 439 user_info.username.as_deref(), ··· 436 446 437 447 let request_id = RequestId::new(request_uri.to_string()); 438 448 if let Err(e) = state 439 - .repos.oauth 449 + .repos 450 + .oauth 440 451 .set_authorization_did(&request_id, &identity.did, None) 441 452 .await 442 453 { ··· 478 489 user_info: &tranquil_pds::sso::providers::SsoUserInfo, 479 490 ) -> Response { 480 491 let existing = state 481 - .repos.sso 492 + .repos 493 + .sso 482 494 .get_external_identity_by_provider(provider, &user_info.provider_user_id) 483 495 .await; 484 496 ··· 517 529 } 518 530 519 531 if let Err(e) = state 520 - .repos.sso 532 + .repos 533 + .sso 521 534 .create_external_identity( 522 535 &did, 523 536 provider, ··· 551 564 user_info: &tranquil_pds::sso::providers::SsoUserInfo, 552 565 ) -> Response { 553 566 match state 554 - .repos.sso 567 + .repos 568 + .sso 555 569 .get_external_identity_by_provider(provider, &user_info.provider_user_id) 556 570 .await 557 571 { ··· 569 583 570 584 let token = generate_registration_token(); 571 585 if let Err(e) = state 572 - .repos.sso 586 + .repos 587 + .sso 573 588 .create_pending_registration( 574 589 &token, 575 590 request_uri, ··· 612 627 auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>, 613 628 ) -> Result<Json<LinkedAccountsResponse>, ApiError> { 614 629 let identities = state 615 - .repos.sso 630 + .repos 631 + .sso 616 632 .get_external_identities_by_did(&auth.did) 617 633 .await?; 618 634 ··· 657 673 let id = uuid::Uuid::parse_str(&input.id).map_err(|_| ApiError::InvalidId)?; 658 674 659 675 let has_password = state 660 - .repos.user 676 + .repos 677 + .user 661 678 .has_password_by_did(&auth.did) 662 679 .await? 663 680 .unwrap_or(false); ··· 667 684 668 685 if !has_password && !has_passkeys { 669 686 let identities = state 670 - .repos.sso 687 + .repos 688 + .sso 671 689 .get_external_identities_by_did(&auth.did) 672 690 .await?; 673 691 ··· 680 698 } 681 699 682 700 let deleted = state 683 - .repos.sso 701 + .repos 702 + .sso 684 703 .delete_external_identity(id, &auth.did) 685 704 .await?; 686 705 ··· 718 737 } 719 738 720 739 let pending = state 721 - .repos.sso 740 + .repos 741 + .sso 722 742 .get_pending_registration(&query.token) 723 743 .await? 724 744 .ok_or(ApiError::SsoSessionExpired)?; ··· 780 800 }; 781 801 782 802 let db_available = state 783 - .repos.user 803 + .repos 804 + .user 784 805 .check_handle_available_for_new_account(&handle_typed) 785 806 .await 786 807 .unwrap_or(false); ··· 850 871 } 851 872 852 873 let pending_preview = state 853 - .repos.sso 874 + .repos 875 + .sso 854 876 .get_pending_registration(&input.token) 855 877 .await? 856 878 .ok_or(ApiError::SsoSessionExpired)?; ··· 977 999 let handle_typed: tranquil_pds::types::Handle = 978 1000 handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; 979 1001 let reserved = state 980 - .repos.user 1002 + .repos 1003 + .user 981 1004 .reserve_handle(&handle_typed, client_ip) 982 1005 .await 983 1006 .unwrap_or(false); ··· 1178 1201 }; 1179 1202 1180 1203 let _ = state 1181 - .repos.user 1204 + .repos 1205 + .user 1182 1206 .release_handle_reservation(&handle_typed) 1183 1207 .await; 1184 1208 ··· 1216 1240 1217 1241 let app_password = generate_app_password(); 1218 1242 let app_password_name = "bsky.app".to_string(); 1219 - let app_password_hash = 1220 - tranquil_api::common::hash_or_internal_error(&app_password)?; 1243 + let app_password_hash = tranquil_api::common::hash_or_internal_error(&app_password)?; 1221 1244 1222 1245 let app_password_data = tranquil_db_traits::AppPasswordCreate { 1223 1246 user_id: create_result.user_id, ··· 1228 1251 created_by_controller_did: None, 1229 1252 }; 1230 1253 if let Err(e) = state 1231 - .repos.session 1254 + .repos 1255 + .session 1232 1256 .create_app_password(&app_password_data) 1233 1257 .await 1234 1258 { ··· 1240 1264 if !is_standalone { 1241 1265 let request_id = RequestId::new(pending_preview.request_uri.clone()); 1242 1266 if let Err(e) = state 1243 - .repos.oauth 1267 + .repos 1268 + .oauth 1244 1269 .set_authorization_did(&request_id, &did_typed, None) 1245 1270 .await 1246 1271 { ··· 1259 1284 ); 1260 1285 1261 1286 let user_id = state 1262 - .repos.user 1287 + .repos 1288 + .user 1263 1289 .get_id_by_did(&did_typed) 1264 1290 .await 1265 1291 .unwrap_or(None); ··· 1270 1296 1271 1297 if channel_auto_verified { 1272 1298 let _ = state 1273 - .repos.user 1299 + .repos 1300 + .user 1274 1301 .set_channel_verified(&did_typed, tranquil_db_traits::CommsChannel::Email) 1275 1302 .await; 1276 1303 tracing::info!(did = %did, "Auto-verified email from SSO provider");
+3 -3
crates/tranquil-pds/src/api/mod.rs
··· 8 8 pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit}; 9 9 pub use responses::{ 10 10 AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput, 11 - EmptyResponse, HasPasswordResponse, InUseOutput, OptionsResponse, 12 - PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse, 13 - TokenRequiredResponse, VerifiedResponse, 11 + EmptyResponse, HasPasswordResponse, InUseOutput, OptionsResponse, PasswordResetOutput, 12 + PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse, TokenRequiredResponse, 13 + VerifiedResponse, 14 14 };
+4 -2
crates/tranquil-pds/src/auth/account_verified.rs
··· 22 22 user: &'a AuthenticatedUser, 23 23 ) -> Result<AccountVerified<'a>, ApiError> { 24 24 let is_verified = state 25 - .repos.user 25 + .repos 26 + .user 26 27 .has_verified_comms_channel(&user.did) 27 28 .await 28 29 .unwrap_or(false); ··· 32 33 } 33 34 34 35 let is_delegated = state 35 - .repos.delegation 36 + .repos 37 + .delegation 36 38 .is_delegated_account(&user.did) 37 39 .await 38 40 .unwrap_or(false);
+4 -2
crates/tranquil-pds/src/auth/extractor.rs
··· 242 242 { 243 243 Ok(result) => { 244 244 let user_info = state 245 - .repos.user 245 + .repos 246 + .user 246 247 .get_user_info_by_did(&result.did) 247 248 .await 248 249 .ok() ··· 321 322 .unwrap_or_else(|| parts.uri.path().to_string()); 322 323 let uri = build_full_url(&original_uri); 323 324 324 - match validate_bearer_token_for_service_auth(state.repos.user.as_ref(), &extracted.token).await { 325 + match validate_bearer_token_for_service_auth(state.repos.user.as_ref(), &extracted.token).await 326 + { 325 327 Ok(user) if !user.auth_source.is_oauth() => { 326 328 return Ok(ExtractedAuth::User(user)); 327 329 }
+12 -6
crates/tranquil-pds/src/auth/mfa_verified.rs
··· 99 99 use chrono::Utc; 100 100 101 101 let status = state 102 - .repos.session 102 + .repos 103 + .session 103 104 .get_session_mfa_status(&user.did) 104 105 .await 105 106 .ok() ··· 144 145 use crate::auth::reauth::check_reauth_required_cached; 145 146 146 147 let has_password = state 147 - .repos.user 148 + .repos 149 + .user 148 150 .has_password_by_did(&user.did) 149 151 .await 150 152 .ok() 151 153 .flatten() 152 154 .unwrap_or(false); 153 155 let has_passkeys = state 154 - .repos.user 156 + .repos 157 + .user 155 158 .has_passkeys(&user.did) 156 159 .await 157 160 .unwrap_or(false); 158 161 let has_totp = state 159 - .repos.user 162 + .repos 163 + .user 160 164 .has_totp_enabled(&user.did) 161 165 .await 162 166 .unwrap_or(false); ··· 188 192 password: &str, 189 193 ) -> Result<MfaVerified<'a>, crate::api::error::ApiError> { 190 194 let hash = state 191 - .repos.user 195 + .repos 196 + .user 192 197 .get_password_hash_by_did(&user.did) 193 198 .await 194 199 .ok() ··· 220 225 221 226 if is_backup_code_format(code) { 222 227 let backup_codes = state 223 - .repos.user 228 + .repos 229 + .user 224 230 .get_unused_backup_codes(&user.did) 225 231 .await 226 232 .ok()
+2 -4
crates/tranquil-pds/src/auth/mod.rs
··· 599 599 if !allow_takendown && status.is_takendown() { 600 600 return Err(TokenValidationError::AccountTakedown); 601 601 } 602 - let key_bytes = try_decrypt_user_key( 603 - user_info.key_bytes.as_deref(), 604 - user_info.encryption_version, 605 - ); 602 + let key_bytes = 603 + try_decrypt_user_key(user_info.key_bytes.as_deref(), user_info.encryption_version); 606 604 Ok(AuthenticatedUser { 607 605 did: result_did, 608 606 key_bytes,
+2 -1
crates/tranquil-pds/src/delegation/mod.rs
··· 30 30 did: &Did, 31 31 ) -> Result<ResolvedIdentity, DidResolutionError> { 32 32 let is_local = state 33 - .repos.user 33 + .repos 34 + .user 34 35 .get_by_did(did) 35 36 .await 36 37 .ok()
+22 -11
crates/tranquil-pds/src/repo_ops.rs
··· 85 85 86 86 pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result<CommitCid, ApiError> { 87 87 let root_cid_str = state 88 - .repos.repo 88 + .repos 89 + .repo 89 90 .get_repo_root_cid_by_user_id(user_id) 90 91 .await 91 92 .map_err(|e| { ··· 168 169 let write_lock = state.repo_write_locks.lock(user_id).await; 169 170 170 171 let root_cid_str = state 171 - .repos.repo 172 + .repos 173 + .repo 172 174 .get_repo_root_cid_by_user_id(user_id) 173 175 .await 174 176 .map_err(|e| { ··· 254 256 if let Some(controller_did) = params.controller_did 255 257 && let Some(detail) = params.delegation_detail 256 258 && let Err(e) = state 257 - .repos.delegation 259 + .repos 260 + .delegation 258 261 .log_delegation_action( 259 262 params.did, 260 263 controller_did, ··· 351 354 obsolete_cids, 352 355 } = params; 353 356 let key_row = state 354 - .repos.user 357 + .repos 358 + .user 355 359 .get_user_key_by_id(user_id) 356 360 .await 357 361 .map_err(|e| CommitError::DatabaseError(format!("Failed to fetch signing key: {}", e)))? ··· 485 489 }; 486 490 487 491 let _result = state 488 - .repos.repo 492 + .repos 493 + .repo 489 494 .apply_commit(input) 490 495 .await 491 496 .map_err(|e| match e { ··· 507 512 record: &serde_json::Value, 508 513 ) -> Result<(String, Cid), CommitError> { 509 514 let user_id: Uuid = state 510 - .repos.user 515 + .repos 516 + .user 511 517 .get_id_by_did(did) 512 518 .await 513 519 .map_err(|e| CommitError::DatabaseError(e.to_string()))? ··· 516 522 let _write_lock = state.repo_write_locks.lock(user_id).await; 517 523 518 524 let root_cid_link = state 519 - .repos.repo 525 + .repos 526 + .repo 520 527 .get_repo_root_cid_by_user_id(user_id) 521 528 .await 522 529 .map_err(|e| CommitError::DatabaseError(e.to_string()))? ··· 610 617 handle: Option<&Handle>, 611 618 ) -> Result<SequenceNumber, CommitError> { 612 619 state 613 - .repos.repo 620 + .repos 621 + .repo 614 622 .insert_identity_event(did, handle) 615 623 .await 616 624 .map_err(|e| CommitError::DatabaseError(format!("identity event: {}", e))) ··· 621 629 status: tranquil_db_traits::AccountStatus, 622 630 ) -> Result<SequenceNumber, CommitError> { 623 631 state 624 - .repos.repo 632 + .repos 633 + .repo 625 634 .insert_account_event(did, status) 626 635 .await 627 636 .map_err(|e| CommitError::DatabaseError(format!("account event: {}", e))) ··· 636 645 .parse() 637 646 .map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?; 638 647 state 639 - .repos.repo 648 + .repos 649 + .repo 640 650 .insert_sync_event(did, &cid_link, rev) 641 651 .await 642 652 .map_err(|e| CommitError::DatabaseError(format!("sync event: {}", e))) ··· 652 662 let commit_cid_link = crate::types::CidLink::from(commit_cid); 653 663 let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid); 654 664 state 655 - .repos.repo 665 + .repos 666 + .repo 656 667 .insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev) 657 668 .await 658 669 .map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
+1 -2
crates/tranquil-pds/src/sync/import.rs
··· 222 222 if let Some(record_cid) = entry.value 223 223 && let Ok(full_key) = String::from_utf8(current_key.clone()) 224 224 && let Some(record_block) = blocks.get(&record_cid) 225 - && let Ok(record_value) = 226 - serde_ipld_dagcbor::from_slice::<Ipld>(record_block) 225 + && let Ok(record_value) = serde_ipld_dagcbor::from_slice::<Ipld>(record_block) 227 226 { 228 227 let blob_refs = find_blob_refs_ipld(&record_value, 0); 229 228 let parts: Vec<&str> = full_key.split('/').collect();
+8 -1
crates/tranquil-pds/src/sync/util.rs
··· 221 221 payload: &P, 222 222 capacity: usize, 223 223 ) -> Result<Vec<u8>, SyncFrameError> { 224 - serialize_cbor_pair(&FrameHeader { op: 1, t: frame_type }, payload, capacity) 224 + serialize_cbor_pair( 225 + &FrameHeader { 226 + op: 1, 227 + t: frame_type, 228 + }, 229 + payload, 230 + capacity, 231 + ) 225 232 } 226 233 227 234 fn format_identity_event(event: &SequencedEvent) -> Result<Vec<u8>, SyncFrameError> {
+44
crates/tranquil-store/Cargo.toml
··· 1 + [package] 2 + name = "tranquil-store" 3 + description = "Embedded storage engine for tranquil-pds (experimental)" 4 + version.workspace = true 5 + edition.workspace = true 6 + license.workspace = true 7 + 8 + [dependencies] 9 + async-trait = { workspace = true } 10 + xxhash-rust = { version = "0.8", features = ["xxh3"] } 11 + serde = { workspace = true } 12 + postcard = { version = "1", features = ["alloc"] } 13 + parking_lot = { workspace = true } 14 + fjall = "3" 15 + flume = "0.11" 16 + tokio = { workspace = true, features = ["sync", "rt"] } 17 + bytes = "1" 18 + memmap2 = "0.9" 19 + tracing = { workspace = true } 20 + jacquard-repo = { workspace = true } 21 + cid = { workspace = true } 22 + multihash = { workspace = true } 23 + sha2 = { workspace = true } 24 + 25 + [features] 26 + test-harness = [] 27 + 28 + [dev-dependencies] 29 + proptest = "1" 30 + tempfile = "3" 31 + futures = { workspace = true } 32 + tokio = { workspace = true, features = ["sync", "rt-multi-thread", "macros", "time"] } 33 + jacquard-common = { workspace = true } 34 + tranquil-repo = { workspace = true } 35 + tranquil-db = { workspace = true } 36 + sqlx = { workspace = true } 37 + k256 = { workspace = true } 38 + rand = { workspace = true } 39 + serde_ipld_dagcbor = { workspace = true } 40 + tikv-jemallocator = "0.6" 41 + 42 + [[bench]] 43 + name = "blockstore" 44 + harness = false
+577
crates/tranquil-store/benches/blockstore.rs
··· 1 + use std::path::Path; 2 + use std::sync::Arc; 3 + use std::sync::atomic::{AtomicU64, Ordering}; 4 + use std::time::{Duration, Instant}; 5 + 6 + use bytes::Bytes; 7 + use cid::Cid; 8 + use futures::StreamExt; 9 + use jacquard_repo::storage::BlockStore; 10 + use multihash::Multihash; 11 + use sha2::{Digest, Sha256}; 12 + 13 + use tranquil_store::blockstore::{ 14 + BlockStoreConfig, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore, 15 + }; 16 + 17 + const DAG_CBOR_CODEC: u64 = 0x71; 18 + const SHA2_256_CODE: u64 = 0x12; 19 + 20 + fn make_block(index: usize) -> Vec<u8> { 21 + let size = if index.is_multiple_of(5) { 22 + 1024 + (index.wrapping_mul(997)) % (63 * 1024) 23 + } else { 24 + 64 + (index.wrapping_mul(131)) % 960 25 + }; 26 + (0..size) 27 + .map(|i| (index.wrapping_mul(257).wrapping_add(i.wrapping_mul(131)) & 0xFF) as u8) 28 + .collect() 29 + } 30 + 31 + fn make_cid(data: &[u8]) -> Cid { 32 + let hash = Sha256::digest(data); 33 + let mh = Multihash::wrap(SHA2_256_CODE, &hash).unwrap(); 34 + Cid::new_v1(DAG_CBOR_CODEC, mh) 35 + } 36 + 37 + struct LatencyStats { 38 + p50: Duration, 39 + p95: Duration, 40 + p99: Duration, 41 + max: Duration, 42 + mean: Duration, 43 + } 44 + 45 + fn compute_stats(durations: &mut [Duration]) -> Option<LatencyStats> { 46 + if durations.is_empty() { 47 + return None; 48 + } 49 + durations.sort(); 50 + let len = durations.len(); 51 + let sum: Duration = durations.iter().sum(); 52 + let divisor = u32::try_from(len).unwrap_or(u32::MAX); 53 + let last = len - 1; 54 + Some(LatencyStats { 55 + p50: durations[last * 50 / 100], 56 + p95: durations[last * 95 / 100], 57 + p99: durations[last * 99 / 100], 58 + max: durations[last], 59 + mean: sum / divisor, 60 + }) 61 + } 62 + 63 + fn open_store(dir: &Path) -> TranquilBlockStore { 64 + TranquilBlockStore::open(BlockStoreConfig { 65 + data_dir: dir.join("data"), 66 + index_dir: dir.join("index"), 67 + max_file_size: DEFAULT_MAX_FILE_SIZE, 68 + group_commit: GroupCommitConfig::default(), 69 + }) 70 + .unwrap() 71 + } 72 + 73 + fn format_latency(stats: Option<&LatencyStats>) -> String { 74 + match stats { 75 + Some(s) => format!( 76 + " | p50={:?} p95={:?} p99={:?} max={:?} mean={:?}", 77 + s.p50, s.p95, s.p99, s.max, s.mean 78 + ), 79 + None => String::new(), 80 + } 81 + } 82 + 83 + async fn bench_write_throughput(block_count: usize, concurrency: usize) { 84 + let dir = tempfile::TempDir::new().unwrap(); 85 + let store = open_store(dir.path()); 86 + 87 + let blocks_per_task = block_count / concurrency; 88 + let actual_count = blocks_per_task * concurrency; 89 + let blocks: Vec<Vec<u8>> = (0..actual_count).map(make_block).collect(); 90 + let total_bytes: usize = blocks.iter().map(Vec::len).sum(); 91 + let first_error: Arc<std::sync::Once> = Arc::new(std::sync::Once::new()); 92 + 93 + let start = Instant::now(); 94 + 95 + let handles: Vec<_> = (0..concurrency) 96 + .map(|task_id| { 97 + let store = store.clone(); 98 + let first_error = Arc::clone(&first_error); 99 + let task_blocks: Vec<Vec<u8>> = 100 + blocks[task_id * blocks_per_task..(task_id + 1) * blocks_per_task].to_vec(); 101 + tokio::spawn(async move { 102 + let mut latencies = Vec::with_capacity(task_blocks.len()); 103 + let mut errors = 0u64; 104 + futures::stream::iter(task_blocks) 105 + .then(|block| { 106 + let store = store.clone(); 107 + let first_error = Arc::clone(&first_error); 108 + async move { 109 + let t = Instant::now(); 110 + match store.put(&block).await { 111 + Ok(_) => Ok(t.elapsed()), 112 + Err(e) => { 113 + first_error.call_once(|| { 114 + eprintln!("first put error: {e:?}"); 115 + }); 116 + Err(()) 117 + } 118 + } 119 + } 120 + }) 121 + .for_each(|result| { 122 + match result { 123 + Ok(d) => latencies.push(d), 124 + Err(()) => errors += 1, 125 + } 126 + async {} 127 + }) 128 + .await; 129 + (latencies, errors) 130 + }) 131 + }) 132 + .collect(); 133 + 134 + let results: Vec<_> = futures::future::join_all(handles) 135 + .await 136 + .into_iter() 137 + .map(Result::unwrap) 138 + .collect(); 139 + let elapsed = start.elapsed(); 140 + 141 + let total_errors: u64 = results.iter().map(|(_, e)| e).sum(); 142 + let mut all_latencies: Vec<Duration> = results.into_iter().flat_map(|(l, _)| l).collect(); 143 + let successful = all_latencies.len(); 144 + let stats = compute_stats(&mut all_latencies); 145 + 146 + let lat = format_latency(stats.as_ref()); 147 + if total_errors > 0 { 148 + println!( 149 + "{successful} ok, {total_errors} errors, {:.0} blocks/sec, {:.1} MB/sec, {:.1}ms{lat}", 150 + successful as f64 / elapsed.as_secs_f64(), 151 + total_bytes as f64 / elapsed.as_secs_f64() / (1024.0 * 1024.0), 152 + elapsed.as_secs_f64() * 1000.0, 153 + ); 154 + } else { 155 + println!( 156 + "{:.0} blocks/sec, {:.1} MB/sec, {:.1}ms{lat}", 157 + actual_count as f64 / elapsed.as_secs_f64(), 158 + total_bytes as f64 / elapsed.as_secs_f64() / (1024.0 * 1024.0), 159 + elapsed.as_secs_f64() * 1000.0, 160 + ); 161 + } 162 + } 163 + 164 + async fn bench_read_throughput(block_count: usize, concurrency: usize) { 165 + let dir = tempfile::TempDir::new().unwrap(); 166 + let store = open_store(dir.path()); 167 + 168 + let cids_per_task = block_count / concurrency; 169 + let actual_count = cids_per_task * concurrency; 170 + let blocks: Vec<Vec<u8>> = (0..actual_count).map(make_block).collect(); 171 + let cids: Vec<Cid> = { 172 + let pairs: Vec<(Cid, Bytes)> = blocks 173 + .iter() 174 + .map(|b| (make_cid(b), Bytes::from(b.clone()))) 175 + .collect(); 176 + let cids: Vec<Cid> = pairs.iter().map(|(c, _)| *c).collect(); 177 + store.put_many(pairs).await.unwrap(); 178 + cids 179 + }; 180 + 181 + let run_reads = |label: &'static str, store: TranquilBlockStore, cids: Vec<Cid>| async move { 182 + let start = Instant::now(); 183 + 184 + let handles: Vec<_> = (0..concurrency) 185 + .map(|task_id| { 186 + let store = store.clone(); 187 + let task_cids: Vec<Cid> = 188 + cids[task_id * cids_per_task..(task_id + 1) * cids_per_task].to_vec(); 189 + tokio::spawn(async move { 190 + futures::stream::iter(task_cids) 191 + .then(|cid| { 192 + let store = store.clone(); 193 + async move { 194 + let t = Instant::now(); 195 + let result = store.get(&cid).await.unwrap(); 196 + assert!(result.is_some()); 197 + t.elapsed() 198 + } 199 + }) 200 + .collect::<Vec<Duration>>() 201 + .await 202 + }) 203 + }) 204 + .collect(); 205 + 206 + let mut all_latencies: Vec<Duration> = futures::future::join_all(handles) 207 + .await 208 + .into_iter() 209 + .flat_map(Result::unwrap) 210 + .collect(); 211 + let elapsed = start.elapsed(); 212 + let stats = compute_stats(&mut all_latencies); 213 + 214 + let lat = format_latency(stats.as_ref()); 215 + println!( 216 + "{label}: {:.0} blocks/sec, {:.1}ms{lat}", 217 + actual_count as f64 / elapsed.as_secs_f64(), 218 + elapsed.as_secs_f64() * 1000.0, 219 + ); 220 + }; 221 + 222 + run_reads("hot", store.clone(), cids.clone()).await; 223 + 224 + #[cfg(target_os = "linux")] 225 + { 226 + if std::fs::write("/proc/sys/vm/drop_caches", "3").is_ok() { 227 + println!("dropped system page caches"); 228 + std::thread::sleep(Duration::from_millis(100)); 229 + run_reads("cold", store.clone(), cids).await; 230 + } else { 231 + println!("cold: skipped, no root"); 232 + } 233 + } 234 + #[cfg(not(target_os = "linux"))] 235 + { 236 + println!("cold: skipped, not linux"); 237 + } 238 + } 239 + 240 + async fn bench_mixed_workload(block_count: usize, concurrency: usize) { 241 + let dir = tempfile::TempDir::new().unwrap(); 242 + let store = open_store(dir.path()); 243 + 244 + let ops_per_task = block_count / concurrency; 245 + let actual_ops = ops_per_task * concurrency; 246 + let pre_populate = actual_ops / 2; 247 + let blocks: Vec<Vec<u8>> = (0..pre_populate).map(make_block).collect(); 248 + let cids: Arc<Vec<Cid>> = Arc::new({ 249 + let pairs: Vec<(Cid, Bytes)> = blocks 250 + .iter() 251 + .map(|b| (make_cid(b), Bytes::from(b.clone()))) 252 + .collect(); 253 + let cids: Vec<Cid> = pairs.iter().map(|(c, _)| *c).collect(); 254 + store.put_many(pairs).await.unwrap(); 255 + cids 256 + }); 257 + 258 + let read_count = Arc::new(AtomicU64::new(0)); 259 + let write_count = Arc::new(AtomicU64::new(0)); 260 + 261 + let timer_jitters: Arc<parking_lot::Mutex<Vec<Duration>>> = 262 + Arc::new(parking_lot::Mutex::new(Vec::new())); 263 + let timer_jitters_ref = Arc::clone(&timer_jitters); 264 + let timer_handle = tokio::spawn(async move { 265 + let jitters: Vec<Duration> = futures::stream::iter(0..100) 266 + .then(|_| async { 267 + let expected = Duration::from_millis(1); 268 + let t = Instant::now(); 269 + tokio::time::sleep(expected).await; 270 + t.elapsed().saturating_sub(expected) 271 + }) 272 + .collect() 273 + .await; 274 + *timer_jitters_ref.lock() = jitters; 275 + }); 276 + 277 + let start = Instant::now(); 278 + 279 + let handles: Vec<_> = (0..concurrency) 280 + .map(|task_id| { 281 + let store = store.clone(); 282 + let cids = Arc::clone(&cids); 283 + let read_count = Arc::clone(&read_count); 284 + let write_count = Arc::clone(&write_count); 285 + tokio::spawn(async move { 286 + futures::stream::iter(0..ops_per_task) 287 + .then(|op_idx| { 288 + let store = store.clone(); 289 + let cids = Arc::clone(&cids); 290 + let read_count = Arc::clone(&read_count); 291 + let write_count = Arc::clone(&write_count); 292 + async move { 293 + let is_read = op_idx % 5 != 0; 294 + if is_read && !cids.is_empty() { 295 + let global_idx = task_id * ops_per_task + op_idx; 296 + let cid_idx = global_idx % cids.len(); 297 + if store.get(&cids[cid_idx]).await.is_ok() { 298 + read_count.fetch_add(1, Ordering::Relaxed); 299 + } 300 + } else { 301 + let global_idx = task_id * ops_per_task + op_idx; 302 + let block = make_block(pre_populate + global_idx); 303 + if store.put(&block).await.is_ok() { 304 + write_count.fetch_add(1, Ordering::Relaxed); 305 + } 306 + } 307 + } 308 + }) 309 + .collect::<Vec<()>>() 310 + .await; 311 + }) 312 + }) 313 + .collect(); 314 + 315 + futures::future::join_all(handles).await; 316 + timer_handle.await.unwrap(); 317 + 318 + let elapsed = start.elapsed(); 319 + let reads = read_count.load(Ordering::Relaxed); 320 + let writes = write_count.load(Ordering::Relaxed); 321 + let total = reads + writes; 322 + 323 + let jitters = timer_jitters.lock(); 324 + let max_jitter = jitters.iter().max().copied().unwrap_or_default(); 325 + let mean_jitter: Duration = if jitters.is_empty() { 326 + Duration::ZERO 327 + } else { 328 + let jitter_divisor = u32::try_from(jitters.len()).unwrap_or(u32::MAX); 329 + jitters.iter().sum::<Duration>() / jitter_divisor 330 + }; 331 + 332 + println!( 333 + "{:.0} ops/sec, {} reads + {} writes, {:.1}ms total", 334 + total as f64 / elapsed.as_secs_f64(), 335 + reads, 336 + writes, 337 + elapsed.as_secs_f64() * 1000.0, 338 + ); 339 + let starvation_warning = if max_jitter > Duration::from_millis(5) { 340 + " worker starvation detected" 341 + } else { 342 + "" 343 + }; 344 + println!( 345 + "timer jitter: mean={:?} max={:?}{starvation_warning}", 346 + mean_jitter, max_jitter 347 + ); 348 + } 349 + 350 + async fn bench_group_commit_effectiveness(block_count: usize) { 351 + println!("-- group commit effectiveness at {block_count} blocks --"); 352 + 353 + let baseline_cycle_time = { 354 + let dir = tempfile::TempDir::new().unwrap(); 355 + let store = open_store(dir.path()); 356 + 357 + let start = Instant::now(); 358 + futures::stream::iter(0..20) 359 + .then(|i| { 360 + let store = store.clone(); 361 + async move { 362 + let block = make_block(i); 363 + store.put(&block).await.unwrap(); 364 + } 365 + }) 366 + .collect::<Vec<()>>() 367 + .await; 368 + let elapsed = start.elapsed(); 369 + drop(store); 370 + elapsed.checked_div(20).unwrap_or(Duration::from_micros(1)) 371 + }; 372 + 373 + futures::stream::iter([1usize, 10, 50, 100]) 374 + .then(|concurrency| { 375 + let baseline = baseline_cycle_time; 376 + async move { 377 + if block_count < concurrency { 378 + return; 379 + } 380 + let dir = tempfile::TempDir::new().unwrap(); 381 + let store = open_store(dir.path()); 382 + let blocks_per_task = block_count / concurrency; 383 + let actual_count = blocks_per_task * concurrency; 384 + 385 + let start = Instant::now(); 386 + 387 + let handles: Vec<_> = (0..concurrency) 388 + .map(|task_id| { 389 + let store = store.clone(); 390 + tokio::spawn(async move { 391 + futures::stream::iter(0..blocks_per_task) 392 + .then(|i| { 393 + let store = store.clone(); 394 + async move { 395 + let block = make_block(task_id * blocks_per_task + i); 396 + store.put(&block).await.unwrap(); 397 + } 398 + }) 399 + .collect::<Vec<()>>() 400 + .await; 401 + }) 402 + }) 403 + .collect(); 404 + 405 + futures::future::join_all(handles).await; 406 + let elapsed = start.elapsed(); 407 + 408 + let blocks_per_sec = actual_count as f64 / elapsed.as_secs_f64(); 409 + let est_fsyncs = elapsed.as_secs_f64() / baseline.as_secs_f64(); 410 + let blocks_per_cycle = actual_count as f64 / est_fsyncs; 411 + 412 + println!( 413 + "concurrency={concurrency}: {blocks_per_sec:.0} blocks/sec, {:.1}ms, ~{est_fsyncs:.0} commit cycles, {blocks_per_cycle:.1} blocks/cycle", 414 + elapsed.as_secs_f64() * 1000.0, 415 + ); 416 + 417 + drop(store); 418 + drop(dir); 419 + } 420 + }) 421 + .collect::<Vec<()>>() 422 + .await; 423 + } 424 + 425 + async fn bench_postgres_write_throughput(block_count: usize, concurrency: usize) { 426 + let database_url = match std::env::var("DATABASE_URL") { 427 + Ok(url) => url, 428 + Err(_) => { 429 + println!("skipped, set DATABASE_URL to enable"); 430 + return; 431 + } 432 + }; 433 + 434 + let max_conns = u32::try_from(concurrency).expect("concurrency exceeds u32") + 5; 435 + let pool = sqlx::postgres::PgPoolOptions::new() 436 + .max_connections(max_conns) 437 + .connect(&database_url) 438 + .await 439 + .unwrap(); 440 + 441 + sqlx::query("CREATE TABLE IF NOT EXISTS blocks (cid bytea PRIMARY KEY, data bytea NOT NULL)") 442 + .execute(&pool) 443 + .await 444 + .unwrap(); 445 + sqlx::query("TRUNCATE blocks").execute(&pool).await.unwrap(); 446 + 447 + let pg_store = tranquil_repo::PostgresBlockStore::new(pool.clone()); 448 + let blocks_per_task = block_count / concurrency; 449 + let actual_count = blocks_per_task * concurrency; 450 + let blocks: Vec<Vec<u8>> = (0..actual_count).map(make_block).collect(); 451 + let total_bytes: usize = blocks.iter().map(Vec::len).sum(); 452 + 453 + let start = Instant::now(); 454 + 455 + let handles: Vec<_> = (0..concurrency) 456 + .map(|task_id| { 457 + let store = pg_store.clone(); 458 + let task_blocks: Vec<Vec<u8>> = 459 + blocks[task_id * blocks_per_task..(task_id + 1) * blocks_per_task].to_vec(); 460 + tokio::spawn(async move { 461 + futures::stream::iter(task_blocks) 462 + .then(|block| { 463 + let store = store.clone(); 464 + async move { 465 + let t = Instant::now(); 466 + store.put(&block).await.unwrap(); 467 + t.elapsed() 468 + } 469 + }) 470 + .collect::<Vec<Duration>>() 471 + .await 472 + }) 473 + }) 474 + .collect(); 475 + 476 + let mut all_latencies: Vec<Duration> = futures::future::join_all(handles) 477 + .await 478 + .into_iter() 479 + .flat_map(Result::unwrap) 480 + .collect(); 481 + let elapsed = start.elapsed(); 482 + let stats = compute_stats(&mut all_latencies); 483 + 484 + let lat = format_latency(stats.as_ref()); 485 + println!( 486 + "{:.0} blocks/sec, {:.1} MB/sec, {:.1}ms{lat}", 487 + actual_count as f64 / elapsed.as_secs_f64(), 488 + total_bytes as f64 / elapsed.as_secs_f64() / (1024.0 * 1024.0), 489 + elapsed.as_secs_f64() * 1000.0, 490 + ); 491 + 492 + sqlx::query("TRUNCATE blocks").execute(&pool).await.unwrap(); 493 + pool.close().await; 494 + } 495 + 496 + fn main() { 497 + let worker_threads = std::env::var("BENCH_WORKER_THREADS") 498 + .ok() 499 + .and_then(|s| s.trim().parse::<usize>().ok()) 500 + .unwrap_or_else(|| { 501 + std::thread::available_parallelism() 502 + .map(|n| n.get()) 503 + .unwrap_or(8) 504 + }); 505 + let rt = tokio::runtime::Builder::new_multi_thread() 506 + .worker_threads(worker_threads) 507 + .enable_all() 508 + .build() 509 + .unwrap(); 510 + println!("tokio worker threads: {worker_threads}"); 511 + 512 + let parse_env_list = |var: &str, defaults: Vec<usize>| -> Vec<usize> { 513 + std::env::var(var).map_or(defaults, |s| { 514 + s.split(',') 515 + .map(|n| { 516 + let trimmed = n.trim(); 517 + trimmed 518 + .replace('_', "") 519 + .parse::<usize>() 520 + .unwrap_or_else(|_| panic!("{var}: failed to parse {trimmed:?} as integer")) 521 + }) 522 + .collect() 523 + }) 524 + }; 525 + let block_counts = parse_env_list("BENCH_BLOCK_COUNTS", vec![1_000, 10_000]); 526 + let concurrency_levels = parse_env_list("BENCH_CONCURRENCY", vec![1, 10, 50]); 527 + 528 + println!( 529 + "block counts: {:?}, concurrency: {:?}", 530 + block_counts, concurrency_levels 531 + ); 532 + 533 + block_counts.iter().for_each(|&block_count| { 534 + concurrency_levels.iter().for_each(|&concurrency| { 535 + if block_count < concurrency { 536 + return; 537 + } 538 + 539 + println!( 540 + "-- write throughput: {} blocks, {} writers --", 541 + block_count, concurrency 542 + ); 543 + rt.block_on(bench_write_throughput(block_count, concurrency)); 544 + 545 + println!( 546 + "-- read throughput: {} blocks, {} readers --", 547 + block_count, concurrency 548 + ); 549 + rt.block_on(bench_read_throughput(block_count, concurrency)); 550 + 551 + println!( 552 + "-- mixed workload 80/20 r/w: {} ops, {} workers --", 553 + block_count, concurrency 554 + ); 555 + rt.block_on(bench_mixed_workload(block_count, concurrency)); 556 + }); 557 + }); 558 + 559 + rt.block_on(bench_group_commit_effectiveness(1000)); 560 + 561 + if std::env::var("DATABASE_URL").is_ok() { 562 + block_counts.iter().for_each(|&block_count| { 563 + concurrency_levels.iter().for_each(|&concurrency| { 564 + if block_count < concurrency { 565 + return; 566 + } 567 + println!( 568 + "-- postgres write: {} blocks, {} writers --", 569 + block_count, concurrency 570 + ); 571 + rt.block_on(bench_postgres_write_throughput(block_count, concurrency)); 572 + }); 573 + }); 574 + } else { 575 + println!("set DATABASE_URL for postgres comparison"); 576 + } 577 + }
+723
crates/tranquil-store/src/blockstore/data_file.rs
··· 1 + use std::io; 2 + 3 + use crate::io::{FileId, StorageIO}; 4 + 5 + use super::types::{BlockLength, BlockLocation, BlockOffset, DataFileId, MAX_BLOCK_SIZE}; 6 + 7 + pub const BLOCK_MAGIC: [u8; 4] = *b"TQBL"; 8 + pub const BLOCK_FORMAT_VERSION: u8 = 1; 9 + pub const BLOCK_HEADER_SIZE: usize = 5; 10 + 11 + pub const CID_SIZE: usize = 36; 12 + pub const BLOCK_RECORD_OVERHEAD: usize = CID_SIZE + 4 + 4; 13 + 14 + pub type ValidBlock = (BlockOffset, [u8; CID_SIZE], Vec<u8>); 15 + 16 + fn block_record_checksum(cid_bytes: &[u8; CID_SIZE], length_bytes: &[u8; 4], data: &[u8]) -> u32 { 17 + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); 18 + hasher.update(cid_bytes); 19 + hasher.update(length_bytes); 20 + hasher.update(data); 21 + hasher.digest() as u32 22 + } 23 + 24 + pub fn encode_block_record<S: StorageIO>( 25 + io: &S, 26 + fd: FileId, 27 + offset: BlockOffset, 28 + cid_bytes: &[u8; CID_SIZE], 29 + data: &[u8], 30 + ) -> io::Result<u64> { 31 + let length = u32::try_from(data.len()) 32 + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "block data exceeds u32::MAX"))?; 33 + if length > MAX_BLOCK_SIZE { 34 + return Err(io::Error::new( 35 + io::ErrorKind::InvalidInput, 36 + "block data exceeds MAX_BLOCK_SIZE", 37 + )); 38 + } 39 + 40 + let length_bytes = length.to_le_bytes(); 41 + let checksum = block_record_checksum(cid_bytes, &length_bytes, data); 42 + 43 + let mut cursor = offset.raw(); 44 + 45 + io.write_all_at(fd, cursor, cid_bytes)?; 46 + cursor += CID_SIZE as u64; 47 + 48 + io.write_all_at(fd, cursor, &length_bytes)?; 49 + cursor += 4; 50 + 51 + io.write_all_at(fd, cursor, data)?; 52 + cursor += data.len() as u64; 53 + 54 + io.write_all_at(fd, cursor, &checksum.to_le_bytes())?; 55 + cursor += 4; 56 + 57 + Ok(cursor - offset.raw()) 58 + } 59 + 60 + pub fn decode_block_record<S: StorageIO>( 61 + io: &S, 62 + fd: FileId, 63 + offset: BlockOffset, 64 + file_size: u64, 65 + ) -> io::Result<Option<ReadBlockRecord>> { 66 + let raw = offset.raw(); 67 + let remaining = match file_size.checked_sub(raw) { 68 + Some(r) => r, 69 + None => return Ok(None), 70 + }; 71 + if remaining == 0 { 72 + return Ok(None); 73 + } 74 + 75 + if remaining < (CID_SIZE + 4) as u64 { 76 + return Ok(Some(ReadBlockRecord::Truncated { offset })); 77 + } 78 + 79 + let mut cid_bytes = [0u8; CID_SIZE]; 80 + io.read_exact_at(fd, raw, &mut cid_bytes)?; 81 + 82 + let mut length_bytes = [0u8; 4]; 83 + io.read_exact_at(fd, raw + CID_SIZE as u64, &mut length_bytes)?; 84 + 85 + let length = u32::from_le_bytes(length_bytes); 86 + if length > MAX_BLOCK_SIZE { 87 + return Ok(Some(ReadBlockRecord::Corrupted { offset })); 88 + } 89 + 90 + let record_size = BLOCK_RECORD_OVERHEAD as u64 + u64::from(length); 91 + if record_size > remaining { 92 + return Ok(Some(ReadBlockRecord::Truncated { offset })); 93 + } 94 + 95 + let data_offset = raw + CID_SIZE as u64 + 4; 96 + let mut data = vec![0u8; length as usize]; 97 + io.read_exact_at(fd, data_offset, &mut data)?; 98 + 99 + let mut checksum_bytes = [0u8; 4]; 100 + io.read_exact_at(fd, data_offset + u64::from(length), &mut checksum_bytes)?; 101 + 102 + let stored_checksum = u32::from_le_bytes(checksum_bytes); 103 + let computed_checksum = block_record_checksum(&cid_bytes, &length_bytes, &data); 104 + 105 + if stored_checksum != computed_checksum { 106 + return Ok(Some(ReadBlockRecord::Corrupted { offset })); 107 + } 108 + 109 + Ok(Some(ReadBlockRecord::Valid { 110 + offset, 111 + cid_bytes, 112 + data, 113 + })) 114 + } 115 + 116 + #[must_use] 117 + #[derive(Debug)] 118 + pub enum ReadBlockRecord { 119 + Valid { 120 + offset: BlockOffset, 121 + cid_bytes: [u8; CID_SIZE], 122 + data: Vec<u8>, 123 + }, 124 + Corrupted { 125 + offset: BlockOffset, 126 + }, 127 + Truncated { 128 + offset: BlockOffset, 129 + }, 130 + } 131 + 132 + pub struct DataFileWriter<'a, S: StorageIO> { 133 + io: &'a S, 134 + fd: FileId, 135 + file_id: DataFileId, 136 + position: BlockOffset, 137 + } 138 + 139 + impl<'a, S: StorageIO> DataFileWriter<'a, S> { 140 + pub fn new(io: &'a S, fd: FileId, file_id: DataFileId) -> io::Result<Self> { 141 + let mut header = [0u8; BLOCK_HEADER_SIZE]; 142 + header[..4].copy_from_slice(&BLOCK_MAGIC); 143 + header[4] = BLOCK_FORMAT_VERSION; 144 + io.write_all_at(fd, 0, &header)?; 145 + Ok(Self { 146 + io, 147 + fd, 148 + file_id, 149 + position: BlockOffset::new(BLOCK_HEADER_SIZE as u64), 150 + }) 151 + } 152 + 153 + pub fn resume(io: &'a S, fd: FileId, file_id: DataFileId, position: BlockOffset) -> Self { 154 + assert!( 155 + position.raw() >= BLOCK_HEADER_SIZE as u64, 156 + "resume position {position:?} is before header end" 157 + ); 158 + Self { 159 + io, 160 + fd, 161 + file_id, 162 + position, 163 + } 164 + } 165 + 166 + pub fn append_block( 167 + &mut self, 168 + cid_bytes: &[u8; CID_SIZE], 169 + data: &[u8], 170 + ) -> io::Result<BlockLocation> { 171 + let record_offset = self.position; 172 + let bytes_written = encode_block_record(self.io, self.fd, record_offset, cid_bytes, data)?; 173 + self.position = self.position.advance(bytes_written); 174 + 175 + Ok(BlockLocation { 176 + file_id: self.file_id, 177 + offset: record_offset, 178 + length: BlockLength::new( 179 + u32::try_from(data.len()).expect("encode_block_record validated length"), 180 + ), 181 + }) 182 + } 183 + 184 + pub fn sync(&self) -> io::Result<()> { 185 + self.io.sync(self.fd) 186 + } 187 + 188 + pub fn position(&self) -> BlockOffset { 189 + self.position 190 + } 191 + 192 + pub fn fd(&self) -> FileId { 193 + self.fd 194 + } 195 + 196 + pub fn file_id(&self) -> DataFileId { 197 + self.file_id 198 + } 199 + } 200 + 201 + pub struct DataFileReader<'a, S: StorageIO> { 202 + io: &'a S, 203 + fd: FileId, 204 + position: BlockOffset, 205 + file_size: u64, 206 + } 207 + 208 + impl<'a, S: StorageIO> DataFileReader<'a, S> { 209 + pub fn open(io: &'a S, fd: FileId) -> io::Result<Self> { 210 + let file_size = io.file_size(fd)?; 211 + if file_size < BLOCK_HEADER_SIZE as u64 { 212 + return Err(io::Error::new( 213 + io::ErrorKind::InvalidData, 214 + "file too small for header", 215 + )); 216 + } 217 + 218 + let mut header = [0u8; BLOCK_HEADER_SIZE]; 219 + io.read_exact_at(fd, 0, &mut header)?; 220 + 221 + if header[..4] != BLOCK_MAGIC { 222 + return Err(io::Error::new(io::ErrorKind::InvalidData, "bad magic")); 223 + } 224 + if header[4] != BLOCK_FORMAT_VERSION { 225 + return Err(io::Error::new( 226 + io::ErrorKind::InvalidData, 227 + "unsupported block format version", 228 + )); 229 + } 230 + 231 + Ok(Self { 232 + io, 233 + fd, 234 + position: BlockOffset::new(BLOCK_HEADER_SIZE as u64), 235 + file_size, 236 + }) 237 + } 238 + 239 + pub fn valid_blocks(self) -> io::Result<Vec<ValidBlock>> { 240 + self.map_while(|r| match r { 241 + Ok(ReadBlockRecord::Valid { 242 + offset, 243 + cid_bytes, 244 + data, 245 + }) => Some(Ok((offset, cid_bytes, data))), 246 + Err(e) => Some(Err(e)), 247 + _ => None, 248 + }) 249 + .collect() 250 + } 251 + } 252 + 253 + impl<S: StorageIO> Iterator for DataFileReader<'_, S> { 254 + type Item = io::Result<ReadBlockRecord>; 255 + 256 + fn next(&mut self) -> Option<Self::Item> { 257 + match decode_block_record(self.io, self.fd, self.position, self.file_size) { 258 + Err(e) => { 259 + self.position = BlockOffset::new(self.file_size); 260 + Some(Err(e)) 261 + } 262 + Ok(None) => None, 263 + Ok(Some(record)) => { 264 + match &record { 265 + ReadBlockRecord::Valid { data, .. } => { 266 + self.position = self 267 + .position 268 + .advance(BLOCK_RECORD_OVERHEAD as u64 + data.len() as u64); 269 + } 270 + ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. } => { 271 + self.position = BlockOffset::new(self.file_size); 272 + } 273 + } 274 + Some(Ok(record)) 275 + } 276 + } 277 + } 278 + } 279 + 280 + #[cfg(test)] 281 + mod tests { 282 + use super::*; 283 + use crate::OpenOptions; 284 + use crate::blockstore::test_cid; 285 + use crate::sim::{FaultConfig, SimulatedIO}; 286 + use proptest::prelude::*; 287 + use std::path::Path; 288 + 289 + fn setup() -> (SimulatedIO, FileId) { 290 + let sim = SimulatedIO::pristine(42); 291 + let dir = Path::new("/test"); 292 + sim.mkdir(dir).unwrap(); 293 + sim.sync_dir(dir).unwrap(); 294 + let fd = sim 295 + .open(Path::new("/test/data.tqb"), OpenOptions::read_write()) 296 + .unwrap(); 297 + (sim, fd) 298 + } 299 + 300 + #[test] 301 + fn write_and_read_single_block() { 302 + let (sim, fd) = setup(); 303 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 304 + let cid = test_cid(1); 305 + let data = b"hello blockstore"; 306 + 307 + let location = writer.append_block(&cid, data).unwrap(); 308 + writer.sync().unwrap(); 309 + 310 + assert_eq!(location.file_id, DataFileId::new(0)); 311 + assert_eq!(location.offset, BlockOffset::new(BLOCK_HEADER_SIZE as u64)); 312 + assert_eq!(location.length, BlockLength::new(data.len() as u32)); 313 + 314 + let reader = DataFileReader::open(&sim, fd).unwrap(); 315 + let blocks = reader.valid_blocks().unwrap(); 316 + assert_eq!(blocks.len(), 1); 317 + assert_eq!(blocks[0].1, cid); 318 + assert_eq!(blocks[0].2, data); 319 + } 320 + 321 + #[test] 322 + fn write_and_read_multiple_blocks() { 323 + let (sim, fd) = setup(); 324 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 325 + 326 + let payloads: Vec<(&[u8], u8)> = vec![(b"first", 1), (b"second", 2), (b"third", 3)]; 327 + let cids: Vec<[u8; CID_SIZE]> = payloads.iter().map(|(_, s)| test_cid(*s)).collect(); 328 + 329 + payloads 330 + .iter() 331 + .zip(cids.iter()) 332 + .for_each(|((data, _), cid)| { 333 + let _ = writer.append_block(cid, data).unwrap(); 334 + }); 335 + writer.sync().unwrap(); 336 + 337 + let reader = DataFileReader::open(&sim, fd).unwrap(); 338 + let blocks = reader.valid_blocks().unwrap(); 339 + assert_eq!(blocks.len(), 3); 340 + assert_eq!(blocks[0].2, b"first"); 341 + assert_eq!(blocks[1].2, b"second"); 342 + assert_eq!(blocks[2].2, b"third"); 343 + assert_eq!(blocks[0].1, cids[0]); 344 + assert_eq!(blocks[1].1, cids[1]); 345 + assert_eq!(blocks[2].1, cids[2]); 346 + } 347 + 348 + #[test] 349 + fn empty_file_has_no_blocks() { 350 + let (sim, fd) = setup(); 351 + DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 352 + 353 + let reader = DataFileReader::open(&sim, fd).unwrap(); 354 + let blocks = reader.valid_blocks().unwrap(); 355 + assert!(blocks.is_empty()); 356 + } 357 + 358 + #[test] 359 + fn detects_truncated_block() { 360 + let (sim, fd) = setup(); 361 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 362 + let cid = test_cid(1); 363 + let _ = writer.append_block(&cid, b"complete block").unwrap(); 364 + writer.sync().unwrap(); 365 + 366 + let partial_cid = test_cid(2); 367 + sim.write_all_at(fd, writer.position().raw(), &partial_cid[..10]) 368 + .unwrap(); 369 + 370 + let mut reader = DataFileReader::open(&sim, fd).unwrap(); 371 + let first = reader.next().unwrap().unwrap(); 372 + assert!(matches!(first, ReadBlockRecord::Valid { .. })); 373 + 374 + let second = reader.next().unwrap().unwrap(); 375 + assert!(matches!(second, ReadBlockRecord::Truncated { .. })); 376 + } 377 + 378 + #[test] 379 + fn checksum_detects_corruption() { 380 + let (sim, fd) = setup(); 381 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 382 + let cid = test_cid(1); 383 + let data = vec![0xAA; 256]; 384 + let _ = writer.append_block(&cid, &data).unwrap(); 385 + writer.sync().unwrap(); 386 + 387 + let corrupt_offset = BLOCK_HEADER_SIZE as u64 + CID_SIZE as u64 + 4 + 128; 388 + sim.write_all_at(fd, corrupt_offset, &[0x00]).unwrap(); 389 + 390 + let mut reader = DataFileReader::open(&sim, fd).unwrap(); 391 + let record = reader.next().unwrap().unwrap(); 392 + assert!(matches!(record, ReadBlockRecord::Corrupted { .. })); 393 + } 394 + 395 + #[test] 396 + fn crash_before_sync_loses_blocks() { 397 + let (sim, fd) = setup(); 398 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 399 + let cid1 = test_cid(1); 400 + let _ = writer.append_block(&cid1, b"synced").unwrap(); 401 + writer.sync().unwrap(); 402 + sim.sync_dir(Path::new("/test")).unwrap(); 403 + 404 + let cid2 = test_cid(2); 405 + let _ = writer.append_block(&cid2, b"not synced").unwrap(); 406 + 407 + sim.crash(); 408 + 409 + let fd = sim 410 + .open(Path::new("/test/data.tqb"), OpenOptions::read()) 411 + .unwrap(); 412 + let reader = DataFileReader::open(&sim, fd).unwrap(); 413 + let blocks = reader.valid_blocks().unwrap(); 414 + assert_eq!(blocks.len(), 1); 415 + assert_eq!(blocks[0].2, b"synced"); 416 + } 417 + 418 + #[test] 419 + fn rejects_oversized_block() { 420 + let (sim, fd) = setup(); 421 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 422 + let cid = test_cid(1); 423 + let oversized = vec![0u8; MAX_BLOCK_SIZE as usize + 1]; 424 + let result = writer.append_block(&cid, &oversized); 425 + assert!(result.is_err()); 426 + } 427 + 428 + #[test] 429 + fn zero_length_block_round_trips() { 430 + let (sim, fd) = setup(); 431 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 432 + let cid = test_cid(1); 433 + 434 + let location = writer.append_block(&cid, &[]).unwrap(); 435 + writer.sync().unwrap(); 436 + 437 + assert_eq!(location.length, BlockLength::new(0)); 438 + 439 + let reader = DataFileReader::open(&sim, fd).unwrap(); 440 + let blocks = reader.valid_blocks().unwrap(); 441 + assert_eq!(blocks.len(), 1); 442 + assert_eq!(blocks[0].1, cid); 443 + assert!(blocks[0].2.is_empty()); 444 + } 445 + 446 + #[test] 447 + fn accepts_exact_max_block_size() { 448 + let (sim, fd) = setup(); 449 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 450 + let cid = test_cid(1); 451 + let max_data = vec![0xBB; MAX_BLOCK_SIZE as usize]; 452 + 453 + let location = writer.append_block(&cid, &max_data).unwrap(); 454 + assert_eq!(location.length, BlockLength::new(MAX_BLOCK_SIZE)); 455 + } 456 + 457 + #[test] 458 + fn bad_magic_rejected() { 459 + let sim = SimulatedIO::pristine(42); 460 + let dir = Path::new("/test"); 461 + sim.mkdir(dir).unwrap(); 462 + sim.sync_dir(dir).unwrap(); 463 + let fd = sim 464 + .open(Path::new("/test/bad.tqb"), OpenOptions::read_write()) 465 + .unwrap(); 466 + sim.write_all_at(fd, 0, b"NOPE\x01").unwrap(); 467 + 468 + let result = DataFileReader::open(&sim, fd); 469 + assert!(result.is_err()); 470 + } 471 + 472 + #[test] 473 + fn encode_decode_round_trip_at_offset() { 474 + let (sim, fd) = setup(); 475 + let cid = test_cid(42); 476 + let data = b"round trip test data"; 477 + 478 + sim.write_all_at(fd, 0, &[0u8; 100]).unwrap(); 479 + 480 + let offset = BlockOffset::new(100); 481 + let bytes_written = encode_block_record(&sim, fd, offset, &cid, data).unwrap(); 482 + let expected_size = BLOCK_RECORD_OVERHEAD as u64 + data.len() as u64; 483 + assert_eq!(bytes_written, expected_size); 484 + 485 + let file_size = sim.file_size(fd).unwrap(); 486 + let record = decode_block_record(&sim, fd, offset, file_size) 487 + .unwrap() 488 + .unwrap(); 489 + match record { 490 + ReadBlockRecord::Valid { 491 + cid_bytes, 492 + data: decoded_data, 493 + .. 494 + } => { 495 + assert_eq!(cid_bytes, cid); 496 + assert_eq!(decoded_data, data); 497 + } 498 + other => panic!("expected Valid, got {other:?}"), 499 + } 500 + } 501 + 502 + #[test] 503 + fn resume_writer_continues_at_position() { 504 + let (sim, fd) = setup(); 505 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 506 + let cid1 = test_cid(1); 507 + let _ = writer.append_block(&cid1, b"first").unwrap(); 508 + writer.sync().unwrap(); 509 + 510 + let resume_pos = writer.position(); 511 + let mut writer2 = DataFileWriter::resume(&sim, fd, DataFileId::new(0), resume_pos); 512 + let cid2 = test_cid(2); 513 + let _ = writer2.append_block(&cid2, b"second").unwrap(); 514 + writer2.sync().unwrap(); 515 + 516 + let reader = DataFileReader::open(&sim, fd).unwrap(); 517 + let blocks = reader.valid_blocks().unwrap(); 518 + assert_eq!(blocks.len(), 2); 519 + assert_eq!(blocks[0].2, b"first"); 520 + assert_eq!(blocks[1].2, b"second"); 521 + } 522 + 523 + fn run_crash_recovery_seed(seed: u64) { 524 + let sim = SimulatedIO::new(seed, FaultConfig::aggressive()); 525 + let dir = Path::new("/data"); 526 + let _ = sim.mkdir(dir); 527 + let _ = sim.sync_dir(dir); 528 + 529 + let mut written_blocks: Vec<(u8, Vec<u8>)> = Vec::new(); 530 + 531 + if let Ok(fd) = sim.open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) 532 + && let Ok(mut writer) = DataFileWriter::new(&sim, fd, DataFileId::new(0)) 533 + { 534 + (0u8..20).for_each(|i| { 535 + let cid = test_cid(i); 536 + let data = vec![i; (i as usize + 1) * 10]; 537 + if writer.append_block(&cid, &data).is_ok() { 538 + written_blocks.push((i, data)); 539 + } 540 + }); 541 + let _ = writer.sync(); 542 + } 543 + let _ = sim.sync_dir(dir); 544 + 545 + sim.crash(); 546 + 547 + if let Ok(fd) = sim.open(Path::new("/data/000000.tqb"), OpenOptions::read()) 548 + && let Ok(reader) = DataFileReader::open(&sim, fd) 549 + { 550 + let recovered: Vec<_> = reader 551 + .map_while(|r| match r { 552 + Ok(ReadBlockRecord::Valid { 553 + cid_bytes, data, .. 554 + }) => Some((cid_bytes, data)), 555 + _ => None, 556 + }) 557 + .collect(); 558 + 559 + assert!( 560 + recovered.len() <= written_blocks.len(), 561 + "recovered {} blocks but only wrote {}", 562 + recovered.len(), 563 + written_blocks.len() 564 + ); 565 + 566 + recovered 567 + .iter() 568 + .enumerate() 569 + .for_each(|(i, (cid_bytes, data))| { 570 + assert_eq!(cid_bytes[0], 0x01, "phantom block at index {i}"); 571 + assert_eq!( 572 + *cid_bytes, 573 + test_cid(written_blocks[i].0), 574 + "block {i} cid does not match written order" 575 + ); 576 + assert_eq!( 577 + *data, written_blocks[i].1, 578 + "block {i} data does not match what was written" 579 + ); 580 + }); 581 + } 582 + } 583 + 584 + proptest! { 585 + #![proptest_config(ProptestConfig::with_cases(2000))] 586 + 587 + #[test] 588 + fn sim_crash_recovery_aggressive_faults(seed in 0u64..u64::MAX) { 589 + run_crash_recovery_seed(seed); 590 + } 591 + } 592 + 593 + #[test] 594 + fn sim_partial_write_mid_block_reports_truncated() { 595 + let sim = SimulatedIO::pristine(42); 596 + let dir = Path::new("/data"); 597 + sim.mkdir(dir).unwrap(); 598 + sim.sync_dir(dir).unwrap(); 599 + 600 + let fd = sim 601 + .open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) 602 + .unwrap(); 603 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 604 + 605 + (0u8..5).for_each(|i| { 606 + let _ = writer.append_block(&test_cid(i), &[i; 50]).unwrap(); 607 + }); 608 + writer.sync().unwrap(); 609 + 610 + let synced_pos = writer.position(); 611 + 612 + sim.write_all_at(fd, synced_pos.raw(), &test_cid(5)[..20]) 613 + .unwrap(); 614 + sim.sync(fd).unwrap(); 615 + sim.sync_dir(dir).unwrap(); 616 + 617 + sim.crash(); 618 + 619 + let fd = sim 620 + .open(Path::new("/data/000000.tqb"), OpenOptions::read()) 621 + .unwrap(); 622 + let records: Vec<_> = DataFileReader::open(&sim, fd) 623 + .unwrap() 624 + .collect::<Result<Vec<_>, _>>() 625 + .unwrap(); 626 + 627 + assert_eq!(records.len(), 6); 628 + (0usize..5).for_each(|i| { 629 + assert!(matches!(&records[i], ReadBlockRecord::Valid { .. })); 630 + }); 631 + assert!(matches!(&records[5], ReadBlockRecord::Truncated { .. })); 632 + 633 + match &records[5] { 634 + ReadBlockRecord::Truncated { offset } => { 635 + assert_eq!(offset.raw(), synced_pos.raw()); 636 + } 637 + other => panic!("expected Truncated, got {other:?}"), 638 + } 639 + } 640 + 641 + fn run_bit_flip_detection_seed(seed: u64) { 642 + let sim = SimulatedIO::pristine(seed); 643 + let dir = Path::new("/data"); 644 + sim.mkdir(dir).unwrap(); 645 + sim.sync_dir(dir).unwrap(); 646 + 647 + let fd = sim 648 + .open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) 649 + .unwrap(); 650 + let mut writer = DataFileWriter::new(&sim, fd, DataFileId::new(0)).unwrap(); 651 + 652 + let data_len = ((seed % 256) as usize).max(1); 653 + let cid = test_cid((seed % 256) as u8); 654 + let data = vec![0xAA; data_len]; 655 + let _ = writer.append_block(&cid, &data).unwrap(); 656 + writer.sync().unwrap(); 657 + 658 + let data_start = BLOCK_HEADER_SIZE as u64 + CID_SIZE as u64 + 4; 659 + let flip_pos = data_start + (seed.wrapping_mul(7) % data_len as u64); 660 + let flip_bit = (seed.wrapping_mul(13) % 8) as u8; 661 + 662 + let mut byte_buf = [0u8; 1]; 663 + sim.read_exact_at(fd, flip_pos, &mut byte_buf).unwrap(); 664 + byte_buf[0] ^= 1 << flip_bit; 665 + sim.write_all_at(fd, flip_pos, &byte_buf).unwrap(); 666 + 667 + let mut reader = DataFileReader::open(&sim, fd).unwrap(); 668 + let record = reader.next().unwrap().unwrap(); 669 + assert!(matches!(record, ReadBlockRecord::Corrupted { .. })); 670 + } 671 + 672 + proptest! { 673 + #![proptest_config(ProptestConfig::with_cases(2000))] 674 + 675 + #[test] 676 + fn sim_bit_flip_detected_by_checksum(seed in 0u64..u64::MAX) { 677 + run_bit_flip_detection_seed(seed); 678 + } 679 + } 680 + 681 + #[test] 682 + fn sim_rotation_without_dir_sync_loses_new_file() { 683 + let sim = SimulatedIO::pristine(42); 684 + let dir = Path::new("/data"); 685 + sim.mkdir(dir).unwrap(); 686 + sim.sync_dir(dir).unwrap(); 687 + 688 + let fd0 = sim 689 + .open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) 690 + .unwrap(); 691 + let mut writer0 = DataFileWriter::new(&sim, fd0, DataFileId::new(0)).unwrap(); 692 + (0u8..3).for_each(|i| { 693 + let _ = writer0.append_block(&test_cid(i), &[i; 50]).unwrap(); 694 + }); 695 + writer0.sync().unwrap(); 696 + sim.sync_dir(dir).unwrap(); 697 + 698 + let fd1 = sim 699 + .open(Path::new("/data/000001.tqb"), OpenOptions::read_write()) 700 + .unwrap(); 701 + let mut writer1 = DataFileWriter::new(&sim, fd1, DataFileId::new(1)).unwrap(); 702 + let _ = writer1 703 + .append_block(&test_cid(10), b"new file data") 704 + .unwrap(); 705 + writer1.sync().unwrap(); 706 + 707 + sim.crash(); 708 + 709 + assert!( 710 + sim.open(Path::new("/data/000001.tqb"), OpenOptions::read()) 711 + .is_err() 712 + ); 713 + 714 + let fd0 = sim 715 + .open(Path::new("/data/000000.tqb"), OpenOptions::read()) 716 + .unwrap(); 717 + let blocks = DataFileReader::open(&sim, fd0) 718 + .unwrap() 719 + .valid_blocks() 720 + .unwrap(); 721 + assert_eq!(blocks.len(), 3); 722 + } 723 + }
+953
crates/tranquil-store/src/blockstore/group_commit.rs
··· 1 + use std::cell::Cell; 2 + use std::collections::HashMap; 3 + use std::io; 4 + use std::sync::Arc; 5 + use std::thread; 6 + 7 + use crate::io::{FileId, OpenOptions, StorageIO}; 8 + 9 + use super::data_file::{CID_SIZE, DataFileWriter}; 10 + use super::hint::{HintFileWriter, hint_file_path}; 11 + use super::key_index::{KeyIndex, KeyIndexError}; 12 + use super::manager::DataFileManager; 13 + use super::types::{BlockLocation, BlockOffset, DataFileId, HintOffset, WriteCursor}; 14 + 15 + #[derive(Debug, Clone)] 16 + pub enum CommitError { 17 + Io(Arc<io::Error>), 18 + Index(Arc<KeyIndexError>), 19 + ChannelClosed, 20 + } 21 + 22 + impl std::fmt::Display for CommitError { 23 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 + match self { 25 + Self::Io(e) => write!(f, "io: {}", e.as_ref()), 26 + Self::Index(e) => write!(f, "index: {}", e.as_ref()), 27 + Self::ChannelClosed => write!(f, "commit channel closed"), 28 + } 29 + } 30 + } 31 + 32 + impl std::error::Error for CommitError { 33 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 34 + match self { 35 + Self::Io(e) => Some(e.as_ref()), 36 + Self::Index(e) => Some(e.as_ref()), 37 + Self::ChannelClosed => None, 38 + } 39 + } 40 + } 41 + 42 + impl From<io::Error> for CommitError { 43 + fn from(e: io::Error) -> Self { 44 + Self::Io(Arc::new(e)) 45 + } 46 + } 47 + 48 + impl From<KeyIndexError> for CommitError { 49 + fn from(e: KeyIndexError) -> Self { 50 + Self::Index(Arc::new(e)) 51 + } 52 + } 53 + 54 + type PutResponse = tokio::sync::oneshot::Sender<Result<Vec<BlockLocation>, CommitError>>; 55 + type ApplyResponse = tokio::sync::oneshot::Sender<Result<(), CommitError>>; 56 + 57 + pub enum CommitRequest { 58 + PutBlocks { 59 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 60 + response: PutResponse, 61 + }, 62 + ApplyCommit { 63 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 64 + deleted_cids: Vec<[u8; CID_SIZE]>, 65 + response: ApplyResponse, 66 + }, 67 + Shutdown, 68 + } 69 + 70 + #[derive(Debug, Clone)] 71 + pub struct GroupCommitConfig { 72 + pub max_batch_size: usize, 73 + pub channel_capacity: usize, 74 + } 75 + 76 + impl Default for GroupCommitConfig { 77 + fn default() -> Self { 78 + Self { 79 + max_batch_size: 1024, 80 + channel_capacity: 4096, 81 + } 82 + } 83 + } 84 + 85 + struct ActiveState { 86 + file_id: DataFileId, 87 + fd: FileId, 88 + position: BlockOffset, 89 + hint_fd: FileId, 90 + hint_position: HintOffset, 91 + } 92 + 93 + fn log_thread_panic(payload: Box<dyn std::any::Any + Send>, context: &str) { 94 + let msg = payload 95 + .downcast_ref::<&str>() 96 + .copied() 97 + .or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str())) 98 + .unwrap_or("unknown panic"); 99 + tracing::error!(panic = msg, "{context}"); 100 + } 101 + 102 + pub struct GroupCommitWriter { 103 + sender: flume::Sender<CommitRequest>, 104 + handle: Option<thread::JoinHandle<()>>, 105 + } 106 + 107 + impl GroupCommitWriter { 108 + pub fn spawn<S: StorageIO + 'static>( 109 + manager: DataFileManager<S>, 110 + index: Arc<KeyIndex>, 111 + config: GroupCommitConfig, 112 + ) -> Result<Self, CommitError> { 113 + let cursor = index.read_write_cursor().map_err(CommitError::from)?; 114 + let mut state = initialize_active_state(&manager, cursor)?; 115 + 116 + let (sender, receiver) = flume::bounded(config.channel_capacity); 117 + 118 + let handle = thread::Builder::new() 119 + .name("blockstore-group-commit".into()) 120 + .spawn(move || { 121 + commit_loop(&manager, &*index, &receiver, &config, &mut state); 122 + }) 123 + .map_err(|e| CommitError::from(io::Error::other(e)))?; 124 + 125 + Ok(Self { 126 + sender, 127 + handle: Some(handle), 128 + }) 129 + } 130 + 131 + pub fn sender(&self) -> &flume::Sender<CommitRequest> { 132 + &self.sender 133 + } 134 + 135 + pub fn shutdown(mut self) { 136 + let _ = self.sender.send(CommitRequest::Shutdown); 137 + if let Some(handle) = self.handle.take() 138 + && let Err(payload) = handle.join() 139 + { 140 + log_thread_panic(payload, "group commit thread panicked"); 141 + } 142 + } 143 + } 144 + 145 + impl Drop for GroupCommitWriter { 146 + fn drop(&mut self) { 147 + let _ = self.sender.try_send(CommitRequest::Shutdown); 148 + if let Some(handle) = self.handle.take() 149 + && let Err(payload) = handle.join() 150 + { 151 + log_thread_panic(payload, "group commit thread panicked during drop"); 152 + } 153 + } 154 + } 155 + 156 + fn initialize_active_state<S: StorageIO>( 157 + manager: &DataFileManager<S>, 158 + cursor: Option<WriteCursor>, 159 + ) -> Result<ActiveState, CommitError> { 160 + let data_dir = manager.data_dir(); 161 + let existing_files = manager.list_files()?; 162 + 163 + match cursor { 164 + Some(wc) => { 165 + let fd = manager.open_for_append(wc.file_id)?; 166 + let file_size = manager.io().file_size(fd)?; 167 + 168 + if file_size < wc.offset.raw() { 169 + return Err(CommitError::from(io::Error::new( 170 + io::ErrorKind::InvalidData, 171 + "data file smaller than write cursor", 172 + ))); 173 + } 174 + 175 + let hint_path = hint_file_path(data_dir, wc.file_id); 176 + let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?; 177 + let hint_size = manager.io().file_size(hint_fd)?; 178 + 179 + Ok(ActiveState { 180 + file_id: wc.file_id, 181 + fd, 182 + position: BlockOffset::new(file_size), 183 + hint_fd, 184 + hint_position: HintOffset::new(hint_size), 185 + }) 186 + } 187 + None => { 188 + let file_id = existing_files 189 + .last() 190 + .copied() 191 + .map(|id| id.next()) 192 + .unwrap_or_else(|| DataFileId::new(0)); 193 + 194 + let fd = manager.open_for_append(file_id)?; 195 + let writer = DataFileWriter::new(manager.io(), fd, file_id)?; 196 + writer.sync()?; 197 + let position = writer.position(); 198 + 199 + let hint_path = hint_file_path(data_dir, file_id); 200 + let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?; 201 + 202 + manager.io().sync_dir(data_dir)?; 203 + 204 + Ok(ActiveState { 205 + file_id, 206 + fd, 207 + position, 208 + hint_fd, 209 + hint_position: HintOffset::new(0), 210 + }) 211 + } 212 + } 213 + } 214 + 215 + enum BatchEntry { 216 + Put { 217 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 218 + response: PutResponse, 219 + }, 220 + Apply { 221 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 222 + deleted_cids: Vec<[u8; CID_SIZE]>, 223 + response: ApplyResponse, 224 + }, 225 + } 226 + 227 + fn classify_request(req: CommitRequest) -> Result<BatchEntry, ()> { 228 + match req { 229 + CommitRequest::PutBlocks { blocks, response } => Ok(BatchEntry::Put { blocks, response }), 230 + CommitRequest::ApplyCommit { 231 + blocks, 232 + deleted_cids, 233 + response, 234 + } => Ok(BatchEntry::Apply { 235 + blocks, 236 + deleted_cids, 237 + response, 238 + }), 239 + CommitRequest::Shutdown => Err(()), 240 + } 241 + } 242 + 243 + fn batch_entry_block_count(entry: &BatchEntry) -> usize { 244 + match entry { 245 + BatchEntry::Put { blocks, .. } | BatchEntry::Apply { blocks, .. } => blocks.len(), 246 + } 247 + } 248 + 249 + fn drain_batch( 250 + receiver: &flume::Receiver<CommitRequest>, 251 + first: CommitRequest, 252 + max_batch_size: usize, 253 + ) -> (Vec<BatchEntry>, bool) { 254 + let first_entry = match classify_request(first) { 255 + Err(()) => return (Vec::new(), true), 256 + Ok(entry) => entry, 257 + }; 258 + 259 + let block_count = Cell::new(batch_entry_block_count(&first_entry)); 260 + let mut entries = vec![first_entry]; 261 + 262 + let saw_shutdown = std::iter::from_fn(|| receiver.try_recv().ok()) 263 + .take_while(|_| block_count.get() < max_batch_size) 264 + .try_for_each(|req| match classify_request(req) { 265 + Err(()) => Err(()), 266 + Ok(entry) => { 267 + block_count.set( 268 + block_count 269 + .get() 270 + .saturating_add(batch_entry_block_count(&entry)), 271 + ); 272 + entries.push(entry); 273 + Ok(()) 274 + } 275 + }) 276 + .is_err(); 277 + 278 + (entries, saw_shutdown) 279 + } 280 + 281 + fn commit_loop<S: StorageIO>( 282 + manager: &DataFileManager<S>, 283 + index: &KeyIndex, 284 + receiver: &flume::Receiver<CommitRequest>, 285 + config: &GroupCommitConfig, 286 + state: &mut ActiveState, 287 + ) { 288 + loop { 289 + let first = match receiver.recv() { 290 + Ok(CommitRequest::Shutdown) => return, 291 + Ok(msg) => msg, 292 + Err(_) => return, 293 + }; 294 + 295 + let (batch, shutdown_after) = drain_batch(receiver, first, config.max_batch_size); 296 + 297 + tracing::debug!( 298 + batch_size = batch.len(), 299 + file_id = %state.file_id, 300 + "processing commit batch" 301 + ); 302 + 303 + let result = process_batch(manager, index, &batch, state); 304 + 305 + if let Err(ref e) = result { 306 + tracing::warn!(error = %e, "commit batch failed"); 307 + } 308 + 309 + dispatch_responses(batch, result); 310 + 311 + if shutdown_after { 312 + drain_and_process_remaining(manager, index, receiver, state); 313 + return; 314 + } 315 + } 316 + } 317 + 318 + fn drain_and_process_remaining<S: StorageIO>( 319 + manager: &DataFileManager<S>, 320 + index: &KeyIndex, 321 + receiver: &flume::Receiver<CommitRequest>, 322 + state: &mut ActiveState, 323 + ) { 324 + let entries: Vec<BatchEntry> = std::iter::from_fn(|| receiver.try_recv().ok()) 325 + .filter_map(|req| classify_request(req).ok()) 326 + .collect(); 327 + 328 + if entries.is_empty() { 329 + return; 330 + } 331 + 332 + let result = process_batch(manager, index, &entries, state); 333 + dispatch_responses(entries, result); 334 + } 335 + 336 + struct RotationState { 337 + file_id: DataFileId, 338 + fd: FileId, 339 + } 340 + 341 + fn process_batch<S: StorageIO>( 342 + manager: &DataFileManager<S>, 343 + index: &KeyIndex, 344 + batch: &[BatchEntry], 345 + state: &mut ActiveState, 346 + ) -> Result<HashMap<[u8; CID_SIZE], BlockLocation>, CommitError> { 347 + let mut dedup: HashMap<[u8; CID_SIZE], BlockLocation> = HashMap::new(); 348 + let mut index_entries: Vec<([u8; CID_SIZE], BlockLocation)> = Vec::new(); 349 + let mut all_decrements: Vec<[u8; CID_SIZE]> = Vec::new(); 350 + 351 + let mut current_hint_fd = state.hint_fd; 352 + let mut rotation: Option<RotationState> = None; 353 + 354 + let mut data_writer = 355 + DataFileWriter::resume(manager.io(), state.fd, state.file_id, state.position); 356 + let mut hint_writer = 357 + HintFileWriter::resume(manager.io(), current_hint_fd, state.hint_position); 358 + 359 + let write_result: Result<(), CommitError> = batch.iter().try_for_each(|entry| { 360 + let (blocks, decrements) = match entry { 361 + BatchEntry::Put { blocks, .. } => (blocks.as_slice(), None), 362 + BatchEntry::Apply { 363 + blocks, 364 + deleted_cids, 365 + .. 366 + } => (blocks.as_slice(), Some(deleted_cids.as_slice())), 367 + }; 368 + 369 + blocks.iter().try_for_each(|(cid_bytes, data)| { 370 + let location = match dedup.get(cid_bytes) { 371 + Some(&loc) => loc, 372 + None => { 373 + if manager.should_rotate(data_writer.position()) { 374 + data_writer.sync()?; 375 + hint_writer.sync()?; 376 + 377 + let (next_id, next_fd) = manager.prepare_rotation(data_writer.file_id())?; 378 + 379 + tracing::info!( 380 + from = %data_writer.file_id(), 381 + to = %next_id, 382 + "data file rotation" 383 + ); 384 + 385 + data_writer = DataFileWriter::new(manager.io(), next_fd, next_id)?; 386 + 387 + let new_hint_path = hint_file_path(manager.data_dir(), next_id); 388 + let new_hint_fd = manager 389 + .io() 390 + .open(&new_hint_path, OpenOptions::read_write())?; 391 + 392 + manager.io().sync_dir(manager.data_dir())?; 393 + 394 + current_hint_fd = new_hint_fd; 395 + hint_writer = HintFileWriter::new(manager.io(), new_hint_fd); 396 + rotation = Some(RotationState { 397 + file_id: next_id, 398 + fd: next_fd, 399 + }); 400 + } 401 + 402 + let loc = data_writer.append_block(cid_bytes, data)?; 403 + hint_writer.append_hint(cid_bytes, loc.file_id, loc.offset, loc.length)?; 404 + 405 + dedup.insert(*cid_bytes, loc); 406 + loc 407 + } 408 + }; 409 + 410 + index_entries.push((*cid_bytes, location)); 411 + Ok::<_, CommitError>(()) 412 + })?; 413 + 414 + if let Some(decs) = decrements { 415 + all_decrements.extend_from_slice(decs); 416 + } 417 + 418 + Ok::<_, CommitError>(()) 419 + }); 420 + 421 + if let Err(e) = write_result { 422 + if let Some(rot) = rotation { 423 + manager.rollback_rotation(rot.file_id, rot.fd); 424 + } 425 + return Err(e); 426 + } 427 + 428 + data_writer.sync()?; 429 + hint_writer.sync()?; 430 + 431 + if let Some(ref rot) = rotation { 432 + manager.commit_rotation(rot.file_id, rot.fd); 433 + } 434 + 435 + state.file_id = data_writer.file_id(); 436 + state.fd = data_writer.fd(); 437 + state.position = data_writer.position(); 438 + state.hint_fd = current_hint_fd; 439 + state.hint_position = hint_writer.position(); 440 + 441 + let cursor = WriteCursor { 442 + file_id: state.file_id, 443 + offset: state.position, 444 + }; 445 + index 446 + .batch_put(&index_entries, &all_decrements, cursor) 447 + .map_err(CommitError::from)?; 448 + 449 + Ok(dedup) 450 + } 451 + 452 + fn dispatch_responses( 453 + batch: Vec<BatchEntry>, 454 + result: Result<HashMap<[u8; CID_SIZE], BlockLocation>, CommitError>, 455 + ) { 456 + match result { 457 + Err(e) => { 458 + batch.into_iter().for_each(|entry| { 459 + let err = e.clone(); 460 + match entry { 461 + BatchEntry::Put { response, .. } => { 462 + let _ = response.send(Err(err)); 463 + } 464 + BatchEntry::Apply { response, .. } => { 465 + let _ = response.send(Err(err)); 466 + } 467 + } 468 + }); 469 + } 470 + Ok(written) => { 471 + batch.into_iter().for_each(|entry| match entry { 472 + BatchEntry::Put { 473 + blocks, response, .. 474 + } => { 475 + let result: Result<Vec<BlockLocation>, CommitError> = blocks 476 + .iter() 477 + .map(|(cid, _)| match written.get(cid) { 478 + Some(&loc) => Ok(loc), 479 + None => { 480 + tracing::error!( 481 + ?cid, 482 + "committed CID missing from dedup map, this is a bug" 483 + ); 484 + Err(CommitError::from(io::Error::other( 485 + "committed CID missing from dedup map", 486 + ))) 487 + } 488 + }) 489 + .collect(); 490 + let _ = response.send(result); 491 + } 492 + BatchEntry::Apply { response, .. } => { 493 + let _ = response.send(Ok(())); 494 + } 495 + }); 496 + } 497 + } 498 + } 499 + 500 + #[cfg(test)] 501 + mod tests { 502 + use super::*; 503 + use crate::RealIO; 504 + use crate::blockstore::data_file::DataFileReader; 505 + use crate::blockstore::manager::DATA_FILE_EXTENSION; 506 + use crate::blockstore::test_cid; 507 + use futures::StreamExt; 508 + 509 + fn setup_real(dir: &std::path::Path) -> (DataFileManager<RealIO>, Arc<KeyIndex>) { 510 + let data_dir = dir.join("data"); 511 + std::fs::create_dir_all(&data_dir).unwrap(); 512 + let index_dir = dir.join("index"); 513 + let manager = DataFileManager::with_default_max_size(RealIO::new(), data_dir); 514 + let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); 515 + (manager, index) 516 + } 517 + 518 + async fn put_blocks( 519 + sender: &flume::Sender<CommitRequest>, 520 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 521 + ) -> Result<Vec<BlockLocation>, CommitError> { 522 + let (tx, rx) = tokio::sync::oneshot::channel(); 523 + sender 524 + .send_async(CommitRequest::PutBlocks { 525 + blocks, 526 + response: tx, 527 + }) 528 + .await 529 + .map_err(|_| CommitError::ChannelClosed)?; 530 + rx.await.map_err(|_| CommitError::ChannelClosed)? 531 + } 532 + 533 + async fn apply_commit_req( 534 + sender: &flume::Sender<CommitRequest>, 535 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 536 + deleted_cids: Vec<[u8; CID_SIZE]>, 537 + ) -> Result<(), CommitError> { 538 + let (tx, rx) = tokio::sync::oneshot::channel(); 539 + sender 540 + .send_async(CommitRequest::ApplyCommit { 541 + blocks, 542 + deleted_cids, 543 + response: tx, 544 + }) 545 + .await 546 + .map_err(|_| CommitError::ChannelClosed)?; 547 + rx.await.map_err(|_| CommitError::ChannelClosed)? 548 + } 549 + 550 + fn count_data_file_blocks(data_dir: &std::path::Path) -> usize { 551 + let io = RealIO::new(); 552 + let data_files = 553 + super::super::list_files_by_extension(&io, data_dir, DATA_FILE_EXTENSION).unwrap(); 554 + data_files 555 + .iter() 556 + .map(|&fid| { 557 + let path = data_dir.join(format!("{fid}.tqb")); 558 + let fd = io.open(&path, OpenOptions::read_only_existing()).unwrap(); 559 + let count = DataFileReader::open(&io, fd) 560 + .unwrap() 561 + .valid_blocks() 562 + .unwrap() 563 + .len(); 564 + let _ = io.close(fd); 565 + count 566 + }) 567 + .sum() 568 + } 569 + 570 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 571 + async fn concurrent_100_writes_from_10_tasks() { 572 + let dir = tempfile::TempDir::new().unwrap(); 573 + let (manager, index) = setup_real(dir.path()); 574 + let data_dir = manager.data_dir().to_path_buf(); 575 + let writer = 576 + GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); 577 + let sender = writer.sender().clone(); 578 + 579 + let handles: Vec<_> = (0u8..10) 580 + .map(|task_id| { 581 + let sender = sender.clone(); 582 + tokio::spawn(async move { 583 + let blocks: Vec<_> = (0u8..10) 584 + .map(|block_id| { 585 + let idx = task_id * 10 + block_id; 586 + (test_cid(idx), vec![idx; (idx as usize + 1) * 8]) 587 + }) 588 + .collect(); 589 + 590 + futures::stream::iter(blocks) 591 + .fold( 592 + Vec::<BlockLocation>::new(), 593 + |mut acc, (cid, data): ([u8; CID_SIZE], Vec<u8>)| { 594 + let sender = sender.clone(); 595 + async move { 596 + let locs = 597 + put_blocks(&sender, vec![(cid, data)]).await.unwrap(); 598 + acc.extend(locs); 599 + acc 600 + } 601 + }, 602 + ) 603 + .await 604 + }) 605 + }) 606 + .collect(); 607 + 608 + let all_locations: Vec<Vec<BlockLocation>> = futures::future::join_all(handles) 609 + .await 610 + .into_iter() 611 + .map(|r| r.unwrap()) 612 + .collect(); 613 + 614 + let total: usize = all_locations.iter().map(|v| v.len()).sum(); 615 + assert_eq!(total, 100); 616 + 617 + writer.shutdown(); 618 + 619 + let index_dir = dir.path().join("index"); 620 + let index = KeyIndex::open(&index_dir).unwrap().into_inner(); 621 + (0u8..100).for_each(|i| { 622 + assert!( 623 + index.has(&test_cid(i)).unwrap(), 624 + "block {i} missing from index" 625 + ); 626 + }); 627 + 628 + assert_eq!(count_data_file_blocks(&data_dir), 100); 629 + } 630 + 631 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 632 + async fn duplicate_cids_in_same_batch_write_once() { 633 + let dir = tempfile::TempDir::new().unwrap(); 634 + let (manager, index) = setup_real(dir.path()); 635 + let data_dir = manager.data_dir().to_path_buf(); 636 + let writer = 637 + GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); 638 + let sender = writer.sender().clone(); 639 + 640 + let cid = test_cid(42); 641 + let data = vec![0xAB; 128]; 642 + let blocks = vec![ 643 + (cid, data.clone()), 644 + (cid, data.clone()), 645 + (cid, data.clone()), 646 + ]; 647 + 648 + let locations = put_blocks(&sender, blocks).await.unwrap(); 649 + 650 + assert_eq!(locations.len(), 3); 651 + assert_eq!(locations[0], locations[1]); 652 + assert_eq!(locations[1], locations[2]); 653 + 654 + writer.shutdown(); 655 + 656 + let index_dir = dir.path().join("index"); 657 + let index = KeyIndex::open(&index_dir).unwrap().into_inner(); 658 + let entry = index.get(&cid).unwrap().unwrap(); 659 + assert_eq!(entry.refcount.raw(), 3); 660 + 661 + assert_eq!( 662 + count_data_file_blocks(&data_dir), 663 + 1, 664 + "duplicate CID should only be written once to data file" 665 + ); 666 + } 667 + 668 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 669 + async fn apply_commit_with_blocks_and_deletes() { 670 + let dir = tempfile::TempDir::new().unwrap(); 671 + let (manager, index) = setup_real(dir.path()); 672 + let writer = 673 + GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); 674 + let sender = writer.sender().clone(); 675 + 676 + let cid_a = test_cid(1); 677 + let cid_b = test_cid(2); 678 + put_blocks( 679 + &sender, 680 + vec![(cid_a, vec![0x01; 64]), (cid_b, vec![0x02; 64])], 681 + ) 682 + .await 683 + .unwrap(); 684 + 685 + let cid_c = test_cid(3); 686 + apply_commit_req(&sender, vec![(cid_c, vec![0x03; 64])], vec![cid_a]) 687 + .await 688 + .unwrap(); 689 + 690 + writer.shutdown(); 691 + 692 + let index_dir = dir.path().join("index"); 693 + let index = KeyIndex::open(&index_dir).unwrap().into_inner(); 694 + assert_eq!(index.get(&cid_a).unwrap().unwrap().refcount.raw(), 0); 695 + assert_eq!(index.get(&cid_b).unwrap().unwrap().refcount.raw(), 1); 696 + assert_eq!(index.get(&cid_c).unwrap().unwrap().refcount.raw(), 1); 697 + } 698 + 699 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 700 + async fn graceful_shutdown_processes_remaining() { 701 + let dir = tempfile::TempDir::new().unwrap(); 702 + let (manager, index) = setup_real(dir.path()); 703 + let writer = 704 + GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); 705 + let sender = writer.sender().clone(); 706 + 707 + let cid = test_cid(99); 708 + let locations = put_blocks(&sender, vec![(cid, vec![0xFF; 32])]) 709 + .await 710 + .unwrap(); 711 + assert_eq!(locations.len(), 1); 712 + 713 + writer.shutdown(); 714 + 715 + let index_dir = dir.path().join("index"); 716 + let index = KeyIndex::open(&index_dir).unwrap().into_inner(); 717 + assert!(index.has(&cid).unwrap()); 718 + } 719 + 720 + #[test] 721 + fn sim_crash_between_write_and_fsync_loses_unsynced() { 722 + use crate::SimulatedIO; 723 + use std::path::Path; 724 + use std::sync::Arc; 725 + 726 + let sim = Arc::new(SimulatedIO::pristine(42)); 727 + let data_dir = Path::new("/data"); 728 + sim.mkdir(data_dir).unwrap(); 729 + sim.sync_dir(data_dir).unwrap(); 730 + 731 + let manager = 732 + DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf()); 733 + let fd = manager.open_for_append(DataFileId::new(0)).unwrap(); 734 + let mut writer = DataFileWriter::new(&*sim, fd, DataFileId::new(0)).unwrap(); 735 + 736 + let synced_cids: Vec<_> = (0u8..5) 737 + .map(|i| { 738 + let cid = test_cid(i); 739 + let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); 740 + cid 741 + }) 742 + .collect(); 743 + writer.sync().unwrap(); 744 + sim.sync_dir(data_dir).unwrap(); 745 + 746 + (5u8..10).for_each(|i| { 747 + let cid = test_cid(i); 748 + let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); 749 + }); 750 + 751 + sim.crash(); 752 + 753 + let fd_after = sim 754 + .open(Path::new("/data/000000.tqb"), OpenOptions::read()) 755 + .unwrap(); 756 + let recovered = DataFileReader::open(&*sim, fd_after) 757 + .unwrap() 758 + .valid_blocks() 759 + .unwrap(); 760 + 761 + assert!( 762 + recovered.len() <= 5, 763 + "expected at most 5 synced blocks, got {}", 764 + recovered.len() 765 + ); 766 + 767 + recovered.iter().enumerate().for_each(|(i, (_, cid, _))| { 768 + assert_eq!(*cid, synced_cids[i], "recovered block {i} CID mismatch"); 769 + }); 770 + } 771 + 772 + #[test] 773 + fn sim_crash_between_fsync_and_index_update_recovers_via_hints() { 774 + use crate::SimulatedIO; 775 + use crate::blockstore::data_file::{BLOCK_HEADER_SIZE, BLOCK_RECORD_OVERHEAD}; 776 + use crate::blockstore::hint::rebuild_index_from_hints; 777 + use crate::blockstore::types::BlockLength; 778 + use std::path::Path; 779 + use std::sync::Arc; 780 + 781 + let sim = Arc::new(SimulatedIO::pristine(42)); 782 + let data_dir = Path::new("/data"); 783 + sim.mkdir(data_dir).unwrap(); 784 + sim.sync_dir(data_dir).unwrap(); 785 + 786 + let manager = 787 + DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf()); 788 + let fd = manager.open_for_append(DataFileId::new(0)).unwrap(); 789 + let mut writer = DataFileWriter::new(&*sim, fd, DataFileId::new(0)).unwrap(); 790 + 791 + let phase1_cids: Vec<_> = (0u8..3) 792 + .map(|i| { 793 + let cid = test_cid(i); 794 + let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); 795 + cid 796 + }) 797 + .collect(); 798 + writer.sync().unwrap(); 799 + let phase1_end = writer.position(); 800 + 801 + let real_dir = tempfile::TempDir::new().unwrap(); 802 + let index_path = real_dir.path().join("index"); 803 + let index = KeyIndex::open(&index_path).unwrap().into_inner(); 804 + 805 + let entries: Vec<_> = phase1_cids 806 + .iter() 807 + .enumerate() 808 + .map(|(i, cid)| { 809 + let offset = BlockOffset::new( 810 + BLOCK_HEADER_SIZE as u64 + i as u64 * (BLOCK_RECORD_OVERHEAD as u64 + 64), 811 + ); 812 + ( 813 + *cid, 814 + BlockLocation { 815 + file_id: DataFileId::new(0), 816 + offset, 817 + length: BlockLength::new(64), 818 + }, 819 + ) 820 + }) 821 + .collect(); 822 + index 823 + .batch_put( 824 + &entries, 825 + &[], 826 + WriteCursor { 827 + file_id: DataFileId::new(0), 828 + offset: phase1_end, 829 + }, 830 + ) 831 + .unwrap(); 832 + index.persist().unwrap(); 833 + 834 + let phase2_cids: Vec<_> = (10u8..15) 835 + .map(|i| { 836 + let cid = test_cid(i); 837 + let _ = writer.append_block(&cid, &vec![i; 128]).unwrap(); 838 + cid 839 + }) 840 + .collect(); 841 + writer.sync().unwrap(); 842 + sim.sync_dir(data_dir).unwrap(); 843 + 844 + let hint_path = hint_file_path(data_dir, DataFileId::new(0)); 845 + let hint_fd = sim.open(&hint_path, OpenOptions::read_write()).unwrap(); 846 + let mut hint_writer = HintFileWriter::new(&*sim, hint_fd); 847 + 848 + let mut offset_tracker = BlockOffset::new(BLOCK_HEADER_SIZE as u64); 849 + phase1_cids.iter().for_each(|cid| { 850 + hint_writer 851 + .append_hint( 852 + cid, 853 + DataFileId::new(0), 854 + offset_tracker, 855 + BlockLength::new(64), 856 + ) 857 + .unwrap(); 858 + offset_tracker = offset_tracker.advance(BLOCK_RECORD_OVERHEAD as u64 + 64); 859 + }); 860 + phase2_cids.iter().for_each(|cid| { 861 + hint_writer 862 + .append_hint( 863 + cid, 864 + DataFileId::new(0), 865 + offset_tracker, 866 + BlockLength::new(128), 867 + ) 868 + .unwrap(); 869 + offset_tracker = offset_tracker.advance(BLOCK_RECORD_OVERHEAD as u64 + 128); 870 + }); 871 + hint_writer.sync().unwrap(); 872 + sim.sync_dir(data_dir).unwrap(); 873 + 874 + sim.crash(); 875 + 876 + drop(index); 877 + let rebuilt_index_path = real_dir.path().join("rebuilt_index"); 878 + let rebuilt_index = KeyIndex::open(&rebuilt_index_path).unwrap().into_inner(); 879 + rebuild_index_from_hints(&*sim, data_dir, &rebuilt_index).unwrap(); 880 + 881 + phase1_cids.iter().for_each(|cid| { 882 + assert!( 883 + rebuilt_index.has(cid).unwrap(), 884 + "phase1 CID should be in rebuilt index" 885 + ); 886 + }); 887 + phase2_cids.iter().for_each(|cid| { 888 + assert!( 889 + rebuilt_index.has(cid).unwrap(), 890 + "phase2 CID should be in rebuilt index, was synced and hinted before crash" 891 + ); 892 + }); 893 + 894 + let cursor = rebuilt_index.read_write_cursor().unwrap().unwrap(); 895 + assert!( 896 + cursor.offset.raw() > phase1_end.raw(), 897 + "cursor should be past phase1 after rebuild" 898 + ); 899 + } 900 + 901 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 902 + async fn rotation_during_batch() { 903 + let dir = tempfile::TempDir::new().unwrap(); 904 + let data_dir = dir.path().join("data"); 905 + std::fs::create_dir_all(&data_dir).unwrap(); 906 + let index_dir = dir.path().join("index"); 907 + 908 + let small_max = 512u64; 909 + let manager = DataFileManager::new(RealIO::new(), data_dir.clone(), small_max); 910 + let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); 911 + let writer = 912 + GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); 913 + let sender = writer.sender().clone(); 914 + 915 + let all_cids: Vec<_> = (0u8..20).map(test_cid).collect(); 916 + 917 + let handles: Vec<_> = all_cids 918 + .iter() 919 + .map(|&cid| { 920 + let sender = sender.clone(); 921 + tokio::spawn(async move { 922 + put_blocks(&sender, vec![(cid, vec![cid[4]; 100])]) 923 + .await 924 + .unwrap() 925 + }) 926 + }) 927 + .collect(); 928 + 929 + let results: Vec<_> = futures::future::join_all(handles) 930 + .await 931 + .into_iter() 932 + .map(|r| r.unwrap()) 933 + .collect(); 934 + 935 + assert_eq!(results.len(), 20); 936 + 937 + writer.shutdown(); 938 + 939 + let io = RealIO::new(); 940 + let data_files = 941 + super::super::list_files_by_extension(&io, &data_dir, DATA_FILE_EXTENSION).unwrap(); 942 + assert!( 943 + data_files.len() > 1, 944 + "expected rotation to create multiple files, got {}", 945 + data_files.len() 946 + ); 947 + 948 + let index = KeyIndex::open(&index_dir).unwrap().into_inner(); 949 + all_cids.iter().for_each(|cid| { 950 + assert!(index.has(cid).unwrap()); 951 + }); 952 + } 953 + }
+1027
crates/tranquil-store/src/blockstore/hint.rs
··· 1 + use std::io; 2 + use std::path::{Path, PathBuf}; 3 + 4 + use crate::io::{FileId, OpenOptions, StorageIO}; 5 + 6 + use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, DataFileReader}; 7 + use super::key_index::{KeyIndex, KeyIndexError}; 8 + use super::list_files_by_extension; 9 + use super::manager::DATA_FILE_EXTENSION; 10 + use super::types::{ 11 + BlockLength, BlockLocation, BlockOffset, DataFileId, HintOffset, MAX_BLOCK_SIZE, WriteCursor, 12 + }; 13 + 14 + pub const HINT_RECORD_SIZE: usize = CID_SIZE + 4 + 8 + 4 + 4; 15 + pub const HINT_FILE_EXTENSION: &str = "tqh"; 16 + 17 + fn hint_checksum(buf: &[u8; CID_SIZE + 4 + 8 + 4]) -> u32 { 18 + xxhash_rust::xxh3::xxh3_64(buf) as u32 19 + } 20 + 21 + pub fn hint_file_path(data_dir: &Path, file_id: DataFileId) -> PathBuf { 22 + data_dir.join(format!("{file_id}.{HINT_FILE_EXTENSION}")) 23 + } 24 + 25 + pub(crate) fn encode_hint_record<S: StorageIO>( 26 + io: &S, 27 + fd: FileId, 28 + write_offset: HintOffset, 29 + cid_bytes: &[u8; CID_SIZE], 30 + file_id: DataFileId, 31 + block_offset: BlockOffset, 32 + length: BlockLength, 33 + ) -> io::Result<()> { 34 + debug_assert!( 35 + write_offset.raw().is_multiple_of(HINT_RECORD_SIZE as u64), 36 + "hint write_offset {} not aligned to HINT_RECORD_SIZE {}", 37 + write_offset.raw(), 38 + HINT_RECORD_SIZE, 39 + ); 40 + 41 + let mut record = [0u8; HINT_RECORD_SIZE]; 42 + record[..CID_SIZE].copy_from_slice(cid_bytes); 43 + record[CID_SIZE..CID_SIZE + 4].copy_from_slice(&file_id.raw().to_le_bytes()); 44 + record[CID_SIZE + 4..CID_SIZE + 12].copy_from_slice(&block_offset.raw().to_le_bytes()); 45 + record[CID_SIZE + 12..CID_SIZE + 16].copy_from_slice(&length.raw().to_le_bytes()); 46 + 47 + let checksum = 48 + hint_checksum(<&[u8; CID_SIZE + 4 + 8 + 4]>::try_from(&record[..CID_SIZE + 16]).unwrap()); 49 + record[CID_SIZE + 16..].copy_from_slice(&checksum.to_le_bytes()); 50 + 51 + io.write_all_at(fd, write_offset.raw(), &record) 52 + } 53 + 54 + #[must_use] 55 + #[derive(Debug)] 56 + pub enum ReadHintRecord { 57 + Valid { 58 + cid_bytes: [u8; CID_SIZE], 59 + file_id: DataFileId, 60 + offset: BlockOffset, 61 + length: BlockLength, 62 + }, 63 + Corrupted, 64 + Truncated, 65 + } 66 + 67 + pub fn decode_hint_record<S: StorageIO>( 68 + io: &S, 69 + fd: FileId, 70 + read_offset: HintOffset, 71 + file_size: u64, 72 + ) -> io::Result<Option<ReadHintRecord>> { 73 + let raw = read_offset.raw(); 74 + let remaining = match file_size.checked_sub(raw) { 75 + Some(r) => r, 76 + None => return Ok(None), 77 + }; 78 + if remaining == 0 { 79 + return Ok(None); 80 + } 81 + 82 + if remaining < HINT_RECORD_SIZE as u64 { 83 + return Ok(Some(ReadHintRecord::Truncated)); 84 + } 85 + 86 + let mut record = [0u8; HINT_RECORD_SIZE]; 87 + io.read_exact_at(fd, raw, &mut record)?; 88 + 89 + let payload: &[u8; CID_SIZE + 4 + 8 + 4] = record[..CID_SIZE + 16].try_into().unwrap(); 90 + let stored = u32::from_le_bytes(record[CID_SIZE + 16..].try_into().unwrap()); 91 + let computed = hint_checksum(payload); 92 + if stored != computed { 93 + return Ok(Some(ReadHintRecord::Corrupted)); 94 + } 95 + 96 + let mut cid_bytes = [0u8; CID_SIZE]; 97 + cid_bytes.copy_from_slice(&record[..CID_SIZE]); 98 + 99 + let file_id = DataFileId::new(u32::from_le_bytes( 100 + record[CID_SIZE..CID_SIZE + 4].try_into().unwrap(), 101 + )); 102 + let block_offset = BlockOffset::new(u64::from_le_bytes( 103 + record[CID_SIZE + 4..CID_SIZE + 12].try_into().unwrap(), 104 + )); 105 + let raw_length = u32::from_le_bytes(record[CID_SIZE + 12..CID_SIZE + 16].try_into().unwrap()); 106 + if raw_length > MAX_BLOCK_SIZE { 107 + return Ok(Some(ReadHintRecord::Corrupted)); 108 + } 109 + let length = BlockLength::new(raw_length); 110 + 111 + Ok(Some(ReadHintRecord::Valid { 112 + cid_bytes, 113 + file_id, 114 + offset: block_offset, 115 + length, 116 + })) 117 + } 118 + 119 + pub struct HintFileWriter<'a, S: StorageIO> { 120 + io: &'a S, 121 + fd: FileId, 122 + position: HintOffset, 123 + } 124 + 125 + impl<'a, S: StorageIO> HintFileWriter<'a, S> { 126 + pub fn new(io: &'a S, fd: FileId) -> Self { 127 + Self { 128 + io, 129 + fd, 130 + position: HintOffset::new(0), 131 + } 132 + } 133 + 134 + pub fn resume(io: &'a S, fd: FileId, position: HintOffset) -> Self { 135 + Self { io, fd, position } 136 + } 137 + 138 + pub fn append_hint( 139 + &mut self, 140 + cid_bytes: &[u8; CID_SIZE], 141 + file_id: DataFileId, 142 + offset: BlockOffset, 143 + length: BlockLength, 144 + ) -> io::Result<()> { 145 + encode_hint_record( 146 + self.io, 147 + self.fd, 148 + self.position, 149 + cid_bytes, 150 + file_id, 151 + offset, 152 + length, 153 + )?; 154 + self.position = self.position.advance(HINT_RECORD_SIZE as u64); 155 + Ok(()) 156 + } 157 + 158 + pub fn sync(&self) -> io::Result<()> { 159 + self.io.sync(self.fd) 160 + } 161 + 162 + pub fn position(&self) -> HintOffset { 163 + self.position 164 + } 165 + } 166 + 167 + pub struct HintFileReader<'a, S: StorageIO> { 168 + io: &'a S, 169 + fd: FileId, 170 + position: HintOffset, 171 + file_size: u64, 172 + } 173 + 174 + impl<'a, S: StorageIO> HintFileReader<'a, S> { 175 + pub fn open(io: &'a S, fd: FileId) -> io::Result<Self> { 176 + let file_size = io.file_size(fd)?; 177 + Ok(Self { 178 + io, 179 + fd, 180 + position: HintOffset::new(0), 181 + file_size, 182 + }) 183 + } 184 + } 185 + 186 + impl<S: StorageIO> Iterator for HintFileReader<'_, S> { 187 + type Item = io::Result<ReadHintRecord>; 188 + 189 + fn next(&mut self) -> Option<Self::Item> { 190 + match decode_hint_record(self.io, self.fd, self.position, self.file_size) { 191 + Err(e) => { 192 + self.position = HintOffset::new(self.file_size); 193 + Some(Err(e)) 194 + } 195 + Ok(None) => None, 196 + Ok(Some(record)) => { 197 + match &record { 198 + ReadHintRecord::Valid { .. } => { 199 + self.position = self.position.advance(HINT_RECORD_SIZE as u64); 200 + } 201 + ReadHintRecord::Corrupted | ReadHintRecord::Truncated => { 202 + self.position = HintOffset::new(self.file_size); 203 + } 204 + } 205 + Some(Ok(record)) 206 + } 207 + } 208 + } 209 + } 210 + 211 + #[derive(Debug)] 212 + pub enum RebuildError { 213 + Io(io::Error), 214 + Index(KeyIndexError), 215 + } 216 + 217 + impl std::fmt::Display for RebuildError { 218 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 219 + match self { 220 + Self::Io(e) => write!(f, "io: {e}"), 221 + Self::Index(e) => write!(f, "index: {e}"), 222 + } 223 + } 224 + } 225 + 226 + impl std::error::Error for RebuildError { 227 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 228 + match self { 229 + Self::Io(e) => Some(e), 230 + Self::Index(e) => Some(e), 231 + } 232 + } 233 + } 234 + 235 + impl From<io::Error> for RebuildError { 236 + fn from(e: io::Error) -> Self { 237 + Self::Io(e) 238 + } 239 + } 240 + 241 + impl From<KeyIndexError> for RebuildError { 242 + fn from(e: KeyIndexError) -> Self { 243 + Self::Index(e) 244 + } 245 + } 246 + 247 + const REBUILD_BATCH_SIZE: usize = 10_000; 248 + 249 + struct RebuildState { 250 + entries: Vec<([u8; CID_SIZE], BlockLocation)>, 251 + cursor_file: DataFileId, 252 + cursor_offset: BlockOffset, 253 + } 254 + 255 + impl RebuildState { 256 + fn new() -> Self { 257 + Self { 258 + entries: Vec::new(), 259 + cursor_file: DataFileId::new(0), 260 + cursor_offset: BlockOffset::new(0), 261 + } 262 + } 263 + 264 + fn push(&mut self, cid_bytes: [u8; CID_SIZE], location: BlockLocation) { 265 + let end = location 266 + .offset 267 + .advance(BLOCK_RECORD_OVERHEAD as u64 + location.length.as_u64()); 268 + if (location.file_id, end) > (self.cursor_file, self.cursor_offset) { 269 + self.cursor_file = location.file_id; 270 + self.cursor_offset = end; 271 + } 272 + self.entries.push((cid_bytes, location)); 273 + } 274 + 275 + fn flush_if_full(&mut self, index: &KeyIndex) -> Result<(), RebuildError> { 276 + if self.entries.len() >= REBUILD_BATCH_SIZE { 277 + self.flush(index)?; 278 + } 279 + Ok(()) 280 + } 281 + 282 + fn flush(&mut self, index: &KeyIndex) -> Result<(), RebuildError> { 283 + if self.entries.is_empty() { 284 + return Ok(()); 285 + } 286 + index.batch_put( 287 + &self.entries, 288 + &[], 289 + WriteCursor { 290 + file_id: self.cursor_file, 291 + offset: self.cursor_offset, 292 + }, 293 + )?; 294 + self.entries.clear(); 295 + Ok(()) 296 + } 297 + } 298 + 299 + pub fn rebuild_index_from_hints<S: StorageIO>( 300 + io: &S, 301 + data_dir: &Path, 302 + index: &KeyIndex, 303 + ) -> Result<(), RebuildError> { 304 + let hint_files = list_files_by_extension(io, data_dir, HINT_FILE_EXTENSION)?; 305 + let mut state = RebuildState::new(); 306 + 307 + hint_files.iter().try_for_each(|&hf_id| { 308 + let path = hint_file_path(data_dir, hf_id); 309 + let fd = io.open(&path, OpenOptions::read_only_existing())?; 310 + let reader = HintFileReader::open(io, fd)?; 311 + 312 + let result: Result<(), RebuildError> = reader 313 + .filter_map(|r| match r { 314 + Ok(ReadHintRecord::Valid { 315 + cid_bytes, 316 + file_id, 317 + offset, 318 + length, 319 + }) => Some(Ok((cid_bytes, file_id, offset, length))), 320 + Ok(_) => None, 321 + Err(e) => Some(Err(RebuildError::Io(e))), 322 + }) 323 + .try_for_each(|r| { 324 + let (cid_bytes, file_id, offset, length) = r?; 325 + state.push( 326 + cid_bytes, 327 + BlockLocation { 328 + file_id, 329 + offset, 330 + length, 331 + }, 332 + ); 333 + state.flush_if_full(index) 334 + }); 335 + 336 + let _ = io.close(fd); 337 + result 338 + })?; 339 + 340 + state.flush(index) 341 + } 342 + 343 + pub fn rebuild_index_from_data_files<S: StorageIO>( 344 + io: &S, 345 + data_dir: &Path, 346 + index: &KeyIndex, 347 + ) -> Result<(), RebuildError> { 348 + let data_files = list_files_by_extension(io, data_dir, DATA_FILE_EXTENSION)?; 349 + let mut state = RebuildState::new(); 350 + 351 + data_files.iter().try_for_each(|&file_id| { 352 + let path = data_dir.join(format!("{file_id}.{DATA_FILE_EXTENSION}")); 353 + let fd = io.open(&path, OpenOptions::read_only_existing())?; 354 + let reader = DataFileReader::open(io, fd)?; 355 + 356 + let result: Result<(), RebuildError> = reader 357 + .filter_map(|r| match r { 358 + Ok(super::data_file::ReadBlockRecord::Valid { 359 + offset, 360 + cid_bytes, 361 + data, 362 + }) => { 363 + let length = BlockLength::new( 364 + u32::try_from(data.len()).expect("block size validated by reader"), 365 + ); 366 + Some(Ok((cid_bytes, offset, length))) 367 + } 368 + Ok(_) => None, 369 + Err(e) => Some(Err(RebuildError::Io(e))), 370 + }) 371 + .try_for_each(|r| { 372 + let (cid_bytes, offset, length) = r?; 373 + state.push( 374 + cid_bytes, 375 + BlockLocation { 376 + file_id, 377 + offset, 378 + length, 379 + }, 380 + ); 381 + state.flush_if_full(index) 382 + }); 383 + 384 + let _ = io.close(fd); 385 + result 386 + })?; 387 + 388 + state.flush(index) 389 + } 390 + 391 + #[cfg(test)] 392 + mod tests { 393 + use super::*; 394 + use crate::OpenOptions; 395 + use crate::blockstore::data_file::{DataFileWriter, ReadBlockRecord, decode_block_record}; 396 + use crate::blockstore::test_cid; 397 + use crate::blockstore::types::RefCount; 398 + use crate::sim::SimulatedIO; 399 + use std::path::Path; 400 + 401 + fn setup() -> (SimulatedIO, FileId) { 402 + let sim = SimulatedIO::pristine(42); 403 + let dir = Path::new("/test"); 404 + sim.mkdir(dir).unwrap(); 405 + sim.sync_dir(dir).unwrap(); 406 + let fd = sim 407 + .open(Path::new("/test/hints.tqh"), OpenOptions::read_write()) 408 + .unwrap(); 409 + (sim, fd) 410 + } 411 + 412 + #[test] 413 + fn hint_record_round_trip() { 414 + let (sim, fd) = setup(); 415 + let cid = test_cid(1); 416 + let file_id = DataFileId::new(3); 417 + let offset = BlockOffset::new(1024); 418 + let length = BlockLength::new(256); 419 + 420 + encode_hint_record(&sim, fd, HintOffset::new(0), &cid, file_id, offset, length).unwrap(); 421 + 422 + let file_size = sim.file_size(fd).unwrap(); 423 + let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size) 424 + .unwrap() 425 + .unwrap(); 426 + 427 + match record { 428 + ReadHintRecord::Valid { 429 + cid_bytes, 430 + file_id: fid, 431 + offset: off, 432 + length: len, 433 + } => { 434 + assert_eq!(cid_bytes, cid); 435 + assert_eq!(fid, file_id); 436 + assert_eq!(off, offset); 437 + assert_eq!(len, length); 438 + } 439 + other => panic!("expected Valid, got {other:?}"), 440 + } 441 + } 442 + 443 + #[test] 444 + fn multiple_hint_records() { 445 + let (sim, fd) = setup(); 446 + 447 + (0u8..5).for_each(|i| { 448 + let cid = test_cid(i); 449 + let write_offset = HintOffset::new(i as u64 * HINT_RECORD_SIZE as u64); 450 + encode_hint_record( 451 + &sim, 452 + fd, 453 + write_offset, 454 + &cid, 455 + DataFileId::new(i as u32), 456 + BlockOffset::new(i as u64 * 100), 457 + BlockLength::new(50 + i as u32), 458 + ) 459 + .unwrap(); 460 + }); 461 + 462 + let file_size = sim.file_size(fd).unwrap(); 463 + assert_eq!(file_size, 5 * HINT_RECORD_SIZE as u64); 464 + 465 + let records: Vec<_> = (0u8..5) 466 + .map(|i| { 467 + let read_offset = HintOffset::new(i as u64 * HINT_RECORD_SIZE as u64); 468 + decode_hint_record(&sim, fd, read_offset, file_size) 469 + .unwrap() 470 + .unwrap() 471 + }) 472 + .collect(); 473 + 474 + records.iter().enumerate().for_each(|(i, r)| match r { 475 + ReadHintRecord::Valid { 476 + file_id, length, .. 477 + } => { 478 + assert_eq!(file_id.raw(), i as u32); 479 + assert_eq!(length.raw(), 50 + i as u32); 480 + } 481 + other => panic!("expected Valid at index {i}, got {other:?}"), 482 + }); 483 + } 484 + 485 + #[test] 486 + fn detects_truncated_hint() { 487 + let (sim, fd) = setup(); 488 + sim.write_all_at(fd, 0, &[0u8; HINT_RECORD_SIZE - 1]) 489 + .unwrap(); 490 + let file_size = sim.file_size(fd).unwrap(); 491 + let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size) 492 + .unwrap() 493 + .unwrap(); 494 + assert!(matches!(record, ReadHintRecord::Truncated)); 495 + } 496 + 497 + #[test] 498 + fn detects_corrupted_hint() { 499 + let (sim, fd) = setup(); 500 + let cid = test_cid(1); 501 + encode_hint_record( 502 + &sim, 503 + fd, 504 + HintOffset::new(0), 505 + &cid, 506 + DataFileId::new(0), 507 + BlockOffset::new(0), 508 + BlockLength::new(100), 509 + ) 510 + .unwrap(); 511 + 512 + sim.write_all_at(fd, 10, &[0xFF]).unwrap(); 513 + 514 + let file_size = sim.file_size(fd).unwrap(); 515 + let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size) 516 + .unwrap() 517 + .unwrap(); 518 + assert!(matches!(record, ReadHintRecord::Corrupted)); 519 + } 520 + 521 + #[test] 522 + fn returns_none_at_eof() { 523 + let (sim, fd) = setup(); 524 + let file_size = sim.file_size(fd).unwrap(); 525 + assert!( 526 + decode_hint_record(&sim, fd, HintOffset::new(0), file_size) 527 + .unwrap() 528 + .is_none() 529 + ); 530 + } 531 + 532 + #[test] 533 + fn oversized_length_treated_as_corrupted() { 534 + let (sim, fd) = setup(); 535 + let cid = test_cid(1); 536 + encode_hint_record( 537 + &sim, 538 + fd, 539 + HintOffset::new(0), 540 + &cid, 541 + DataFileId::new(0), 542 + BlockOffset::new(0), 543 + BlockLength::new(100), 544 + ) 545 + .unwrap(); 546 + 547 + let length_offset = CID_SIZE as u64 + 4 + 8; 548 + let oversized = (MAX_BLOCK_SIZE + 1).to_le_bytes(); 549 + sim.write_all_at(fd, length_offset, &oversized).unwrap(); 550 + 551 + let checksum_offset = (CID_SIZE + 4 + 8 + 4) as u64; 552 + let mut buf = [0u8; CID_SIZE + 4 + 8 + 4]; 553 + sim.read_exact_at(fd, 0, &mut buf).unwrap(); 554 + let fixed_checksum = hint_checksum(&buf); 555 + sim.write_all_at(fd, checksum_offset, &fixed_checksum.to_le_bytes()) 556 + .unwrap(); 557 + 558 + let file_size = sim.file_size(fd).unwrap(); 559 + let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size) 560 + .unwrap() 561 + .unwrap(); 562 + assert!(matches!(record, ReadHintRecord::Corrupted)); 563 + } 564 + 565 + #[test] 566 + fn hint_writer_writes_readable_records() { 567 + let (sim, fd) = setup(); 568 + let mut writer = HintFileWriter::new(&sim, fd); 569 + 570 + (0u8..5).for_each(|i| { 571 + writer 572 + .append_hint( 573 + &test_cid(i), 574 + DataFileId::new(0), 575 + BlockOffset::new(i as u64 * 100), 576 + BlockLength::new(50 + i as u32), 577 + ) 578 + .unwrap(); 579 + }); 580 + 581 + assert_eq!( 582 + writer.position(), 583 + HintOffset::new(5 * HINT_RECORD_SIZE as u64) 584 + ); 585 + 586 + let reader = HintFileReader::open(&sim, fd).unwrap(); 587 + let records: Vec<_> = reader.map(|r| r.unwrap()).collect(); 588 + assert_eq!(records.len(), 5); 589 + 590 + records.iter().enumerate().for_each(|(i, r)| match r { 591 + ReadHintRecord::Valid { 592 + file_id, length, .. 593 + } => { 594 + assert_eq!(file_id.raw(), 0); 595 + assert_eq!(length.raw(), 50 + i as u32); 596 + } 597 + other => panic!("expected Valid at {i}, got {other:?}"), 598 + }); 599 + } 600 + 601 + #[test] 602 + fn hint_writer_resume_continues_at_position() { 603 + let (sim, fd) = setup(); 604 + let mut writer = HintFileWriter::new(&sim, fd); 605 + writer 606 + .append_hint( 607 + &test_cid(0), 608 + DataFileId::new(0), 609 + BlockOffset::new(0), 610 + BlockLength::new(100), 611 + ) 612 + .unwrap(); 613 + 614 + let pos = writer.position(); 615 + let mut writer2 = HintFileWriter::resume(&sim, fd, pos); 616 + writer2 617 + .append_hint( 618 + &test_cid(1), 619 + DataFileId::new(0), 620 + BlockOffset::new(100), 621 + BlockLength::new(200), 622 + ) 623 + .unwrap(); 624 + 625 + let reader = HintFileReader::open(&sim, fd).unwrap(); 626 + let valid_count = reader 627 + .filter_map(|r| match r.ok()? { 628 + ReadHintRecord::Valid { .. } => Some(()), 629 + _ => None, 630 + }) 631 + .count(); 632 + assert_eq!(valid_count, 2); 633 + } 634 + 635 + #[test] 636 + fn hint_reader_empty_file() { 637 + let (sim, fd) = setup(); 638 + let reader = HintFileReader::open(&sim, fd).unwrap(); 639 + assert_eq!(reader.count(), 0); 640 + } 641 + 642 + #[test] 643 + fn hint_reader_stops_on_truncated() { 644 + let (sim, fd) = setup(); 645 + let mut writer = HintFileWriter::new(&sim, fd); 646 + writer 647 + .append_hint( 648 + &test_cid(0), 649 + DataFileId::new(0), 650 + BlockOffset::new(0), 651 + BlockLength::new(100), 652 + ) 653 + .unwrap(); 654 + 655 + sim.write_all_at(fd, writer.position().raw(), &[0u8; HINT_RECORD_SIZE - 1]) 656 + .unwrap(); 657 + 658 + let reader = HintFileReader::open(&sim, fd).unwrap(); 659 + let records: Vec<_> = reader.map(|r| r.unwrap()).collect(); 660 + assert_eq!(records.len(), 2); 661 + assert!(matches!(records[0], ReadHintRecord::Valid { .. })); 662 + assert!(matches!(records[1], ReadHintRecord::Truncated)); 663 + } 664 + 665 + #[test] 666 + fn hint_reader_stops_on_corrupted() { 667 + let (sim, fd) = setup(); 668 + let mut writer = HintFileWriter::new(&sim, fd); 669 + 670 + (0u8..3).for_each(|i| { 671 + writer 672 + .append_hint( 673 + &test_cid(i), 674 + DataFileId::new(0), 675 + BlockOffset::new(i as u64 * 100), 676 + BlockLength::new(50), 677 + ) 678 + .unwrap(); 679 + }); 680 + 681 + sim.write_all_at(fd, HINT_RECORD_SIZE as u64 + 5, &[0xFF]) 682 + .unwrap(); 683 + 684 + let reader = HintFileReader::open(&sim, fd).unwrap(); 685 + let records: Vec<_> = reader.map(|r| r.unwrap()).collect(); 686 + assert_eq!(records.len(), 2); 687 + assert!(matches!(records[0], ReadHintRecord::Valid { .. })); 688 + assert!(matches!(records[1], ReadHintRecord::Corrupted)); 689 + } 690 + 691 + fn setup_data_dir(sim: &SimulatedIO) -> &'static Path { 692 + let dir = Path::new("/data"); 693 + sim.mkdir(dir).unwrap(); 694 + sim.sync_dir(dir).unwrap(); 695 + dir 696 + } 697 + 698 + fn write_test_blocks( 699 + sim: &SimulatedIO, 700 + dir: &Path, 701 + file_id: DataFileId, 702 + count: u8, 703 + ) -> (Vec<BlockLocation>, BlockOffset) { 704 + let data_path = dir.join(format!("{file_id}.tqb")); 705 + let data_fd = sim.open(&data_path, OpenOptions::read_write()).unwrap(); 706 + let mut data_writer = DataFileWriter::new(sim, data_fd, file_id).unwrap(); 707 + 708 + let hint_fd = sim 709 + .open(&hint_file_path(dir, file_id), OpenOptions::read_write()) 710 + .unwrap(); 711 + let mut hint_writer = HintFileWriter::new(sim, hint_fd); 712 + 713 + let locations: Vec<BlockLocation> = (0..count) 714 + .map(|i| { 715 + let cid = test_cid(i); 716 + let data = vec![i; (i as usize + 1) * 10]; 717 + let loc = data_writer.append_block(&cid, &data).unwrap(); 718 + hint_writer 719 + .append_hint(&cid, loc.file_id, loc.offset, loc.length) 720 + .unwrap(); 721 + loc 722 + }) 723 + .collect(); 724 + 725 + data_writer.sync().unwrap(); 726 + hint_writer.sync().unwrap(); 727 + sim.sync_dir(dir).unwrap(); 728 + 729 + let final_pos = data_writer.position(); 730 + (locations, final_pos) 731 + } 732 + 733 + fn write_test_blocks_no_hints( 734 + sim: &SimulatedIO, 735 + dir: &Path, 736 + file_id: DataFileId, 737 + count: u8, 738 + ) -> (Vec<BlockLocation>, BlockOffset) { 739 + let data_path = dir.join(format!("{file_id}.tqb")); 740 + let data_fd = sim.open(&data_path, OpenOptions::read_write()).unwrap(); 741 + let mut data_writer = DataFileWriter::new(sim, data_fd, file_id).unwrap(); 742 + 743 + let locations: Vec<BlockLocation> = (0..count) 744 + .map(|i| { 745 + let cid = test_cid(i); 746 + let data = vec![i; (i as usize + 1) * 10]; 747 + data_writer.append_block(&cid, &data).unwrap() 748 + }) 749 + .collect(); 750 + 751 + data_writer.sync().unwrap(); 752 + sim.sync_dir(dir).unwrap(); 753 + 754 + let final_pos = data_writer.position(); 755 + (locations, final_pos) 756 + } 757 + 758 + #[test] 759 + fn rebuild_from_hints_restores_index() { 760 + let sim = SimulatedIO::pristine(42); 761 + let dir = setup_data_dir(&sim); 762 + let block_count = 10u8; 763 + let (locations, final_pos) = write_test_blocks(&sim, dir, DataFileId::new(0), block_count); 764 + 765 + let index_dir = tempfile::TempDir::new().unwrap(); 766 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 767 + 768 + rebuild_index_from_hints(&sim, dir, &index).unwrap(); 769 + 770 + (0..block_count).for_each(|i| { 771 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 772 + assert_eq!(entry.location, locations[i as usize]); 773 + assert_eq!(entry.refcount, RefCount::one()); 774 + }); 775 + 776 + let cursor = index.read_write_cursor().unwrap().unwrap(); 777 + assert_eq!(cursor.file_id, DataFileId::new(0)); 778 + assert_eq!(cursor.offset, final_pos); 779 + } 780 + 781 + #[test] 782 + fn rebuild_from_data_files_restores_index() { 783 + let sim = SimulatedIO::pristine(42); 784 + let dir = setup_data_dir(&sim); 785 + let block_count = 10u8; 786 + let (locations, final_pos) = 787 + write_test_blocks_no_hints(&sim, dir, DataFileId::new(0), block_count); 788 + 789 + let index_dir = tempfile::TempDir::new().unwrap(); 790 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 791 + 792 + rebuild_index_from_data_files(&sim, dir, &index).unwrap(); 793 + 794 + (0..block_count).for_each(|i| { 795 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 796 + assert_eq!(entry.location, locations[i as usize]); 797 + assert_eq!(entry.refcount, RefCount::one()); 798 + }); 799 + 800 + let cursor = index.read_write_cursor().unwrap().unwrap(); 801 + assert_eq!(cursor.file_id, DataFileId::new(0)); 802 + assert_eq!(cursor.offset, final_pos); 803 + } 804 + 805 + #[test] 806 + fn rebuild_from_hints_handles_empty_dir() { 807 + let sim = SimulatedIO::pristine(42); 808 + let dir = setup_data_dir(&sim); 809 + 810 + let index_dir = tempfile::TempDir::new().unwrap(); 811 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 812 + 813 + rebuild_index_from_hints(&sim, dir, &index).unwrap(); 814 + assert!(index.read_write_cursor().unwrap().is_none()); 815 + } 816 + 817 + #[test] 818 + fn rebuild_from_data_files_handles_empty_dir() { 819 + let sim = SimulatedIO::pristine(42); 820 + let dir = setup_data_dir(&sim); 821 + 822 + let index_dir = tempfile::TempDir::new().unwrap(); 823 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 824 + 825 + rebuild_index_from_data_files(&sim, dir, &index).unwrap(); 826 + assert!(index.read_write_cursor().unwrap().is_none()); 827 + } 828 + 829 + #[test] 830 + fn rebuild_from_hints_handles_duplicate_cids() { 831 + let sim = SimulatedIO::pristine(42); 832 + let dir = setup_data_dir(&sim); 833 + 834 + let data_fd = sim 835 + .open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) 836 + .unwrap(); 837 + let mut data_writer = DataFileWriter::new(&sim, data_fd, DataFileId::new(0)).unwrap(); 838 + 839 + let hint_fd = sim 840 + .open( 841 + &hint_file_path(dir, DataFileId::new(0)), 842 + OpenOptions::read_write(), 843 + ) 844 + .unwrap(); 845 + let mut hint_writer = HintFileWriter::new(&sim, hint_fd); 846 + 847 + let cid = test_cid(1); 848 + let data = vec![0xAA; 64]; 849 + 850 + let loc1 = data_writer.append_block(&cid, &data).unwrap(); 851 + hint_writer 852 + .append_hint(&cid, loc1.file_id, loc1.offset, loc1.length) 853 + .unwrap(); 854 + 855 + let loc2 = data_writer.append_block(&cid, &data).unwrap(); 856 + hint_writer 857 + .append_hint(&cid, loc2.file_id, loc2.offset, loc2.length) 858 + .unwrap(); 859 + 860 + data_writer.sync().unwrap(); 861 + hint_writer.sync().unwrap(); 862 + sim.sync_dir(dir).unwrap(); 863 + 864 + let index_dir = tempfile::TempDir::new().unwrap(); 865 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 866 + 867 + rebuild_index_from_hints(&sim, dir, &index).unwrap(); 868 + 869 + let entry = index.get(&cid).unwrap().unwrap(); 870 + assert_eq!(entry.refcount, RefCount::new(2)); 871 + assert_eq!(entry.location, loc1); 872 + } 873 + 874 + #[test] 875 + fn sim_hints_survive_crash_and_enable_rebuild() { 876 + let sim = SimulatedIO::pristine(42); 877 + let dir = setup_data_dir(&sim); 878 + let block_count = 15u8; 879 + let (locations, _) = write_test_blocks(&sim, dir, DataFileId::new(0), block_count); 880 + 881 + sim.crash(); 882 + 883 + let hint_fd = sim 884 + .open( 885 + &hint_file_path(dir, DataFileId::new(0)), 886 + OpenOptions::read_only_existing(), 887 + ) 888 + .unwrap(); 889 + let hint_size = sim.file_size(hint_fd).unwrap(); 890 + assert_eq!(hint_size, block_count as u64 * HINT_RECORD_SIZE as u64); 891 + let _ = sim.close(hint_fd); 892 + 893 + let index_dir = tempfile::TempDir::new().unwrap(); 894 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 895 + 896 + rebuild_index_from_hints(&sim, dir, &index).unwrap(); 897 + 898 + let data_fd = sim 899 + .open( 900 + Path::new("/data/000000.tqb"), 901 + OpenOptions::read_only_existing(), 902 + ) 903 + .unwrap(); 904 + let data_size = sim.file_size(data_fd).unwrap(); 905 + 906 + (0..block_count).for_each(|i| { 907 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 908 + assert_eq!(entry.location, locations[i as usize]); 909 + 910 + let record = decode_block_record(&sim, data_fd, entry.location.offset, data_size) 911 + .unwrap() 912 + .unwrap(); 913 + match record { 914 + ReadBlockRecord::Valid { 915 + cid_bytes, data, .. 916 + } => { 917 + assert_eq!(cid_bytes, test_cid(i)); 918 + assert_eq!(data, vec![i; (i as usize + 1) * 10]); 919 + } 920 + other => panic!("expected Valid for block {i}, got {other:?}"), 921 + } 922 + }); 923 + } 924 + 925 + #[test] 926 + fn sim_rebuild_from_data_files_without_hints() { 927 + let sim = SimulatedIO::pristine(42); 928 + let dir = setup_data_dir(&sim); 929 + let block_count = 15u8; 930 + let (locations, _) = write_test_blocks_no_hints(&sim, dir, DataFileId::new(0), block_count); 931 + 932 + sim.crash(); 933 + 934 + let index_dir = tempfile::TempDir::new().unwrap(); 935 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 936 + 937 + rebuild_index_from_data_files(&sim, dir, &index).unwrap(); 938 + 939 + let data_fd = sim 940 + .open( 941 + Path::new("/data/000000.tqb"), 942 + OpenOptions::read_only_existing(), 943 + ) 944 + .unwrap(); 945 + let data_size = sim.file_size(data_fd).unwrap(); 946 + 947 + (0..block_count).for_each(|i| { 948 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 949 + assert_eq!(entry.location, locations[i as usize]); 950 + 951 + let record = decode_block_record(&sim, data_fd, entry.location.offset, data_size) 952 + .unwrap() 953 + .unwrap(); 954 + match record { 955 + ReadBlockRecord::Valid { 956 + cid_bytes, data, .. 957 + } => { 958 + assert_eq!(cid_bytes, test_cid(i)); 959 + assert_eq!(data, vec![i; (i as usize + 1) * 10]); 960 + } 961 + other => panic!("expected Valid for block {i}, got {other:?}"), 962 + } 963 + }); 964 + } 965 + 966 + #[test] 967 + fn rebuild_across_multiple_data_files() { 968 + let sim = SimulatedIO::pristine(42); 969 + let dir = setup_data_dir(&sim); 970 + 971 + let (locs0, _) = write_test_blocks(&sim, dir, DataFileId::new(0), 5); 972 + 973 + let data_fd1 = sim 974 + .open(Path::new("/data/000001.tqb"), OpenOptions::read_write()) 975 + .unwrap(); 976 + let mut data_writer1 = DataFileWriter::new(&sim, data_fd1, DataFileId::new(1)).unwrap(); 977 + let hint_fd1 = sim 978 + .open( 979 + &hint_file_path(dir, DataFileId::new(1)), 980 + OpenOptions::read_write(), 981 + ) 982 + .unwrap(); 983 + let mut hint_writer1 = HintFileWriter::new(&sim, hint_fd1); 984 + 985 + let locs1: Vec<BlockLocation> = (5u8..10) 986 + .map(|i| { 987 + let cid = test_cid(i); 988 + let data = vec![i; (i as usize + 1) * 10]; 989 + let loc = data_writer1.append_block(&cid, &data).unwrap(); 990 + hint_writer1 991 + .append_hint(&cid, loc.file_id, loc.offset, loc.length) 992 + .unwrap(); 993 + loc 994 + }) 995 + .collect(); 996 + 997 + data_writer1.sync().unwrap(); 998 + hint_writer1.sync().unwrap(); 999 + sim.sync_dir(dir).unwrap(); 1000 + 1001 + let index_dir = tempfile::TempDir::new().unwrap(); 1002 + let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); 1003 + 1004 + rebuild_index_from_hints(&sim, dir, &index).unwrap(); 1005 + 1006 + (0u8..5).for_each(|i| { 1007 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 1008 + assert_eq!(entry.location, locs0[i as usize]); 1009 + }); 1010 + (5u8..10).for_each(|i| { 1011 + let entry = index.get(&test_cid(i)).unwrap().unwrap(); 1012 + assert_eq!(entry.location, locs1[(i - 5) as usize]); 1013 + }); 1014 + 1015 + let cursor = index.read_write_cursor().unwrap().unwrap(); 1016 + assert_eq!(cursor.file_id, DataFileId::new(1)); 1017 + } 1018 + 1019 + #[test] 1020 + fn hint_file_path_format() { 1021 + let path = hint_file_path(Path::new("/data"), DataFileId::new(0)); 1022 + assert_eq!(path, Path::new("/data/000000.tqh")); 1023 + 1024 + let path = hint_file_path(Path::new("/data"), DataFileId::new(42)); 1025 + assert_eq!(path, Path::new("/data/000042.tqh")); 1026 + } 1027 + }
+539
crates/tranquil-store/src/blockstore/key_index.rs
··· 1 + use std::collections::HashMap; 2 + use std::path::Path; 3 + 4 + use fjall::{ 5 + Database, Keyspace, KeyspaceCreateOptions, PersistMode, 6 + config::{BloomConstructionPolicy, FilterPolicy, FilterPolicyEntry}, 7 + }; 8 + 9 + use super::data_file::CID_SIZE; 10 + use super::types::{BlockLocation, IndexEntry, RefCount, WriteCursor}; 11 + 12 + const WRITE_CURSOR_KEY: &[u8] = b"\x00write_cursor"; 13 + 14 + const KEYSPACE_NAME: &str = "blocks"; 15 + 16 + fn bloom_options() -> KeyspaceCreateOptions { 17 + KeyspaceCreateOptions::default().filter_policy(FilterPolicy::new([ 18 + FilterPolicyEntry::Bloom(BloomConstructionPolicy::FalsePositiveRate(0.01)), 19 + FilterPolicyEntry::Bloom(BloomConstructionPolicy::FalsePositiveRate(0.01)), 20 + ])) 21 + } 22 + 23 + fn is_corruption_error(e: &fjall::Error) -> bool { 24 + match e { 25 + fjall::Error::Io(io_err) => matches!( 26 + io_err.kind(), 27 + std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof 28 + ), 29 + fjall::Error::Locked | fjall::Error::KeyspaceDeleted => false, 30 + _ => true, 31 + } 32 + } 33 + 34 + fn serialize_entry(entry: &IndexEntry) -> Vec<u8> { 35 + postcard::to_allocvec(entry) 36 + .expect("IndexEntry serialization is infallible for fixed-layout types") 37 + } 38 + 39 + fn deserialize_entry(bytes: &[u8]) -> Result<IndexEntry, KeyIndexError> { 40 + postcard::from_bytes(bytes).map_err(KeyIndexError::Deserialize) 41 + } 42 + 43 + fn serialize_cursor(cursor: &WriteCursor) -> Vec<u8> { 44 + postcard::to_allocvec(cursor) 45 + .expect("WriteCursor serialization is infallible for fixed-layout types") 46 + } 47 + 48 + fn deserialize_cursor(bytes: &[u8]) -> Result<WriteCursor, KeyIndexError> { 49 + postcard::from_bytes(bytes).map_err(KeyIndexError::Deserialize) 50 + } 51 + 52 + #[derive(Debug)] 53 + pub enum KeyIndexError { 54 + Fjall(fjall::Error), 55 + Deserialize(postcard::Error), 56 + MissingEntry, 57 + } 58 + 59 + impl std::fmt::Display for KeyIndexError { 60 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 61 + match self { 62 + Self::Fjall(e) => write!(f, "fjall: {e}"), 63 + Self::Deserialize(e) => write!(f, "deserialize: {e}"), 64 + Self::MissingEntry => write!(f, "entry not found"), 65 + } 66 + } 67 + } 68 + 69 + impl std::error::Error for KeyIndexError { 70 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 71 + match self { 72 + Self::Fjall(e) => Some(e), 73 + Self::Deserialize(e) => Some(e), 74 + Self::MissingEntry => None, 75 + } 76 + } 77 + } 78 + 79 + impl From<fjall::Error> for KeyIndexError { 80 + fn from(e: fjall::Error) -> Self { 81 + Self::Fjall(e) 82 + } 83 + } 84 + 85 + pub enum KeyIndexOpenOutcome { 86 + Opened(KeyIndex), 87 + NeedsRebuild(KeyIndex), 88 + } 89 + 90 + impl KeyIndexOpenOutcome { 91 + pub fn into_inner(self) -> KeyIndex { 92 + match self { 93 + Self::Opened(idx) | Self::NeedsRebuild(idx) => idx, 94 + } 95 + } 96 + 97 + pub fn needs_rebuild(&self) -> bool { 98 + matches!(self, Self::NeedsRebuild(_)) 99 + } 100 + } 101 + 102 + pub struct KeyIndex { 103 + db: Database, 104 + blocks: Keyspace, 105 + } 106 + 107 + impl KeyIndex { 108 + pub fn open(path: &Path) -> Result<KeyIndexOpenOutcome, KeyIndexError> { 109 + match Self::try_open(path) { 110 + Ok(idx) => Ok(KeyIndexOpenOutcome::Opened(idx)), 111 + Err(KeyIndexError::Fjall(ref e)) if is_corruption_error(e) => { 112 + let _ = std::fs::remove_dir_all(path); 113 + let idx = Self::try_open(path)?; 114 + Ok(KeyIndexOpenOutcome::NeedsRebuild(idx)) 115 + } 116 + Err(e) => Err(e), 117 + } 118 + } 119 + 120 + fn try_open(path: &Path) -> Result<Self, KeyIndexError> { 121 + let db = Database::builder(path).open()?; 122 + let blocks = db.keyspace(KEYSPACE_NAME, bloom_options)?; 123 + Ok(Self { db, blocks }) 124 + } 125 + 126 + pub fn get(&self, cid_bytes: &[u8; CID_SIZE]) -> Result<Option<IndexEntry>, KeyIndexError> { 127 + self.blocks 128 + .get(cid_bytes)? 129 + .map(|v| deserialize_entry(&v)) 130 + .transpose() 131 + } 132 + 133 + pub fn has(&self, cid_bytes: &[u8; CID_SIZE]) -> Result<bool, KeyIndexError> { 134 + self.blocks.contains_key(cid_bytes).map_err(Into::into) 135 + } 136 + 137 + pub fn put( 138 + &self, 139 + cid_bytes: &[u8; CID_SIZE], 140 + location: BlockLocation, 141 + ) -> Result<(), KeyIndexError> { 142 + let entry = match self.get(cid_bytes)? { 143 + Some(existing) => IndexEntry { 144 + location: existing.location, 145 + refcount: existing.refcount.increment(), 146 + }, 147 + None => IndexEntry { 148 + location, 149 + refcount: RefCount::one(), 150 + }, 151 + }; 152 + self.blocks 153 + .insert(cid_bytes, serialize_entry(&entry)) 154 + .map_err(Into::into) 155 + } 156 + 157 + pub fn decrement_refcount( 158 + &self, 159 + cid_bytes: &[u8; CID_SIZE], 160 + ) -> Result<RefCount, KeyIndexError> { 161 + let existing = self.get(cid_bytes)?.ok_or(KeyIndexError::MissingEntry)?; 162 + let new_refcount = match existing.refcount.is_zero() { 163 + true => { 164 + tracing::warn!(?cid_bytes, "decrement on zero-refcount entry, skipping"); 165 + existing.refcount 166 + } 167 + false => existing.refcount.decrement(), 168 + }; 169 + let updated = IndexEntry { 170 + location: existing.location, 171 + refcount: new_refcount, 172 + }; 173 + self.blocks.insert(cid_bytes, serialize_entry(&updated))?; 174 + Ok(new_refcount) 175 + } 176 + 177 + pub fn batch_put( 178 + &self, 179 + entries: &[([u8; CID_SIZE], BlockLocation)], 180 + decrements: &[[u8; CID_SIZE]], 181 + cursor: WriteCursor, 182 + ) -> Result<(), KeyIndexError> { 183 + let mut batch = self.db.batch().durability(Some(PersistMode::SyncData)); 184 + let mut pending: HashMap<[u8; CID_SIZE], IndexEntry> = HashMap::new(); 185 + 186 + entries.iter().try_for_each(|(cid_bytes, location)| { 187 + let entry = match pending.get(cid_bytes).copied().or(self.get(cid_bytes)?) { 188 + Some(existing) => IndexEntry { 189 + location: existing.location, 190 + refcount: existing.refcount.increment(), 191 + }, 192 + None => IndexEntry { 193 + location: *location, 194 + refcount: RefCount::one(), 195 + }, 196 + }; 197 + pending.insert(*cid_bytes, entry); 198 + batch.insert(&self.blocks, cid_bytes.as_slice(), serialize_entry(&entry)); 199 + Ok::<_, KeyIndexError>(()) 200 + })?; 201 + 202 + decrements.iter().try_for_each(|cid_bytes| { 203 + let existing = pending 204 + .get(cid_bytes) 205 + .copied() 206 + .or(self.get(cid_bytes)?) 207 + .ok_or(KeyIndexError::MissingEntry)?; 208 + let new_refcount = match existing.refcount.is_zero() { 209 + true => { 210 + tracing::warn!(?cid_bytes, "decrement on zero-refcount entry, skipping"); 211 + existing.refcount 212 + } 213 + false => existing.refcount.decrement(), 214 + }; 215 + let updated = IndexEntry { 216 + location: existing.location, 217 + refcount: new_refcount, 218 + }; 219 + pending.insert(*cid_bytes, updated); 220 + batch.insert( 221 + &self.blocks, 222 + cid_bytes.as_slice(), 223 + serialize_entry(&updated), 224 + ); 225 + Ok::<_, KeyIndexError>(()) 226 + })?; 227 + 228 + batch.insert(&self.blocks, WRITE_CURSOR_KEY, serialize_cursor(&cursor)); 229 + 230 + batch.commit().map_err(Into::into) 231 + } 232 + 233 + pub fn read_write_cursor(&self) -> Result<Option<WriteCursor>, KeyIndexError> { 234 + self.blocks 235 + .get(WRITE_CURSOR_KEY)? 236 + .map(|v| deserialize_cursor(&v)) 237 + .transpose() 238 + } 239 + 240 + pub fn persist(&self) -> Result<(), KeyIndexError> { 241 + self.db.persist(PersistMode::SyncData).map_err(Into::into) 242 + } 243 + } 244 + 245 + #[cfg(test)] 246 + mod tests { 247 + use super::*; 248 + use crate::blockstore::test_cid; 249 + use crate::blockstore::types::{BlockLength, BlockOffset, DataFileId}; 250 + 251 + fn test_location(file_id: u32, offset: u64, length: u32) -> BlockLocation { 252 + BlockLocation { 253 + file_id: DataFileId::new(file_id), 254 + offset: BlockOffset::new(offset), 255 + length: BlockLength::new(length), 256 + } 257 + } 258 + 259 + fn open_temp() -> (tempfile::TempDir, KeyIndex) { 260 + let dir = tempfile::TempDir::new().unwrap(); 261 + let outcome = KeyIndex::open(dir.path()).unwrap(); 262 + assert!(!outcome.needs_rebuild()); 263 + (dir, outcome.into_inner()) 264 + } 265 + 266 + #[test] 267 + fn put_then_get_round_trips() { 268 + let (_dir, idx) = open_temp(); 269 + let cid = test_cid(1); 270 + let loc = test_location(0, 100, 256); 271 + 272 + idx.put(&cid, loc).unwrap(); 273 + let entry = idx.get(&cid).unwrap().unwrap(); 274 + assert_eq!(entry.location, loc); 275 + assert_eq!(entry.refcount, RefCount::one()); 276 + } 277 + 278 + #[test] 279 + fn get_missing_returns_none() { 280 + let (_dir, idx) = open_temp(); 281 + assert!(idx.get(&test_cid(42)).unwrap().is_none()); 282 + } 283 + 284 + #[test] 285 + fn has_missing_returns_false() { 286 + let (_dir, idx) = open_temp(); 287 + assert!(!idx.has(&test_cid(42)).unwrap()); 288 + } 289 + 290 + #[test] 291 + fn has_existing_returns_true() { 292 + let (_dir, idx) = open_temp(); 293 + let cid = test_cid(1); 294 + idx.put(&cid, test_location(0, 0, 10)).unwrap(); 295 + assert!(idx.has(&cid).unwrap()); 296 + } 297 + 298 + #[test] 299 + fn duplicate_put_increments_refcount() { 300 + let (_dir, idx) = open_temp(); 301 + let cid = test_cid(1); 302 + let loc = test_location(0, 100, 256); 303 + 304 + idx.put(&cid, loc).unwrap(); 305 + idx.put(&cid, test_location(1, 200, 512)).unwrap(); 306 + 307 + let entry = idx.get(&cid).unwrap().unwrap(); 308 + assert_eq!(entry.refcount, RefCount::new(2)); 309 + assert_eq!(entry.location, loc); 310 + } 311 + 312 + #[test] 313 + fn decrement_refcount_from_two_to_one() { 314 + let (_dir, idx) = open_temp(); 315 + let cid = test_cid(1); 316 + idx.put(&cid, test_location(0, 0, 10)).unwrap(); 317 + idx.put(&cid, test_location(0, 0, 10)).unwrap(); 318 + 319 + let rc = idx.decrement_refcount(&cid).unwrap(); 320 + assert_eq!(rc, RefCount::one()); 321 + 322 + let entry = idx.get(&cid).unwrap().unwrap(); 323 + assert_eq!(entry.refcount, RefCount::one()); 324 + } 325 + 326 + #[test] 327 + fn decrement_refcount_to_zero_keeps_entry() { 328 + let (_dir, idx) = open_temp(); 329 + let cid = test_cid(1); 330 + idx.put(&cid, test_location(0, 0, 10)).unwrap(); 331 + 332 + let rc = idx.decrement_refcount(&cid).unwrap(); 333 + assert!(rc.is_zero()); 334 + 335 + let entry = idx.get(&cid).unwrap().unwrap(); 336 + assert!(entry.refcount.is_zero()); 337 + } 338 + 339 + #[test] 340 + fn decrement_missing_entry_errors() { 341 + let (_dir, idx) = open_temp(); 342 + let result = idx.decrement_refcount(&test_cid(99)); 343 + assert!(matches!(result, Err(KeyIndexError::MissingEntry))); 344 + } 345 + 346 + #[test] 347 + fn batch_put_new_entries() { 348 + let (_dir, idx) = open_temp(); 349 + let entries: Vec<_> = (0u8..3) 350 + .map(|i| (test_cid(i), test_location(0, i as u64 * 100, 50))) 351 + .collect(); 352 + let cursor = WriteCursor { 353 + file_id: DataFileId::new(0), 354 + offset: BlockOffset::new(300), 355 + }; 356 + 357 + idx.batch_put(&entries, &[], cursor).unwrap(); 358 + 359 + entries.iter().for_each(|(cid, loc)| { 360 + let entry = idx.get(cid).unwrap().unwrap(); 361 + assert_eq!(entry.location, *loc); 362 + assert_eq!(entry.refcount, RefCount::one()); 363 + }); 364 + } 365 + 366 + #[test] 367 + fn batch_put_increments_existing() { 368 + let (_dir, idx) = open_temp(); 369 + let cid = test_cid(1); 370 + let original_loc = test_location(0, 100, 50); 371 + idx.put(&cid, original_loc).unwrap(); 372 + 373 + let entries = vec![(cid, test_location(1, 200, 60))]; 374 + let cursor = WriteCursor { 375 + file_id: DataFileId::new(1), 376 + offset: BlockOffset::new(260), 377 + }; 378 + idx.batch_put(&entries, &[], cursor).unwrap(); 379 + 380 + let entry = idx.get(&cid).unwrap().unwrap(); 381 + assert_eq!(entry.refcount, RefCount::new(2)); 382 + assert_eq!(entry.location, original_loc); 383 + } 384 + 385 + #[test] 386 + fn batch_put_with_decrements() { 387 + let (_dir, idx) = open_temp(); 388 + let cid_a = test_cid(1); 389 + let cid_b = test_cid(2); 390 + idx.put(&cid_b, test_location(0, 0, 10)).unwrap(); 391 + idx.put(&cid_b, test_location(0, 0, 10)).unwrap(); 392 + 393 + let entries = vec![(cid_a, test_location(0, 100, 50))]; 394 + let decrements = vec![cid_b]; 395 + let cursor = WriteCursor { 396 + file_id: DataFileId::new(0), 397 + offset: BlockOffset::new(150), 398 + }; 399 + idx.batch_put(&entries, &decrements, cursor).unwrap(); 400 + 401 + let a = idx.get(&cid_a).unwrap().unwrap(); 402 + assert_eq!(a.refcount, RefCount::one()); 403 + 404 + let b = idx.get(&cid_b).unwrap().unwrap(); 405 + assert_eq!(b.refcount, RefCount::one()); 406 + } 407 + 408 + #[test] 409 + fn batch_put_mixed_new_and_duplicate() { 410 + let (_dir, idx) = open_temp(); 411 + let existing_cid = test_cid(1); 412 + let existing_loc = test_location(0, 0, 10); 413 + idx.put(&existing_cid, existing_loc).unwrap(); 414 + 415 + let entries: Vec<_> = (1u8..=4) 416 + .map(|i| (test_cid(i), test_location(0, i as u64 * 100, 50))) 417 + .collect(); 418 + let cursor = WriteCursor { 419 + file_id: DataFileId::new(0), 420 + offset: BlockOffset::new(500), 421 + }; 422 + idx.batch_put(&entries, &[], cursor).unwrap(); 423 + 424 + let existing = idx.get(&existing_cid).unwrap().unwrap(); 425 + assert_eq!(existing.refcount, RefCount::new(2)); 426 + assert_eq!(existing.location, existing_loc); 427 + 428 + (2u8..=4).for_each(|i| { 429 + let entry = idx.get(&test_cid(i)).unwrap().unwrap(); 430 + assert_eq!(entry.refcount, RefCount::one()); 431 + }); 432 + } 433 + 434 + #[test] 435 + fn batch_put_duplicate_cid_in_same_batch() { 436 + let (_dir, idx) = open_temp(); 437 + let cid = test_cid(1); 438 + let loc = test_location(0, 100, 50); 439 + 440 + let entries = vec![(cid, loc), (cid, test_location(0, 200, 60))]; 441 + let cursor = WriteCursor { 442 + file_id: DataFileId::new(0), 443 + offset: BlockOffset::new(260), 444 + }; 445 + idx.batch_put(&entries, &[], cursor).unwrap(); 446 + 447 + let entry = idx.get(&cid).unwrap().unwrap(); 448 + assert_eq!(entry.refcount, RefCount::new(2)); 449 + assert_eq!(entry.location, loc); 450 + } 451 + 452 + #[test] 453 + fn batch_put_entry_then_decrement_same_cid() { 454 + let (_dir, idx) = open_temp(); 455 + let cid = test_cid(1); 456 + let loc = test_location(0, 100, 50); 457 + 458 + let entries = vec![(cid, loc)]; 459 + let decrements = vec![cid]; 460 + let cursor = WriteCursor { 461 + file_id: DataFileId::new(0), 462 + offset: BlockOffset::new(150), 463 + }; 464 + idx.batch_put(&entries, &decrements, cursor).unwrap(); 465 + 466 + let entry = idx.get(&cid).unwrap().unwrap(); 467 + assert!(entry.refcount.is_zero()); 468 + } 469 + 470 + #[test] 471 + fn write_cursor_round_trip() { 472 + let (_dir, idx) = open_temp(); 473 + assert!(idx.read_write_cursor().unwrap().is_none()); 474 + 475 + let cursor = WriteCursor { 476 + file_id: DataFileId::new(3), 477 + offset: BlockOffset::new(65536), 478 + }; 479 + let entries = vec![(test_cid(1), test_location(3, 0, 100))]; 480 + idx.batch_put(&entries, &[], cursor).unwrap(); 481 + 482 + let read_back = idx.read_write_cursor().unwrap().unwrap(); 483 + assert_eq!(read_back, cursor); 484 + } 485 + 486 + #[test] 487 + fn write_cursor_persists_across_reopen() { 488 + let dir = tempfile::TempDir::new().unwrap(); 489 + 490 + let cursor = WriteCursor { 491 + file_id: DataFileId::new(7), 492 + offset: BlockOffset::new(99999), 493 + }; 494 + 495 + { 496 + let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); 497 + let entries = vec![(test_cid(1), test_location(7, 0, 100))]; 498 + idx.batch_put(&entries, &[], cursor).unwrap(); 499 + idx.persist().unwrap(); 500 + } 501 + 502 + { 503 + let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); 504 + let read_back = idx.read_write_cursor().unwrap().unwrap(); 505 + assert_eq!(read_back, cursor); 506 + 507 + let entry = idx.get(&test_cid(1)).unwrap().unwrap(); 508 + assert_eq!(entry.refcount, RefCount::one()); 509 + } 510 + } 511 + 512 + #[test] 513 + fn corrupt_index_triggers_needs_rebuild() { 514 + let dir = tempfile::TempDir::new().unwrap(); 515 + 516 + { 517 + let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); 518 + idx.put(&test_cid(1), test_location(0, 0, 10)).unwrap(); 519 + idx.persist().unwrap(); 520 + } 521 + 522 + std::fs::read_dir(dir.path()) 523 + .unwrap() 524 + .filter_map(|e| e.ok()) 525 + .for_each(|entry| { 526 + let path = entry.path(); 527 + if path.is_file() { 528 + std::fs::write(&path, b"corrupted").unwrap(); 529 + } 530 + }); 531 + 532 + let outcome = KeyIndex::open(dir.path()).unwrap(); 533 + assert!(outcome.needs_rebuild()); 534 + 535 + let idx = outcome.into_inner(); 536 + assert!(idx.get(&test_cid(1)).unwrap().is_none()); 537 + assert!(idx.read_write_cursor().unwrap().is_none()); 538 + } 539 + }
+344
crates/tranquil-store/src/blockstore/manager.rs
··· 1 + use std::collections::HashMap; 2 + use std::io; 3 + use std::path::{Path, PathBuf}; 4 + 5 + use parking_lot::RwLock; 6 + 7 + use crate::io::{FileId, OpenOptions, StorageIO}; 8 + 9 + use super::list_files_by_extension; 10 + use super::types::{BlockOffset, DataFileId}; 11 + 12 + pub const DEFAULT_MAX_FILE_SIZE: u64 = 256 * 1024 * 1024; 13 + 14 + pub(crate) const DATA_FILE_EXTENSION: &str = "tqb"; 15 + 16 + struct CachedHandle { 17 + fd: FileId, 18 + writable: bool, 19 + } 20 + 21 + pub struct DataFileManager<S: StorageIO> { 22 + io: S, 23 + data_dir: PathBuf, 24 + max_file_size: u64, 25 + handles: RwLock<HashMap<DataFileId, CachedHandle>>, 26 + } 27 + 28 + impl<S: StorageIO> DataFileManager<S> { 29 + pub fn new(io: S, data_dir: PathBuf, max_file_size: u64) -> Self { 30 + Self { 31 + io, 32 + data_dir, 33 + max_file_size, 34 + handles: RwLock::new(HashMap::new()), 35 + } 36 + } 37 + 38 + pub fn with_default_max_size(io: S, data_dir: PathBuf) -> Self { 39 + Self::new(io, data_dir, DEFAULT_MAX_FILE_SIZE) 40 + } 41 + 42 + pub fn io(&self) -> &S { 43 + &self.io 44 + } 45 + 46 + pub fn data_dir(&self) -> &Path { 47 + &self.data_dir 48 + } 49 + 50 + pub fn max_file_size(&self) -> u64 { 51 + self.max_file_size 52 + } 53 + 54 + pub fn data_file_path(&self, file_id: DataFileId) -> PathBuf { 55 + self.data_dir 56 + .join(format!("{file_id}.{DATA_FILE_EXTENSION}")) 57 + } 58 + 59 + pub fn open_for_append(&self, file_id: DataFileId) -> io::Result<FileId> { 60 + { 61 + let cache = self.handles.read(); 62 + if let Some(entry) = cache.get(&file_id) 63 + && entry.writable 64 + { 65 + return Ok(entry.fd); 66 + } 67 + } 68 + let path = self.data_file_path(file_id); 69 + let fd = self.io.open(&path, OpenOptions::read_write())?; 70 + let mut cache = self.handles.write(); 71 + match cache.get(&file_id) { 72 + Some(entry) if entry.writable => { 73 + let _ = self.io.close(fd); 74 + Ok(entry.fd) 75 + } 76 + Some(entry) => { 77 + let old_fd = entry.fd; 78 + cache.insert(file_id, CachedHandle { fd, writable: true }); 79 + let _ = self.io.close(old_fd); 80 + Ok(fd) 81 + } 82 + None => { 83 + cache.insert(file_id, CachedHandle { fd, writable: true }); 84 + Ok(fd) 85 + } 86 + } 87 + } 88 + 89 + pub fn open_for_read(&self, file_id: DataFileId) -> io::Result<FileId> { 90 + if let Some(entry) = self.handles.read().get(&file_id) { 91 + return Ok(entry.fd); 92 + } 93 + let path = self.data_file_path(file_id); 94 + let fd = self.io.open(&path, OpenOptions::read_only_existing())?; 95 + let mut cache = self.handles.write(); 96 + match cache.get(&file_id) { 97 + Some(entry) => { 98 + let _ = self.io.close(fd); 99 + Ok(entry.fd) 100 + } 101 + None => { 102 + cache.insert( 103 + file_id, 104 + CachedHandle { 105 + fd, 106 + writable: false, 107 + }, 108 + ); 109 + Ok(fd) 110 + } 111 + } 112 + } 113 + 114 + pub fn prepare_rotation(&self, current: DataFileId) -> io::Result<(DataFileId, FileId)> { 115 + let next = current.next(); 116 + let path = self.data_file_path(next); 117 + let fd = self.io.open(&path, OpenOptions::read_write())?; 118 + Ok((next, fd)) 119 + } 120 + 121 + pub fn commit_rotation(&self, file_id: DataFileId, fd: FileId) { 122 + self.handles 123 + .write() 124 + .insert(file_id, CachedHandle { fd, writable: true }); 125 + } 126 + 127 + pub fn rollback_rotation(&self, file_id: DataFileId, fd: FileId) { 128 + let _ = self.io.close(fd); 129 + self.handles.write().remove(&file_id); 130 + } 131 + 132 + pub fn should_rotate(&self, position: BlockOffset) -> bool { 133 + position.raw() >= self.max_file_size 134 + } 135 + 136 + pub fn list_files(&self) -> io::Result<Vec<DataFileId>> { 137 + list_files_by_extension(&self.io, &self.data_dir, DATA_FILE_EXTENSION) 138 + } 139 + } 140 + 141 + #[cfg(test)] 142 + mod tests { 143 + use super::*; 144 + use crate::blockstore::data_file::{BLOCK_HEADER_SIZE, DataFileReader, DataFileWriter}; 145 + use crate::blockstore::test_cid; 146 + use crate::sim::SimulatedIO; 147 + 148 + fn setup_manager(max_file_size: u64) -> DataFileManager<SimulatedIO> { 149 + let sim = SimulatedIO::pristine(42); 150 + let dir = Path::new("/data"); 151 + sim.mkdir(dir).unwrap(); 152 + sim.sync_dir(dir).unwrap(); 153 + DataFileManager::new(sim, dir.to_path_buf(), max_file_size) 154 + } 155 + 156 + #[test] 157 + fn open_for_append_creates_file() { 158 + let mgr = setup_manager(1024); 159 + let fd = mgr.open_for_append(DataFileId::new(0)).unwrap(); 160 + assert_eq!(mgr.io().file_size(fd).unwrap(), 0); 161 + } 162 + 163 + #[test] 164 + fn open_for_read_missing_file_errors() { 165 + let mgr = setup_manager(1024); 166 + assert!(mgr.open_for_read(DataFileId::new(99)).is_err()); 167 + } 168 + 169 + #[test] 170 + fn handle_cache_returns_same_fd() { 171 + let mgr = setup_manager(1024); 172 + let fd1 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 173 + let fd2 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 174 + assert_eq!(fd1, fd2); 175 + } 176 + 177 + #[test] 178 + fn open_for_read_uses_cache_from_append() { 179 + let mgr = setup_manager(1024); 180 + let fd_write = mgr.open_for_append(DataFileId::new(0)).unwrap(); 181 + let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap(); 182 + assert_eq!(fd_write, fd_read); 183 + } 184 + 185 + #[test] 186 + fn rotation_lifecycle_prepare_commit() { 187 + let mgr = setup_manager(1024); 188 + let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 189 + let (next_id, next_fd) = mgr.prepare_rotation(DataFileId::new(0)).unwrap(); 190 + assert_eq!(next_id, DataFileId::new(1)); 191 + assert_eq!(mgr.io().file_size(next_fd).unwrap(), 0); 192 + mgr.io().sync_dir(mgr.data_dir()).unwrap(); 193 + mgr.commit_rotation(next_id, next_fd); 194 + assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd); 195 + } 196 + 197 + #[test] 198 + fn rotation_rollback_cleans_handle() { 199 + let mgr = setup_manager(1024); 200 + let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 201 + let (next_id, next_fd) = mgr.prepare_rotation(DataFileId::new(0)).unwrap(); 202 + mgr.commit_rotation(next_id, next_fd); 203 + 204 + assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd); 205 + mgr.rollback_rotation(next_id, next_fd); 206 + 207 + let reopened_fd = mgr.open_for_read(next_id).unwrap(); 208 + assert_ne!( 209 + reopened_fd, next_fd, 210 + "rollback should have closed the cached fd" 211 + ); 212 + } 213 + 214 + #[test] 215 + fn should_rotate_respects_threshold() { 216 + let mgr = setup_manager(1024); 217 + assert!(!mgr.should_rotate(BlockOffset::new(100))); 218 + assert!(!mgr.should_rotate(BlockOffset::new(1023))); 219 + assert!(mgr.should_rotate(BlockOffset::new(1024))); 220 + assert!(mgr.should_rotate(BlockOffset::new(2000))); 221 + } 222 + 223 + #[test] 224 + fn list_files_finds_data_files() { 225 + let mgr = setup_manager(1024); 226 + let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 227 + let _fd3 = mgr.open_for_append(DataFileId::new(3)).unwrap(); 228 + 229 + let files = mgr.list_files().unwrap(); 230 + assert_eq!(files, vec![DataFileId::new(0), DataFileId::new(3)]); 231 + } 232 + 233 + #[test] 234 + fn list_files_ignores_non_data_files() { 235 + let mgr = setup_manager(1024); 236 + let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 237 + mgr.io() 238 + .open(Path::new("/data/notes.txt"), OpenOptions::read_write()) 239 + .unwrap(); 240 + 241 + let files = mgr.list_files().unwrap(); 242 + assert_eq!(files, vec![DataFileId::new(0)]); 243 + } 244 + 245 + #[test] 246 + fn data_file_path_format() { 247 + let mgr = setup_manager(1024); 248 + assert_eq!( 249 + mgr.data_file_path(DataFileId::new(0)), 250 + Path::new("/data/000000.tqb") 251 + ); 252 + assert_eq!( 253 + mgr.data_file_path(DataFileId::new(42)), 254 + Path::new("/data/000042.tqb") 255 + ); 256 + } 257 + 258 + #[test] 259 + fn rotate_and_write_across_files() { 260 + let mgr = setup_manager(1024); 261 + let fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap(); 262 + let mut writer0 = DataFileWriter::new(mgr.io(), fd0, DataFileId::new(0)).unwrap(); 263 + let _ = writer0 264 + .append_block(&test_cid(1), b"first file data") 265 + .unwrap(); 266 + writer0.sync().unwrap(); 267 + 268 + let (id1, fd1) = mgr.prepare_rotation(DataFileId::new(0)).unwrap(); 269 + mgr.io().sync_dir(mgr.data_dir()).unwrap(); 270 + mgr.commit_rotation(id1, fd1); 271 + let mut writer1 = DataFileWriter::new(mgr.io(), fd1, id1).unwrap(); 272 + let _ = writer1 273 + .append_block(&test_cid(2), b"second file data") 274 + .unwrap(); 275 + writer1.sync().unwrap(); 276 + 277 + let fd0_read = mgr.open_for_read(DataFileId::new(0)).unwrap(); 278 + let blocks0 = DataFileReader::open(mgr.io(), fd0_read) 279 + .unwrap() 280 + .valid_blocks() 281 + .unwrap(); 282 + assert_eq!(blocks0.len(), 1); 283 + assert_eq!(blocks0[0].2, b"first file data"); 284 + 285 + let fd1_read = mgr.open_for_read(id1).unwrap(); 286 + let blocks1 = DataFileReader::open(mgr.io(), fd1_read) 287 + .unwrap() 288 + .valid_blocks() 289 + .unwrap(); 290 + assert_eq!(blocks1.len(), 1); 291 + assert_eq!(blocks1[0].2, b"second file data"); 292 + } 293 + 294 + #[test] 295 + fn read_cache_hit_from_writable_entry() { 296 + let mgr = setup_manager(1024); 297 + let fd_write = mgr.open_for_append(DataFileId::new(0)).unwrap(); 298 + DataFileWriter::new(mgr.io(), fd_write, DataFileId::new(0)).unwrap(); 299 + 300 + let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap(); 301 + assert_eq!(fd_write, fd_read); 302 + } 303 + 304 + #[test] 305 + fn read_only_cache_upgraded_on_append() { 306 + let mgr = setup_manager(1024); 307 + 308 + let raw_fd = mgr 309 + .io() 310 + .open( 311 + &mgr.data_file_path(DataFileId::new(0)), 312 + OpenOptions::read_write(), 313 + ) 314 + .unwrap(); 315 + DataFileWriter::new(mgr.io(), raw_fd, DataFileId::new(0)).unwrap(); 316 + mgr.io().sync(raw_fd).unwrap(); 317 + mgr.io().sync_dir(mgr.data_dir()).unwrap(); 318 + mgr.io().close(raw_fd).unwrap(); 319 + 320 + let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap(); 321 + let _reader = DataFileReader::open(mgr.io(), fd_read).unwrap(); 322 + 323 + let fd_append = mgr.open_for_append(DataFileId::new(0)).unwrap(); 324 + assert_ne!(fd_read, fd_append); 325 + 326 + let mut writer = DataFileWriter::resume( 327 + mgr.io(), 328 + fd_append, 329 + DataFileId::new(0), 330 + BlockOffset::new(BLOCK_HEADER_SIZE as u64), 331 + ); 332 + let _ = writer 333 + .append_block(&test_cid(1), b"written after upgrade") 334 + .unwrap(); 335 + writer.sync().unwrap(); 336 + 337 + let blocks = DataFileReader::open(mgr.io(), fd_append) 338 + .unwrap() 339 + .valid_blocks() 340 + .unwrap(); 341 + assert_eq!(blocks.len(), 1); 342 + assert_eq!(blocks[0].2, b"written after upgrade"); 343 + } 344 + }
+68
crates/tranquil-store/src/blockstore/mod.rs
··· 1 + mod data_file; 2 + mod group_commit; 3 + mod hint; 4 + mod key_index; 5 + mod manager; 6 + mod reader; 7 + mod store; 8 + mod types; 9 + 10 + pub use data_file::{ 11 + BLOCK_FORMAT_VERSION, BLOCK_HEADER_SIZE, BLOCK_MAGIC, BLOCK_RECORD_OVERHEAD, CID_SIZE, 12 + DataFileReader, DataFileWriter, ReadBlockRecord, ValidBlock, decode_block_record, 13 + encode_block_record, 14 + }; 15 + pub use group_commit::{CommitError, CommitRequest, GroupCommitConfig, GroupCommitWriter}; 16 + pub use hint::{ 17 + HINT_FILE_EXTENSION, HINT_RECORD_SIZE, HintFileReader, HintFileWriter, ReadHintRecord, 18 + RebuildError, decode_hint_record, hint_file_path, rebuild_index_from_data_files, 19 + rebuild_index_from_hints, 20 + }; 21 + pub use key_index::{KeyIndex, KeyIndexError, KeyIndexOpenOutcome}; 22 + pub use manager::{DEFAULT_MAX_FILE_SIZE, DataFileManager}; 23 + pub use reader::{BlockStoreReader, ReadError}; 24 + pub use store::{BlockStoreConfig, TranquilBlockStore}; 25 + pub use types::{ 26 + BlockLength, BlockLocation, BlockOffset, DataFileId, HintOffset, IndexEntry, MAX_BLOCK_SIZE, 27 + RefCount, WriteCursor, 28 + }; 29 + 30 + use std::io; 31 + use std::path::Path; 32 + 33 + use crate::io::StorageIO; 34 + 35 + pub(crate) fn list_files_by_extension<S: StorageIO>( 36 + io: &S, 37 + dir: &Path, 38 + extension: &str, 39 + ) -> io::Result<Vec<DataFileId>> { 40 + let entries = io.list_dir(dir)?; 41 + let mut ids: Vec<DataFileId> = entries 42 + .iter() 43 + .filter_map(|path| { 44 + let stem = path.file_stem()?.to_str()?; 45 + let ext = path.extension()?.to_str()?; 46 + (ext == extension).then(|| stem.parse::<u32>().ok().map(DataFileId::new))? 47 + }) 48 + .collect(); 49 + ids.sort(); 50 + Ok(ids) 51 + } 52 + 53 + #[cfg(test)] 54 + pub(crate) fn test_cid(seed: u8) -> [u8; CID_SIZE] { 55 + test_cid_u16(seed as u16) 56 + } 57 + 58 + #[cfg(test)] 59 + pub(crate) fn test_cid_u16(seed: u16) -> [u8; CID_SIZE] { 60 + let mut cid = [0u8; CID_SIZE]; 61 + cid[0] = 0x01; 62 + cid[1] = 0x71; 63 + cid[2] = 0x12; 64 + cid[3] = 0x20; 65 + cid[4..6].copy_from_slice(&seed.to_le_bytes()); 66 + (6..CID_SIZE).for_each(|i| cid[i] = (seed as u8).wrapping_add(i as u8)); 67 + cid 68 + }
+611
crates/tranquil-store/src/blockstore/reader.rs
··· 1 + use std::collections::HashMap; 2 + use std::io; 3 + use std::sync::Arc; 4 + 5 + use bytes::Bytes; 6 + 7 + use crate::io::{FileId, StorageIO}; 8 + 9 + use super::data_file::{CID_SIZE, ReadBlockRecord, decode_block_record}; 10 + use super::key_index::{KeyIndex, KeyIndexError}; 11 + use super::manager::DataFileManager; 12 + use super::types::{BlockLocation, BlockOffset, DataFileId}; 13 + 14 + #[derive(Debug, Clone)] 15 + pub enum ReadError { 16 + Io(Arc<io::Error>), 17 + Index(Arc<KeyIndexError>), 18 + Corrupted { 19 + file_id: DataFileId, 20 + offset: BlockOffset, 21 + }, 22 + } 23 + 24 + impl std::fmt::Display for ReadError { 25 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 + match self { 27 + Self::Io(e) => write!(f, "io: {e}"), 28 + Self::Index(e) => write!(f, "index: {e}"), 29 + Self::Corrupted { file_id, offset } => { 30 + write!(f, "corrupted block at {file_id}:{}", offset.raw()) 31 + } 32 + } 33 + } 34 + } 35 + 36 + impl std::error::Error for ReadError { 37 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 38 + match self { 39 + Self::Io(e) => Some(e.as_ref()), 40 + Self::Index(e) => Some(e.as_ref()), 41 + Self::Corrupted { .. } => None, 42 + } 43 + } 44 + } 45 + 46 + impl From<io::Error> for ReadError { 47 + fn from(e: io::Error) -> Self { 48 + Self::Io(Arc::new(e)) 49 + } 50 + } 51 + 52 + impl From<KeyIndexError> for ReadError { 53 + fn from(e: KeyIndexError) -> Self { 54 + Self::Index(Arc::new(e)) 55 + } 56 + } 57 + 58 + pub struct BlockStoreReader<S: StorageIO> { 59 + index: Arc<KeyIndex>, 60 + manager: Arc<DataFileManager<S>>, 61 + } 62 + 63 + impl<S: StorageIO> Clone for BlockStoreReader<S> { 64 + fn clone(&self) -> Self { 65 + Self { 66 + index: Arc::clone(&self.index), 67 + manager: Arc::clone(&self.manager), 68 + } 69 + } 70 + } 71 + 72 + impl<S: StorageIO> BlockStoreReader<S> { 73 + pub fn new(index: Arc<KeyIndex>, manager: Arc<DataFileManager<S>>) -> Self { 74 + Self { index, manager } 75 + } 76 + 77 + pub fn get(&self, cid: &[u8; CID_SIZE]) -> Result<Option<Bytes>, ReadError> { 78 + let entry = match self.index.get(cid)? { 79 + Some(e) => e, 80 + None => return Ok(None), 81 + }; 82 + self.read_block_at(entry.location).map(Some) 83 + } 84 + 85 + pub fn has(&self, cid: &[u8; CID_SIZE]) -> Result<bool, ReadError> { 86 + self.index.has(cid).map_err(ReadError::from) 87 + } 88 + 89 + pub fn get_many(&self, cids: &[[u8; CID_SIZE]]) -> Result<Vec<Option<Bytes>>, ReadError> { 90 + let mut results: Vec<Option<Bytes>> = vec![None; cids.len()]; 91 + 92 + let lookups: Vec<(usize, BlockLocation)> = cids 93 + .iter() 94 + .enumerate() 95 + .filter_map(|(i, cid)| match self.index.get(cid) { 96 + Ok(Some(entry)) => Some(Ok((i, entry.location))), 97 + Ok(None) => None, 98 + Err(e) => Some(Err(ReadError::from(e))), 99 + }) 100 + .collect::<Result<Vec<_>, _>>()?; 101 + 102 + let mut by_file: HashMap<DataFileId, Vec<(usize, BlockLocation)>> = HashMap::new(); 103 + lookups.into_iter().for_each(|(idx, loc)| { 104 + by_file.entry(loc.file_id).or_default().push((idx, loc)); 105 + }); 106 + 107 + by_file.into_iter().try_for_each(|(file_id, mut entries)| { 108 + let fd = self.manager.open_for_read(file_id)?; 109 + let file_size = self.manager.io().file_size(fd)?; 110 + 111 + entries.sort_by_key(|(_, loc)| loc.offset); 112 + 113 + entries.into_iter().try_for_each(|(orig_idx, loc)| { 114 + let data = self.decode_and_validate(fd, file_size, loc)?; 115 + results[orig_idx] = Some(data); 116 + Ok::<_, ReadError>(()) 117 + }) 118 + })?; 119 + 120 + Ok(results) 121 + } 122 + 123 + fn read_block_at(&self, location: BlockLocation) -> Result<Bytes, ReadError> { 124 + let fd = self.manager.open_for_read(location.file_id)?; 125 + let file_size = self.manager.io().file_size(fd)?; 126 + self.decode_and_validate(fd, file_size, location) 127 + } 128 + 129 + fn decode_and_validate( 130 + &self, 131 + fd: FileId, 132 + file_size: u64, 133 + location: BlockLocation, 134 + ) -> Result<Bytes, ReadError> { 135 + match decode_block_record(self.manager.io(), fd, location.offset, file_size)? { 136 + Some(ReadBlockRecord::Valid { data, .. }) 137 + if data.len() == location.length.raw() as usize => 138 + { 139 + Ok(Bytes::from(data)) 140 + } 141 + Some(ReadBlockRecord::Valid { .. }) => Err(ReadError::Corrupted { 142 + file_id: location.file_id, 143 + offset: location.offset, 144 + }), 145 + Some(ReadBlockRecord::Corrupted { offset } | ReadBlockRecord::Truncated { offset }) => { 146 + Err(ReadError::Corrupted { 147 + file_id: location.file_id, 148 + offset, 149 + }) 150 + } 151 + None => Err(ReadError::Corrupted { 152 + file_id: location.file_id, 153 + offset: location.offset, 154 + }), 155 + } 156 + } 157 + } 158 + 159 + #[cfg(test)] 160 + mod tests { 161 + use super::*; 162 + use crate::RealIO; 163 + use crate::blockstore::data_file::CID_SIZE; 164 + use crate::blockstore::group_commit::{CommitRequest, GroupCommitConfig, GroupCommitWriter}; 165 + use crate::blockstore::key_index::KeyIndex; 166 + use crate::blockstore::manager::DataFileManager; 167 + use crate::blockstore::test_cid; 168 + use futures::StreamExt; 169 + 170 + struct TestHarness { 171 + _dir: tempfile::TempDir, 172 + index: Arc<KeyIndex>, 173 + manager: Arc<DataFileManager<RealIO>>, 174 + writer: Option<GroupCommitWriter>, 175 + sender: flume::Sender<CommitRequest>, 176 + } 177 + 178 + impl TestHarness { 179 + fn new() -> Self { 180 + let dir = tempfile::TempDir::new().unwrap(); 181 + let data_dir = dir.path().join("data"); 182 + std::fs::create_dir_all(&data_dir).unwrap(); 183 + let index_dir = dir.path().join("index"); 184 + let manager = Arc::new(DataFileManager::with_default_max_size( 185 + RealIO::new(), 186 + data_dir, 187 + )); 188 + let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); 189 + let writer = GroupCommitWriter::spawn( 190 + DataFileManager::with_default_max_size(RealIO::new(), dir.path().join("data")), 191 + Arc::clone(&index), 192 + GroupCommitConfig::default(), 193 + ) 194 + .unwrap(); 195 + let sender = writer.sender().clone(); 196 + 197 + Self { 198 + _dir: dir, 199 + index, 200 + manager, 201 + writer: Some(writer), 202 + sender, 203 + } 204 + } 205 + 206 + fn reader(&self) -> BlockStoreReader<RealIO> { 207 + BlockStoreReader::new(Arc::clone(&self.index), Arc::clone(&self.manager)) 208 + } 209 + 210 + async fn put_blocks( 211 + &self, 212 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 213 + ) -> Result<Vec<super::super::types::BlockLocation>, super::super::group_commit::CommitError> 214 + { 215 + let (tx, rx) = tokio::sync::oneshot::channel(); 216 + self.sender 217 + .send_async(CommitRequest::PutBlocks { 218 + blocks, 219 + response: tx, 220 + }) 221 + .await 222 + .map_err(|_| super::super::group_commit::CommitError::ChannelClosed)?; 223 + rx.await 224 + .map_err(|_| super::super::group_commit::CommitError::ChannelClosed)? 225 + } 226 + 227 + fn shutdown(&mut self) { 228 + if let Some(w) = self.writer.take() { 229 + w.shutdown(); 230 + } 231 + } 232 + } 233 + 234 + impl Drop for TestHarness { 235 + fn drop(&mut self) { 236 + self.shutdown(); 237 + } 238 + } 239 + 240 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 241 + async fn get_existing_block() { 242 + let mut harness = TestHarness::new(); 243 + let cid = test_cid(1); 244 + let data = vec![0xAB; 256]; 245 + harness.put_blocks(vec![(cid, data.clone())]).await.unwrap(); 246 + harness.shutdown(); 247 + 248 + let reader = harness.reader(); 249 + let result = reader.get(&cid).unwrap().unwrap(); 250 + assert_eq!(&result[..], &data[..]); 251 + } 252 + 253 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 254 + async fn get_missing_block_returns_none() { 255 + let mut harness = TestHarness::new(); 256 + harness.shutdown(); 257 + 258 + let reader = harness.reader(); 259 + assert!(reader.get(&test_cid(99)).unwrap().is_none()); 260 + } 261 + 262 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 263 + async fn get_many_mixed_hits_and_misses() { 264 + let mut harness = TestHarness::new(); 265 + let blocks: Vec<_> = (0u8..5) 266 + .map(|i| (test_cid(i), vec![i; (i as usize + 1) * 32])) 267 + .collect(); 268 + harness.put_blocks(blocks.clone()).await.unwrap(); 269 + harness.shutdown(); 270 + 271 + let reader = harness.reader(); 272 + let query: Vec<[u8; CID_SIZE]> = vec![ 273 + test_cid(0), 274 + test_cid(99), 275 + test_cid(2), 276 + test_cid(100), 277 + test_cid(4), 278 + ]; 279 + let results = reader.get_many(&query).unwrap(); 280 + 281 + assert_eq!(results.len(), 5); 282 + assert_eq!(&results[0].as_ref().unwrap()[..], &blocks[0].1[..]); 283 + assert!(results[1].is_none()); 284 + assert_eq!(&results[2].as_ref().unwrap()[..], &blocks[2].1[..]); 285 + assert!(results[3].is_none()); 286 + assert_eq!(&results[4].as_ref().unwrap()[..], &blocks[4].1[..]); 287 + } 288 + 289 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 290 + async fn has_returns_true_for_existing() { 291 + let mut harness = TestHarness::new(); 292 + let cid = test_cid(1); 293 + harness 294 + .put_blocks(vec![(cid, vec![0xFF; 64])]) 295 + .await 296 + .unwrap(); 297 + harness.shutdown(); 298 + 299 + let reader = harness.reader(); 300 + assert!(reader.has(&cid).unwrap()); 301 + assert!(!reader.has(&test_cid(99)).unwrap()); 302 + } 303 + 304 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 305 + async fn checksum_mismatch_returns_error() { 306 + let mut harness = TestHarness::new(); 307 + let cid = test_cid(1); 308 + let data = vec![0xAA; 256]; 309 + harness.put_blocks(vec![(cid, data)]).await.unwrap(); 310 + harness.shutdown(); 311 + 312 + let entry = harness.index.get(&cid).unwrap().unwrap(); 313 + let loc = entry.location; 314 + let data_file_path = harness.manager.data_file_path(loc.file_id); 315 + 316 + let corrupt_offset = loc.offset.raw() + super::super::data_file::CID_SIZE as u64 + 4 + 128; 317 + let file_bytes = std::fs::read(&data_file_path).unwrap(); 318 + let mut corrupted = file_bytes; 319 + corrupted[corrupt_offset as usize] ^= 0xFF; 320 + std::fs::write(&data_file_path, &corrupted).unwrap(); 321 + 322 + let fresh_manager = Arc::new(DataFileManager::with_default_max_size( 323 + RealIO::new(), 324 + harness.manager.data_dir().to_path_buf(), 325 + )); 326 + let reader = BlockStoreReader::new(Arc::clone(&harness.index), fresh_manager); 327 + let result = reader.get(&cid); 328 + assert!( 329 + matches!(result, Err(ReadError::Corrupted { .. })), 330 + "expected Corrupted error, got {result:?}" 331 + ); 332 + } 333 + 334 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 335 + async fn spawn_blocking_does_not_block_tokio_workers() { 336 + let mut harness = TestHarness::new(); 337 + let blocks: Vec<_> = (0u8..200).map(|i| (test_cid(i), vec![i; 1024])).collect(); 338 + harness.put_blocks(blocks).await.unwrap(); 339 + harness.shutdown(); 340 + 341 + let reader = harness.reader(); 342 + let reader = Arc::new(reader); 343 + 344 + let timer_handle = tokio::spawn(futures::stream::iter(0..100).fold( 345 + std::time::Duration::ZERO, 346 + |max_drift, _| async move { 347 + let start = std::time::Instant::now(); 348 + tokio::time::sleep(std::time::Duration::from_millis(1)).await; 349 + let drift = start 350 + .elapsed() 351 + .saturating_sub(std::time::Duration::from_millis(1)); 352 + max_drift.max(drift) 353 + }, 354 + )); 355 + 356 + let read_handles: Vec<_> = (0..8) 357 + .map(|_| { 358 + let reader = Arc::clone(&reader); 359 + tokio::spawn(futures::stream::iter(0u8..200).fold( 360 + (0u64, 200u64), 361 + move |(total_us, count), i| { 362 + let reader = Arc::clone(&reader); 363 + async move { 364 + let cid = test_cid(i); 365 + let start = std::time::Instant::now(); 366 + let result = tokio::task::spawn_blocking(move || reader.get(&cid)) 367 + .await 368 + .unwrap(); 369 + let elapsed_us = start.elapsed().as_micros() as u64; 370 + assert!(result.unwrap().is_some()); 371 + (total_us.saturating_add(elapsed_us), count) 372 + } 373 + }, 374 + )) 375 + }) 376 + .collect(); 377 + 378 + let timer_drift = timer_handle.await.unwrap(); 379 + assert!( 380 + timer_drift < std::time::Duration::from_millis(5), 381 + "timer drift {timer_drift:?} exceeds 5ms, reads may be blocking tokio workers" 382 + ); 383 + 384 + let stats: Vec<(u64, u64)> = futures::future::join_all(read_handles) 385 + .await 386 + .into_iter() 387 + .map(|r| r.unwrap()) 388 + .collect(); 389 + let total_us: u64 = stats.iter().map(|(us, _)| us).sum(); 390 + let total_count: u64 = stats.iter().map(|(_, c)| c).sum(); 391 + let avg_us = total_us / total_count.max(1); 392 + eprintln!("avg read latency: {avg_us}us across {total_count} reads"); 393 + } 394 + 395 + use crate::blockstore::test_cid_u16 as stress_cid; 396 + 397 + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] 398 + async fn stress_50_writers_20_readers() { 399 + let dir = tempfile::TempDir::new().unwrap(); 400 + let data_dir = dir.path().join("data"); 401 + std::fs::create_dir_all(&data_dir).unwrap(); 402 + let index_dir = dir.path().join("index"); 403 + let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); 404 + let manager_for_writer = 405 + DataFileManager::with_default_max_size(RealIO::new(), data_dir.clone()); 406 + let writer = GroupCommitWriter::spawn( 407 + manager_for_writer, 408 + Arc::clone(&index), 409 + GroupCommitConfig::default(), 410 + ) 411 + .unwrap(); 412 + let sender = writer.sender().clone(); 413 + let manager_for_reader = Arc::new(DataFileManager::with_default_max_size( 414 + RealIO::new(), 415 + data_dir, 416 + )); 417 + let reader = BlockStoreReader::new(Arc::clone(&index), manager_for_reader); 418 + 419 + let committed = Arc::new(std::sync::Mutex::new(Vec::<(u16, Vec<u8>)>::new())); 420 + let writer_done = Arc::new(std::sync::atomic::AtomicBool::new(false)); 421 + 422 + let writer_handles: Vec<_> = (0u16..50) 423 + .map(|writer_id| { 424 + let sender = sender.clone(); 425 + let committed = Arc::clone(&committed); 426 + tokio::spawn(async move { 427 + futures::stream::iter(0u16..200) 428 + .fold((), |(), block_id| { 429 + let sender = sender.clone(); 430 + let committed = Arc::clone(&committed); 431 + async move { 432 + let seed = writer_id * 200 + block_id; 433 + let cid = stress_cid(seed); 434 + let size = ((seed as usize % 256) + 1) * 4; 435 + let data = vec![seed as u8; size]; 436 + let (tx, rx) = tokio::sync::oneshot::channel(); 437 + sender 438 + .send_async(CommitRequest::PutBlocks { 439 + blocks: vec![(cid, data.clone())], 440 + response: tx, 441 + }) 442 + .await 443 + .unwrap(); 444 + rx.await.unwrap().unwrap(); 445 + committed.lock().unwrap().push((seed, data)); 446 + } 447 + }) 448 + .await; 449 + }) 450 + }) 451 + .collect(); 452 + 453 + let reader_handles: Vec<_> = (0..20) 454 + .map(|_| { 455 + let reader = reader.clone(); 456 + let committed = Arc::clone(&committed); 457 + let done = Arc::clone(&writer_done); 458 + tokio::spawn(async move { 459 + let reads = std::sync::atomic::AtomicU64::new(0); 460 + (0..5000) 461 + .take_while(|_| { 462 + let is_done = done.load(std::sync::atomic::Ordering::Relaxed); 463 + let has_reads = reads.load(std::sync::atomic::Ordering::Relaxed) > 100; 464 + !(is_done && has_reads) 465 + }) 466 + .for_each(|_| { 467 + let snapshot = committed.lock().unwrap().clone(); 468 + if let Some((seed, expected)) = snapshot.last() { 469 + let cid = stress_cid(*seed); 470 + match reader.get(&cid) { 471 + Ok(Some(actual)) => { 472 + assert_eq!(&actual[..], &expected[..]); 473 + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); 474 + } 475 + Ok(None) => {} 476 + Err(e) => panic!("read error: {e}"), 477 + } 478 + } 479 + std::thread::yield_now(); 480 + }); 481 + reads.load(std::sync::atomic::Ordering::Relaxed) 482 + }) 483 + }) 484 + .collect(); 485 + 486 + futures::future::join_all(writer_handles) 487 + .await 488 + .into_iter() 489 + .for_each(|r| r.unwrap()); 490 + writer_done.store(true, std::sync::atomic::Ordering::Relaxed); 491 + 492 + let read_counts: Vec<u64> = futures::future::join_all(reader_handles) 493 + .await 494 + .into_iter() 495 + .map(|r| r.unwrap()) 496 + .collect(); 497 + 498 + let total_reads: u64 = read_counts.iter().sum(); 499 + eprintln!("total reader reads: {total_reads}"); 500 + assert!(total_reads > 0); 501 + 502 + writer.shutdown(); 503 + 504 + let final_committed = committed.lock().unwrap(); 505 + assert_eq!(final_committed.len(), 10_000); 506 + } 507 + 508 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 509 + async fn concurrent_read_write() { 510 + let mut harness = TestHarness::new(); 511 + let sender = harness.sender.clone(); 512 + let reader = harness.reader(); 513 + 514 + let written_cids = Arc::new(std::sync::Mutex::new(Vec::<(u8, Vec<u8>)>::new())); 515 + let writer_done = Arc::new(std::sync::atomic::AtomicBool::new(false)); 516 + 517 + let writer_handle = { 518 + let written = Arc::clone(&written_cids); 519 + tokio::spawn(async move { 520 + futures::stream::iter(0u8..50) 521 + .fold((), |(), i| { 522 + let sender = sender.clone(); 523 + let written = Arc::clone(&written); 524 + async move { 525 + let cid = test_cid(i); 526 + let data = vec![i; (i as usize + 1) * 16]; 527 + let (tx, rx) = tokio::sync::oneshot::channel(); 528 + sender 529 + .send_async(CommitRequest::PutBlocks { 530 + blocks: vec![(cid, data.clone())], 531 + response: tx, 532 + }) 533 + .await 534 + .unwrap(); 535 + rx.await.unwrap().unwrap(); 536 + written.lock().unwrap().push((i, data)); 537 + } 538 + }) 539 + .await; 540 + }) 541 + }; 542 + 543 + let reader_handles: Vec<_> = (0..4) 544 + .map(|_| { 545 + let reader = reader.clone(); 546 + let written = Arc::clone(&written_cids); 547 + let done = Arc::clone(&writer_done); 548 + tokio::spawn(async move { 549 + let reads = std::sync::atomic::AtomicU64::new(0); 550 + 551 + (0..2000) 552 + .take_while(|_| { 553 + let is_done = done.load(std::sync::atomic::Ordering::Relaxed); 554 + let has_reads = reads.load(std::sync::atomic::Ordering::Relaxed) > 0; 555 + !(is_done && has_reads) 556 + }) 557 + .for_each(|_| { 558 + let snapshot = written.lock().unwrap().clone(); 559 + snapshot.iter().for_each(|(seed, expected_data)| { 560 + let cid = test_cid(*seed); 561 + match reader.get(&cid) { 562 + Ok(Some(actual)) => { 563 + assert_eq!( 564 + &actual[..], 565 + &expected_data[..], 566 + "data mismatch for block {seed}" 567 + ); 568 + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); 569 + } 570 + Ok(None) => {} 571 + Err(e) => panic!("read error for block {seed}: {e}"), 572 + } 573 + }); 574 + std::thread::yield_now(); 575 + }); 576 + reads.load(std::sync::atomic::Ordering::Relaxed) 577 + }) 578 + }) 579 + .collect(); 580 + 581 + writer_handle.await.unwrap(); 582 + writer_done.store(true, std::sync::atomic::Ordering::Relaxed); 583 + 584 + let read_counts: Vec<u64> = futures::future::join_all(reader_handles) 585 + .await 586 + .into_iter() 587 + .map(|r| r.unwrap()) 588 + .collect(); 589 + 590 + let total_reads: u64 = read_counts.iter().sum(); 591 + assert!( 592 + total_reads > 0, 593 + "readers should have completed at least some reads" 594 + ); 595 + 596 + let final_snapshot = written_cids.lock().unwrap().clone(); 597 + assert_eq!(final_snapshot.len(), 50); 598 + 599 + final_snapshot.iter().for_each(|(seed, expected_data)| { 600 + let cid = test_cid(*seed); 601 + let actual = reader.get(&cid).unwrap().unwrap(); 602 + assert_eq!( 603 + &actual[..], 604 + &expected_data[..], 605 + "final verification failed for block {seed}" 606 + ); 607 + }); 608 + 609 + harness.shutdown(); 610 + } 611 + }
+1185
crates/tranquil-store/src/blockstore/store.rs
··· 1 + use std::io; 2 + use std::path::{Path, PathBuf}; 3 + use std::sync::Arc; 4 + 5 + use bytes::Bytes; 6 + use cid::Cid; 7 + use jacquard_repo::error::RepoError; 8 + use jacquard_repo::repo::CommitData; 9 + use jacquard_repo::storage::BlockStore; 10 + use multihash::Multihash; 11 + use sha2::{Digest, Sha256}; 12 + 13 + use crate::io::{OpenOptions, RealIO, StorageIO}; 14 + 15 + use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, ReadBlockRecord}; 16 + use super::group_commit::{CommitError, CommitRequest, GroupCommitConfig, GroupCommitWriter}; 17 + use super::hint::{rebuild_index_from_data_files, rebuild_index_from_hints}; 18 + use super::key_index::KeyIndex; 19 + use super::manager::DataFileManager; 20 + use super::reader::{BlockStoreReader, ReadError}; 21 + use super::types::{BlockLength, BlockLocation, BlockOffset, DataFileId, WriteCursor}; 22 + 23 + const DAG_CBOR_CODEC: u64 = 0x71; 24 + const SHA2_256_CODE: u64 = 0x12; 25 + 26 + fn cid_to_bytes(cid: &Cid) -> Result<[u8; CID_SIZE], RepoError> { 27 + let raw = cid.to_bytes(); 28 + let len = raw.len(); 29 + raw.try_into().map_err(|_| { 30 + RepoError::storage(io::Error::new( 31 + io::ErrorKind::InvalidData, 32 + format!( 33 + "CID byte length {len} differs from expected {CID_SIZE}, only CIDv1 + SHA-256 is supported" 34 + ), 35 + )) 36 + }) 37 + } 38 + 39 + fn hash_and_cid(data: &[u8]) -> Result<Cid, RepoError> { 40 + let mut hasher = Sha256::new(); 41 + hasher.update(data); 42 + let hash = hasher.finalize(); 43 + let multihash = Multihash::wrap(SHA2_256_CODE, &hash).map_err(|e| { 44 + RepoError::storage(io::Error::new(io::ErrorKind::InvalidData, e.to_string())) 45 + })?; 46 + Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash)) 47 + } 48 + 49 + fn commit_error_to_repo(e: CommitError) -> RepoError { 50 + match e { 51 + CommitError::Io(io_err) => { 52 + RepoError::storage(io::Error::new(io_err.kind(), io_err.to_string())) 53 + } 54 + CommitError::Index(idx_err) => RepoError::storage(io::Error::other(idx_err.to_string())), 55 + CommitError::ChannelClosed => RepoError::storage(io::Error::new( 56 + io::ErrorKind::BrokenPipe, 57 + "blockstore commit channel closed", 58 + )), 59 + } 60 + } 61 + 62 + fn read_error_to_repo(e: ReadError) -> RepoError { 63 + match e { 64 + ReadError::Io(io_err) => { 65 + RepoError::storage(io::Error::new(io_err.kind(), io_err.to_string())) 66 + } 67 + ReadError::Index(idx_err) => RepoError::storage(io::Error::other(idx_err.to_string())), 68 + ReadError::Corrupted { file_id, offset } => RepoError::storage(io::Error::new( 69 + io::ErrorKind::InvalidData, 70 + format!("corrupted block at {file_id}:{}", offset.raw()), 71 + )), 72 + } 73 + } 74 + 75 + #[derive(Debug, Clone)] 76 + pub struct BlockStoreConfig { 77 + pub data_dir: PathBuf, 78 + pub index_dir: PathBuf, 79 + pub max_file_size: u64, 80 + pub group_commit: GroupCommitConfig, 81 + } 82 + 83 + #[derive(Clone)] 84 + pub struct TranquilBlockStore { 85 + sender: flume::Sender<CommitRequest>, 86 + reader: Arc<BlockStoreReader<RealIO>>, 87 + _writer_handle: Arc<WriterHandle>, 88 + } 89 + 90 + struct WriterHandle { 91 + writer: parking_lot::Mutex<Option<GroupCommitWriter>>, 92 + } 93 + 94 + impl Drop for WriterHandle { 95 + fn drop(&mut self) { 96 + if let Some(w) = self.writer.lock().take() { 97 + w.shutdown(); 98 + } 99 + } 100 + } 101 + 102 + impl TranquilBlockStore { 103 + pub fn open(config: BlockStoreConfig) -> Result<Self, RepoError> { 104 + if config.data_dir == config.index_dir { 105 + return Err(RepoError::storage(io::Error::new( 106 + io::ErrorKind::InvalidInput, 107 + "data_dir and index_dir must be different directories", 108 + ))); 109 + } 110 + std::fs::create_dir_all(&config.data_dir).map_err(RepoError::storage)?; 111 + std::fs::create_dir_all(&config.index_dir).map_err(RepoError::storage)?; 112 + 113 + let io = RealIO::new(); 114 + let outcome = KeyIndex::open(&config.index_dir) 115 + .map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?; 116 + 117 + let needs_full_rebuild = outcome.needs_rebuild(); 118 + let index = Arc::new(outcome.into_inner()); 119 + 120 + if needs_full_rebuild { 121 + tracing::warn!("fjall index corrupt or missing, rebuilding from hints/data files"); 122 + Self::rebuild_index(&io, &config.data_dir, &index)?; 123 + } else { 124 + Self::recover_from_cursor(&io, &config.data_dir, &index)?; 125 + } 126 + 127 + let manager_for_writer = 128 + DataFileManager::new(RealIO::new(), config.data_dir.clone(), config.max_file_size); 129 + let writer = 130 + GroupCommitWriter::spawn(manager_for_writer, Arc::clone(&index), config.group_commit) 131 + .map_err(commit_error_to_repo)?; 132 + let sender = writer.sender().clone(); 133 + 134 + let manager_for_reader = Arc::new(DataFileManager::new( 135 + RealIO::new(), 136 + config.data_dir, 137 + config.max_file_size, 138 + )); 139 + let reader = Arc::new(BlockStoreReader::new( 140 + Arc::clone(&index), 141 + manager_for_reader, 142 + )); 143 + 144 + Ok(Self { 145 + sender, 146 + reader, 147 + _writer_handle: Arc::new(WriterHandle { 148 + writer: parking_lot::Mutex::new(Some(writer)), 149 + }), 150 + }) 151 + } 152 + 153 + fn rebuild_index<S: StorageIO>( 154 + io: &S, 155 + data_dir: &Path, 156 + index: &KeyIndex, 157 + ) -> Result<(), RepoError> { 158 + match rebuild_index_from_hints(io, data_dir, index) { 159 + Ok(()) => { 160 + tracing::info!("index rebuilt from hint files"); 161 + Ok(()) 162 + } 163 + Err(hint_err) => { 164 + tracing::warn!( 165 + error = %hint_err, 166 + "hint-based rebuild failed, falling back to data file scan" 167 + ); 168 + rebuild_index_from_data_files(io, data_dir, index) 169 + .map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?; 170 + tracing::info!("index rebuilt from data files"); 171 + Ok(()) 172 + } 173 + } 174 + } 175 + 176 + fn recover_from_cursor<S: StorageIO>( 177 + io: &S, 178 + data_dir: &Path, 179 + index: &KeyIndex, 180 + ) -> Result<(), RepoError> { 181 + let map_idx = |e: super::key_index::KeyIndexError| { 182 + RepoError::storage(io::Error::other(e.to_string())) 183 + }; 184 + 185 + let cursor = index.read_write_cursor().map_err(map_idx)?; 186 + 187 + let all_data_files = 188 + super::list_files_by_extension(io, data_dir, super::manager::DATA_FILE_EXTENSION) 189 + .map_err(RepoError::storage)?; 190 + 191 + match cursor { 192 + None if !all_data_files.is_empty() => { 193 + tracing::warn!("no write cursor but data files exist, rebuilding index"); 194 + Self::rebuild_index(io, data_dir, index) 195 + } 196 + None => Ok(()), 197 + Some(wc) => { 198 + tracing::info!( 199 + cursor_file = %wc.file_id, 200 + cursor_offset = wc.offset.raw(), 201 + "starting recovery from write cursor" 202 + ); 203 + Self::replay_single_file(io, data_dir, index, wc.file_id, wc.offset)?; 204 + 205 + let orphan_count = all_data_files 206 + .iter() 207 + .filter(|&&fid| fid > wc.file_id) 208 + .count(); 209 + if orphan_count > 0 { 210 + tracing::info!( 211 + orphan_files = orphan_count, 212 + "scanning data files past cursor for un-indexed blocks" 213 + ); 214 + } 215 + 216 + all_data_files 217 + .iter() 218 + .copied() 219 + .filter(|&fid| fid > wc.file_id) 220 + .try_for_each(|fid| { 221 + Self::replay_single_file( 222 + io, 223 + data_dir, 224 + index, 225 + fid, 226 + BlockOffset::new(super::data_file::BLOCK_HEADER_SIZE as u64), 227 + ) 228 + }) 229 + } 230 + } 231 + } 232 + 233 + fn replay_single_file<S: StorageIO>( 234 + io: &S, 235 + data_dir: &Path, 236 + index: &KeyIndex, 237 + file_id: DataFileId, 238 + start_offset: BlockOffset, 239 + ) -> Result<(), RepoError> { 240 + let file_path = data_dir.join(format!("{file_id}.{}", super::manager::DATA_FILE_EXTENSION)); 241 + 242 + let fd = match io.open(&file_path, OpenOptions::read_write_existing()) { 243 + Ok(fd) => fd, 244 + Err(e) if e.kind() == io::ErrorKind::NotFound => { 245 + tracing::error!( 246 + file_id = %file_id, 247 + "cursor references missing data file, possible data loss, skipping replay" 248 + ); 249 + return Ok(()); 250 + } 251 + Err(e) => return Err(RepoError::storage(e)), 252 + }; 253 + 254 + let result = Self::scan_and_index(io, index, fd, file_id, start_offset); 255 + 256 + let _ = io.close(fd); 257 + 258 + result 259 + } 260 + 261 + fn scan_and_index<S: StorageIO>( 262 + io: &S, 263 + index: &KeyIndex, 264 + fd: crate::io::FileId, 265 + file_id: DataFileId, 266 + start_offset: BlockOffset, 267 + ) -> Result<(), RepoError> { 268 + let map_idx = |e: super::key_index::KeyIndexError| { 269 + RepoError::storage(io::Error::other(e.to_string())) 270 + }; 271 + 272 + let file_size = io.file_size(fd).map_err(RepoError::storage)?; 273 + 274 + if file_size <= start_offset.raw() { 275 + return Ok(()); 276 + } 277 + 278 + let scan_pos = &mut { start_offset }; 279 + let (recovered_entries, last_valid_end) = std::iter::from_fn(|| { 280 + match super::data_file::decode_block_record(io, fd, *scan_pos, file_size) { 281 + Err(e) => { 282 + tracing::warn!( 283 + file_id = %file_id, 284 + offset = scan_pos.raw(), 285 + error = %e, 286 + "IO error during recovery scan, stopping" 287 + ); 288 + None 289 + } 290 + Ok(None) => None, 291 + Ok(Some(ReadBlockRecord::Valid { 292 + offset, 293 + cid_bytes, 294 + data, 295 + })) => { 296 + let raw_len = match u32::try_from(data.len()) { 297 + Ok(n) if n <= super::types::MAX_BLOCK_SIZE => n, 298 + _ => return None, 299 + }; 300 + let length = BlockLength::new(raw_len); 301 + let record_size = BLOCK_RECORD_OVERHEAD as u64 + u64::from(raw_len); 302 + *scan_pos = scan_pos.advance(record_size); 303 + Some(( 304 + cid_bytes, 305 + BlockLocation { 306 + file_id, 307 + offset, 308 + length, 309 + }, 310 + )) 311 + } 312 + Ok(Some(ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. })) => { 313 + None 314 + } 315 + } 316 + }) 317 + .fold( 318 + (Vec::new(), start_offset), 319 + |(mut entries, _), (cid_bytes, location)| { 320 + let new_end = location 321 + .offset 322 + .advance(BLOCK_RECORD_OVERHEAD as u64 + location.length.as_u64()); 323 + entries.push((cid_bytes, location)); 324 + (entries, new_end) 325 + }, 326 + ); 327 + 328 + if file_size > last_valid_end.raw() { 329 + tracing::info!( 330 + file_id = %file_id, 331 + truncating_from = last_valid_end.raw(), 332 + file_size, 333 + "truncating partial/corrupted tail" 334 + ); 335 + io.truncate(fd, last_valid_end.raw()) 336 + .map_err(RepoError::storage)?; 337 + io.sync(fd).map_err(RepoError::storage)?; 338 + } 339 + 340 + if !recovered_entries.is_empty() { 341 + let new_cursor = WriteCursor { 342 + file_id, 343 + offset: last_valid_end, 344 + }; 345 + tracing::info!( 346 + file_id = %file_id, 347 + recovered = recovered_entries.len(), 348 + new_cursor_offset = last_valid_end.raw(), 349 + "replayed un-indexed blocks past write cursor" 350 + ); 351 + index 352 + .batch_put(&recovered_entries, &[], new_cursor) 353 + .map_err(map_idx)?; 354 + } 355 + 356 + Ok(()) 357 + } 358 + 359 + pub fn put_blocks_blocking( 360 + &self, 361 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 362 + ) -> Result<(), RepoError> { 363 + if blocks.is_empty() { 364 + return Ok(()); 365 + } 366 + let (tx, rx) = tokio::sync::oneshot::channel(); 367 + self.sender 368 + .send(CommitRequest::PutBlocks { 369 + blocks, 370 + response: tx, 371 + }) 372 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?; 373 + rx.blocking_recv() 374 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))? 375 + .map_err(commit_error_to_repo)?; 376 + Ok(()) 377 + } 378 + 379 + async fn send_put_blocks( 380 + &self, 381 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 382 + ) -> Result<Vec<BlockLocation>, RepoError> { 383 + let (tx, rx) = tokio::sync::oneshot::channel(); 384 + self.sender 385 + .send_async(CommitRequest::PutBlocks { 386 + blocks, 387 + response: tx, 388 + }) 389 + .await 390 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?; 391 + rx.await 392 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))? 393 + .map_err(commit_error_to_repo) 394 + } 395 + 396 + async fn send_apply_commit( 397 + &self, 398 + blocks: Vec<([u8; CID_SIZE], Vec<u8>)>, 399 + deleted_cids: Vec<[u8; CID_SIZE]>, 400 + ) -> Result<(), RepoError> { 401 + let (tx, rx) = tokio::sync::oneshot::channel(); 402 + self.sender 403 + .send_async(CommitRequest::ApplyCommit { 404 + blocks, 405 + deleted_cids, 406 + response: tx, 407 + }) 408 + .await 409 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?; 410 + rx.await 411 + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))? 412 + .map_err(commit_error_to_repo) 413 + } 414 + } 415 + 416 + impl BlockStore for TranquilBlockStore { 417 + async fn get(&self, cid: &Cid) -> Result<Option<Bytes>, RepoError> { 418 + let cid_bytes = cid_to_bytes(cid)?; 419 + let reader = Arc::clone(&self.reader); 420 + tokio::task::spawn_blocking(move || reader.get(&cid_bytes)) 421 + .await 422 + .map_err(RepoError::task_failed)? 423 + .map_err(read_error_to_repo) 424 + } 425 + 426 + async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> { 427 + let cid = hash_and_cid(data)?; 428 + let cid_bytes = cid_to_bytes(&cid)?; 429 + self.send_put_blocks(vec![(cid_bytes, data.to_vec())]) 430 + .await?; 431 + Ok(cid) 432 + } 433 + 434 + async fn has(&self, cid: &Cid) -> Result<bool, RepoError> { 435 + let cid_bytes = cid_to_bytes(cid)?; 436 + let reader = Arc::clone(&self.reader); 437 + tokio::task::spawn_blocking(move || reader.has(&cid_bytes)) 438 + .await 439 + .map_err(RepoError::task_failed)? 440 + .map_err(read_error_to_repo) 441 + } 442 + 443 + async fn put_many( 444 + &self, 445 + blocks: impl IntoIterator<Item = (Cid, Bytes)> + Send, 446 + ) -> Result<(), RepoError> { 447 + let entries: Vec<([u8; CID_SIZE], Vec<u8>)> = blocks 448 + .into_iter() 449 + .map(|(cid, data)| Ok((cid_to_bytes(&cid)?, data.to_vec()))) 450 + .collect::<Result<Vec<_>, RepoError>>()?; 451 + if entries.is_empty() { 452 + return Ok(()); 453 + } 454 + self.send_put_blocks(entries).await?; 455 + Ok(()) 456 + } 457 + 458 + async fn get_many(&self, cids: &[Cid]) -> Result<Vec<Option<Bytes>>, RepoError> { 459 + if cids.is_empty() { 460 + return Ok(Vec::new()); 461 + } 462 + let cid_bytes: Vec<[u8; CID_SIZE]> = cids 463 + .iter() 464 + .map(cid_to_bytes) 465 + .collect::<Result<Vec<_>, _>>()?; 466 + let reader = Arc::clone(&self.reader); 467 + tokio::task::spawn_blocking(move || reader.get_many(&cid_bytes)) 468 + .await 469 + .map_err(RepoError::task_failed)? 470 + .map_err(read_error_to_repo) 471 + } 472 + 473 + async fn apply_commit(&self, commit: CommitData) -> Result<(), RepoError> { 474 + let blocks: Vec<([u8; CID_SIZE], Vec<u8>)> = commit 475 + .blocks 476 + .into_iter() 477 + .map(|(cid, data)| Ok((cid_to_bytes(&cid)?, data.to_vec()))) 478 + .collect::<Result<Vec<_>, RepoError>>()?; 479 + let deleted_cids: Vec<[u8; CID_SIZE]> = commit 480 + .deleted_cids 481 + .iter() 482 + .map(cid_to_bytes) 483 + .collect::<Result<Vec<_>, _>>()?; 484 + self.send_apply_commit(blocks, deleted_cids).await 485 + } 486 + } 487 + 488 + #[cfg(test)] 489 + mod tests { 490 + use super::super::manager::DEFAULT_MAX_FILE_SIZE; 491 + use super::*; 492 + 493 + fn test_config(dir: &Path) -> BlockStoreConfig { 494 + BlockStoreConfig { 495 + data_dir: dir.join("data"), 496 + index_dir: dir.join("index"), 497 + max_file_size: DEFAULT_MAX_FILE_SIZE, 498 + group_commit: GroupCommitConfig::default(), 499 + } 500 + } 501 + 502 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 503 + async fn put_and_get_round_trips() { 504 + let dir = tempfile::TempDir::new().unwrap(); 505 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 506 + 507 + let data = b"hello blockstore"; 508 + let cid = store.put(data).await.unwrap(); 509 + 510 + let retrieved = store.get(&cid).await.unwrap().unwrap(); 511 + assert_eq!(&retrieved[..], data); 512 + } 513 + 514 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 515 + async fn get_missing_returns_none() { 516 + let dir = tempfile::TempDir::new().unwrap(); 517 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 518 + 519 + let fake_cid = hash_and_cid(b"nonexistent").unwrap(); 520 + assert!(store.get(&fake_cid).await.unwrap().is_none()); 521 + } 522 + 523 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 524 + async fn has_returns_correct_values() { 525 + let dir = tempfile::TempDir::new().unwrap(); 526 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 527 + 528 + let data = b"existence check"; 529 + let cid = store.put(data).await.unwrap(); 530 + 531 + assert!(store.has(&cid).await.unwrap()); 532 + 533 + let fake_cid = hash_and_cid(b"does not exist").unwrap(); 534 + assert!(!store.has(&fake_cid).await.unwrap()); 535 + } 536 + 537 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 538 + async fn put_many_and_get_many() { 539 + let dir = tempfile::TempDir::new().unwrap(); 540 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 541 + 542 + let items: Vec<(Cid, Bytes)> = (0u8..10) 543 + .map(|i| { 544 + let data = vec![i; (i as usize + 1) * 32]; 545 + let cid = hash_and_cid(&data).unwrap(); 546 + (cid, Bytes::from(data)) 547 + }) 548 + .collect(); 549 + 550 + let cids: Vec<Cid> = items.iter().map(|(c, _)| *c).collect(); 551 + let expected: Vec<Bytes> = items.iter().map(|(_, d)| d.clone()).collect(); 552 + 553 + store.put_many(items).await.unwrap(); 554 + 555 + let results = store.get_many(&cids).await.unwrap(); 556 + assert_eq!(results.len(), 10); 557 + results 558 + .iter() 559 + .zip(expected.iter()) 560 + .for_each(|(result, exp)| { 561 + assert_eq!(result.as_ref().unwrap().as_ref(), exp.as_ref()); 562 + }); 563 + } 564 + 565 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 566 + async fn get_many_with_mixed_hits_and_misses() { 567 + let dir = tempfile::TempDir::new().unwrap(); 568 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 569 + 570 + let data_a = b"block a"; 571 + let data_b = b"block b"; 572 + let cid_a = store.put(data_a).await.unwrap(); 573 + let cid_b = store.put(data_b).await.unwrap(); 574 + let cid_missing = hash_and_cid(b"missing").unwrap(); 575 + 576 + let results = store.get_many(&[cid_a, cid_missing, cid_b]).await.unwrap(); 577 + assert_eq!(results.len(), 3); 578 + assert_eq!(results[0].as_ref().unwrap().as_ref(), data_a); 579 + assert!(results[1].is_none()); 580 + assert_eq!(results[2].as_ref().unwrap().as_ref(), data_b); 581 + } 582 + 583 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 584 + async fn put_many_empty_is_noop() { 585 + let dir = tempfile::TempDir::new().unwrap(); 586 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 587 + store 588 + .put_many(std::iter::empty::<(Cid, Bytes)>()) 589 + .await 590 + .unwrap(); 591 + } 592 + 593 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 594 + async fn apply_commit_with_blocks_and_deletes() { 595 + use jacquard_common::types::integer::LimitedU32; 596 + use jacquard_common::types::string::Tid; 597 + use std::collections::BTreeMap; 598 + 599 + let dir = tempfile::TempDir::new().unwrap(); 600 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 601 + 602 + let data_keep = b"keep this block"; 603 + let data_delete = b"delete this block"; 604 + let cid_keep = store.put(data_keep).await.unwrap(); 605 + let cid_delete = store.put(data_delete).await.unwrap(); 606 + 607 + assert!(store.has(&cid_keep).await.unwrap()); 608 + assert!(store.has(&cid_delete).await.unwrap()); 609 + 610 + let new_data = b"new block from commit"; 611 + let new_cid = hash_and_cid(new_data).unwrap(); 612 + 613 + let mut blocks = BTreeMap::new(); 614 + blocks.insert(new_cid, Bytes::from(new_data.as_slice())); 615 + 616 + let commit = CommitData { 617 + cid: new_cid, 618 + rev: Tid::now(LimitedU32::MIN), 619 + since: None, 620 + prev: None, 621 + data: new_cid, 622 + prev_data: None, 623 + blocks, 624 + relevant_blocks: BTreeMap::new(), 625 + deleted_cids: vec![cid_delete], 626 + }; 627 + 628 + store.apply_commit(commit).await.unwrap(); 629 + 630 + assert!(store.has(&cid_keep).await.unwrap()); 631 + assert!(store.has(&new_cid).await.unwrap()); 632 + let new_retrieved = store.get(&new_cid).await.unwrap().unwrap(); 633 + assert_eq!(&new_retrieved[..], new_data); 634 + } 635 + 636 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 637 + async fn duplicate_put_returns_same_cid() { 638 + let dir = tempfile::TempDir::new().unwrap(); 639 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 640 + 641 + let data = b"identical content"; 642 + let cid1 = store.put(data).await.unwrap(); 643 + let cid2 = store.put(data).await.unwrap(); 644 + 645 + assert_eq!(cid1, cid2); 646 + 647 + let retrieved = store.get(&cid1).await.unwrap().unwrap(); 648 + assert_eq!(&retrieved[..], data); 649 + } 650 + 651 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 652 + async fn reopen_preserves_data() { 653 + let dir = tempfile::TempDir::new().unwrap(); 654 + let config = test_config(dir.path()); 655 + 656 + let cid = { 657 + let store = TranquilBlockStore::open(config.clone()).unwrap(); 658 + let data = b"persistent data"; 659 + let cid = store.put(data).await.unwrap(); 660 + assert!(store.has(&cid).await.unwrap()); 661 + drop(store); 662 + cid 663 + }; 664 + 665 + { 666 + let store = TranquilBlockStore::open(config).unwrap(); 667 + let retrieved = store.get(&cid).await.unwrap().unwrap(); 668 + assert_eq!(&retrieved[..], b"persistent data"); 669 + } 670 + } 671 + 672 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 673 + async fn concurrent_puts_from_multiple_tasks() { 674 + let dir = tempfile::TempDir::new().unwrap(); 675 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 676 + 677 + let handles: Vec<_> = (0u8..50) 678 + .map(|i| { 679 + let store = store.clone(); 680 + tokio::spawn(async move { 681 + let data = vec![i; (i as usize + 1) * 16]; 682 + let cid = store.put(&data).await.unwrap(); 683 + (cid, data) 684 + }) 685 + }) 686 + .collect(); 687 + 688 + let results: Vec<(Cid, Vec<u8>)> = futures::future::join_all(handles) 689 + .await 690 + .into_iter() 691 + .map(|r| r.unwrap()) 692 + .collect(); 693 + 694 + let verify_handles: Vec<_> = results 695 + .into_iter() 696 + .map(|(cid, expected)| { 697 + let store = store.clone(); 698 + tokio::spawn(async move { 699 + let retrieved = store.get(&cid).await.unwrap().unwrap(); 700 + assert_eq!(&retrieved[..], &expected[..]); 701 + }) 702 + }) 703 + .collect(); 704 + 705 + futures::future::join_all(verify_handles) 706 + .await 707 + .into_iter() 708 + .for_each(|r| r.unwrap()); 709 + } 710 + 711 + mod sim { 712 + use super::*; 713 + use crate::SimulatedIO; 714 + use crate::blockstore::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, DataFileWriter}; 715 + use crate::blockstore::hint::{HintFileWriter, hint_file_path}; 716 + use crate::blockstore::key_index::KeyIndex; 717 + use crate::blockstore::manager::DataFileManager; 718 + use crate::blockstore::reader::BlockStoreReader; 719 + use crate::blockstore::types::{BlockOffset, DataFileId, WriteCursor}; 720 + use futures::StreamExt; 721 + use std::path::Path; 722 + use std::sync::Arc; 723 + 724 + use crate::blockstore::test_cid_u16 as sim_test_cid; 725 + 726 + struct SimHarness { 727 + sim: Arc<SimulatedIO>, 728 + data_dir: &'static Path, 729 + index_dir: tempfile::TempDir, 730 + } 731 + 732 + impl SimHarness { 733 + fn new(seed: u64) -> Self { 734 + let sim = Arc::new(SimulatedIO::pristine(seed)); 735 + let data_dir = Path::new("/data"); 736 + sim.mkdir(data_dir).unwrap(); 737 + sim.sync_dir(data_dir).unwrap(); 738 + Self { 739 + sim, 740 + data_dir, 741 + index_dir: tempfile::TempDir::new().unwrap(), 742 + } 743 + } 744 + 745 + fn fresh_index_dir(&mut self) { 746 + self.index_dir = tempfile::TempDir::new().unwrap(); 747 + } 748 + 749 + fn open_index(&self) -> KeyIndex { 750 + KeyIndex::open(self.index_dir.path()).unwrap().into_inner() 751 + } 752 + 753 + fn ensure_data_file(&self, file_id: DataFileId) -> BlockOffset { 754 + let manager = DataFileManager::with_default_max_size( 755 + Arc::clone(&self.sim), 756 + self.data_dir.to_path_buf(), 757 + ); 758 + let fd = manager.open_for_append(file_id).unwrap(); 759 + let file_size = self.sim.file_size(fd).unwrap(); 760 + match file_size { 761 + 0 => { 762 + let w = DataFileWriter::new(&*self.sim, fd, file_id).unwrap(); 763 + w.sync().unwrap(); 764 + self.sim.sync_dir(self.data_dir).unwrap(); 765 + w.position() 766 + } 767 + n => BlockOffset::new(n), 768 + } 769 + } 770 + 771 + fn write_blocks( 772 + &self, 773 + file_id: DataFileId, 774 + start_pos: BlockOffset, 775 + seeds: std::ops::Range<u16>, 776 + data_size: usize, 777 + sync: bool, 778 + ) -> (BlockOffset, Vec<([u8; CID_SIZE], BlockLocation)>) { 779 + let path = self.data_dir.join(format!( 780 + "{file_id}.{}", 781 + crate::blockstore::manager::DATA_FILE_EXTENSION 782 + )); 783 + let fd = self 784 + .sim 785 + .open(&path, crate::io::OpenOptions::read_write()) 786 + .unwrap(); 787 + let mut writer = DataFileWriter::resume(&*self.sim, fd, file_id, start_pos); 788 + 789 + let hint_path = hint_file_path(self.data_dir, file_id); 790 + let hint_fd = self 791 + .sim 792 + .open(&hint_path, crate::io::OpenOptions::read_write()) 793 + .unwrap(); 794 + let hint_size = self.sim.file_size(hint_fd).unwrap(); 795 + let mut hint_writer = HintFileWriter::resume( 796 + &*self.sim, 797 + hint_fd, 798 + crate::blockstore::types::HintOffset::new(hint_size), 799 + ); 800 + 801 + let entries: Vec<_> = seeds 802 + .map(|seed| { 803 + let cid = sim_test_cid(seed); 804 + let data = vec![seed as u8; data_size]; 805 + let loc = writer.append_block(&cid, &data).unwrap(); 806 + hint_writer 807 + .append_hint(&cid, loc.file_id, loc.offset, loc.length) 808 + .unwrap(); 809 + (cid, loc) 810 + }) 811 + .collect(); 812 + 813 + if sync { 814 + writer.sync().unwrap(); 815 + hint_writer.sync().unwrap(); 816 + self.sim.sync_dir(self.data_dir).unwrap(); 817 + } 818 + 819 + let pos = writer.position(); 820 + let _ = self.sim.close(hint_fd); 821 + let _ = self.sim.close(fd); 822 + (pos, entries) 823 + } 824 + 825 + fn index_entries( 826 + &self, 827 + index: &KeyIndex, 828 + entries: &[([u8; CID_SIZE], BlockLocation)], 829 + cursor: WriteCursor, 830 + ) { 831 + index.batch_put(entries, &[], cursor).unwrap(); 832 + index.persist().unwrap(); 833 + } 834 + 835 + fn make_reader(&self, index: Arc<KeyIndex>) -> BlockStoreReader<Arc<SimulatedIO>> { 836 + let manager = Arc::new(DataFileManager::with_default_max_size( 837 + Arc::clone(&self.sim), 838 + self.data_dir.to_path_buf(), 839 + )); 840 + BlockStoreReader::new(index, manager) 841 + } 842 + 843 + fn recover(&self, index: &KeyIndex) { 844 + TranquilBlockStore::recover_from_cursor(&*self.sim, self.data_dir, index).unwrap(); 845 + } 846 + 847 + fn rebuild(&self, index: &KeyIndex) { 848 + TranquilBlockStore::rebuild_index(&*self.sim, self.data_dir, index).unwrap(); 849 + } 850 + } 851 + 852 + #[test] 853 + fn sim_crash_and_recover_blocks() { 854 + (0u64..200).for_each(|seed| { 855 + let h = SimHarness::new(seed); 856 + let file_id = DataFileId::new(0); 857 + 858 + let total_blocks = ((seed % 47) + 10) as u16; 859 + let indexed_count = ((seed % total_blocks as u64) + 1) as u16; 860 + let unsynced_start = total_blocks; 861 + let unsynced_count = ((seed % 5) + 1) as u16; 862 + 863 + let start_pos = h.ensure_data_file(file_id); 864 + let (synced_end, entries) = 865 + h.write_blocks(file_id, start_pos, 0..total_blocks, 64, true); 866 + 867 + let index = h.open_index(); 868 + let indexed = &entries[..indexed_count as usize]; 869 + let cursor_end = indexed 870 + .last() 871 + .map(|(_, loc)| { 872 + loc.offset 873 + .advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64()) 874 + }) 875 + .unwrap_or(start_pos); 876 + h.index_entries( 877 + &index, 878 + indexed, 879 + WriteCursor { 880 + file_id, 881 + offset: cursor_end, 882 + }, 883 + ); 884 + index.persist().unwrap(); 885 + drop(index); 886 + 887 + let _ = h.write_blocks( 888 + file_id, 889 + synced_end, 890 + unsynced_start..unsynced_start + unsynced_count, 891 + 64, 892 + false, 893 + ); 894 + 895 + h.sim.crash(); 896 + 897 + let recovered_index = h.open_index(); 898 + h.recover(&recovered_index); 899 + 900 + let idx = Arc::new(recovered_index); 901 + let reader = h.make_reader(Arc::clone(&idx)); 902 + 903 + (0..total_blocks).for_each(|i| { 904 + let cid = sim_test_cid(i); 905 + let entry = idx.get(&cid).unwrap(); 906 + assert!( 907 + entry.is_some(), 908 + "seed={seed} synced block {i}/{total_blocks} missing, indexed={indexed_count}" 909 + ); 910 + match reader.get(&cid) { 911 + Ok(Some(actual)) => { 912 + assert_eq!( 913 + actual.len(), 914 + 64, 915 + "seed={seed} block {i} wrong length" 916 + ); 917 + assert_eq!( 918 + actual[0], 919 + i as u8, 920 + "seed={seed} block {i} data mismatch" 921 + ); 922 + } 923 + other => panic!( 924 + "seed={seed} block {i} expected readable, got {other:?}" 925 + ), 926 + } 927 + }); 928 + 929 + (unsynced_start..unsynced_start + unsynced_count).for_each(|i| { 930 + let cid = sim_test_cid(i); 931 + assert!( 932 + idx.get(&cid).unwrap().is_none(), 933 + "seed={seed} unsynced block {i} should not appear in index" 934 + ); 935 + }); 936 + }); 937 + } 938 + 939 + #[test] 940 + fn sim_refcounts_and_deletes() { 941 + (0u64..100).for_each(|seed| { 942 + let h = SimHarness::new(seed); 943 + let file_id = DataFileId::new(0); 944 + let start_pos = h.ensure_data_file(file_id); 945 + 946 + let dup_count = (seed % 5) as u32 + 2; 947 + let data_size = ((seed % 7) as usize + 1) * 32; 948 + 949 + let dup_cid = sim_test_cid(0); 950 + let dup_data = vec![0u8; data_size]; 951 + let unique_cid = sim_test_cid(1); 952 + let unique_data = vec![1u8; data_size]; 953 + 954 + let path = h.data_dir.join(format!( 955 + "{file_id}.{}", 956 + crate::blockstore::manager::DATA_FILE_EXTENSION 957 + )); 958 + let fd = h 959 + .sim 960 + .open(&path, crate::io::OpenOptions::read_write()) 961 + .unwrap(); 962 + let mut writer = DataFileWriter::resume(&*h.sim, fd, file_id, start_pos); 963 + 964 + let loc_dup = writer.append_block(&dup_cid, &dup_data).unwrap(); 965 + let loc_unique = writer.append_block(&unique_cid, &unique_data).unwrap(); 966 + writer.sync().unwrap(); 967 + h.sim.sync_dir(h.data_dir).unwrap(); 968 + let end_pos = writer.position(); 969 + let _ = h.sim.close(fd); 970 + 971 + let index = h.open_index(); 972 + 973 + let mut entries: Vec<_> = (0..dup_count).map(|_| (dup_cid, loc_dup)).collect(); 974 + entries.push((unique_cid, loc_unique)); 975 + h.index_entries( 976 + &index, 977 + &entries, 978 + WriteCursor { 979 + file_id, 980 + offset: end_pos, 981 + }, 982 + ); 983 + 984 + let dup_entry = index.get(&dup_cid).unwrap().unwrap(); 985 + assert_eq!( 986 + dup_entry.refcount.raw(), 987 + dup_count, 988 + "seed={seed} expected refcount {dup_count}" 989 + ); 990 + let unique_entry = index.get(&unique_cid).unwrap().unwrap(); 991 + assert_eq!(unique_entry.refcount.raw(), 1); 992 + 993 + let dec_count = (seed % dup_count as u64) as u32 + 1; 994 + let decrements: Vec<_> = (0..dec_count).map(|_| dup_cid).collect(); 995 + index 996 + .batch_put( 997 + &[], 998 + &decrements, 999 + WriteCursor { 1000 + file_id, 1001 + offset: end_pos, 1002 + }, 1003 + ) 1004 + .unwrap(); 1005 + 1006 + let dup_after = index.get(&dup_cid).unwrap().unwrap(); 1007 + assert_eq!( 1008 + dup_after.refcount.raw(), 1009 + dup_count - dec_count, 1010 + "seed={seed} refcount after {dec_count} decrements" 1011 + ); 1012 + 1013 + let idx = Arc::new(index); 1014 + let reader = h.make_reader(Arc::clone(&idx)); 1015 + 1016 + let dup_read = reader.get(&dup_cid).unwrap().unwrap(); 1017 + assert_eq!(&dup_read[..], &dup_data[..], "seed={seed}"); 1018 + let unique_read = reader.get(&unique_cid).unwrap().unwrap(); 1019 + assert_eq!(&unique_read[..], &unique_data[..], "seed={seed}"); 1020 + 1021 + drop(reader); 1022 + let index = Arc::into_inner(idx).unwrap(); 1023 + 1024 + let remaining = dup_count - dec_count; 1025 + let final_decrements: Vec<_> = (0..remaining).map(|_| dup_cid).collect(); 1026 + index 1027 + .batch_put( 1028 + &[], 1029 + &final_decrements, 1030 + WriteCursor { 1031 + file_id, 1032 + offset: end_pos, 1033 + }, 1034 + ) 1035 + .unwrap(); 1036 + 1037 + let dup_zero = index.get(&dup_cid).unwrap().unwrap(); 1038 + assert!( 1039 + dup_zero.refcount.is_zero(), 1040 + "seed={seed} expected zero refcount" 1041 + ); 1042 + 1043 + let idx = Arc::new(index); 1044 + let reader = h.make_reader(idx); 1045 + let still_readable = reader.get(&dup_cid).unwrap(); 1046 + assert!( 1047 + still_readable.is_some(), 1048 + "seed={seed} zero-refcount block should still be readable, GC not implemented" 1049 + ); 1050 + }); 1051 + } 1052 + 1053 + #[test] 1054 + fn sim_repeated_crash_recover_cycles() { 1055 + (0u64..150).for_each(|seed| { 1056 + let mut h = SimHarness::new(seed); 1057 + let file_id = DataFileId::new(0); 1058 + let mut next_seed: u16 = 0; 1059 + let mut all_committed: Vec<u16> = Vec::new(); 1060 + 1061 + let cycles = (seed % 4) + 2; 1062 + (0..cycles).for_each(|cycle| { 1063 + let start_pos = h.ensure_data_file(file_id); 1064 + 1065 + let count = ((seed.wrapping_add(cycle)) % 15 + 3) as u16; 1066 + let range = next_seed..next_seed + count; 1067 + let (end_pos, entries) = h.write_blocks(file_id, start_pos, range, 48, true); 1068 + next_seed += count; 1069 + 1070 + let index = h.open_index(); 1071 + h.index_entries( 1072 + &index, 1073 + &entries, 1074 + WriteCursor { 1075 + file_id, 1076 + offset: end_pos, 1077 + }, 1078 + ); 1079 + drop(index); 1080 + 1081 + all_committed.extend( 1082 + entries 1083 + .iter() 1084 + .map(|(cid, _)| u16::from_le_bytes([cid[4], cid[5]])), 1085 + ); 1086 + 1087 + let unsynced = ((seed.wrapping_add(cycle)) % 3 + 1) as u16; 1088 + let _ = h.write_blocks( 1089 + file_id, 1090 + end_pos, 1091 + next_seed + 1000..next_seed + 1000 + unsynced, 1092 + 48, 1093 + false, 1094 + ); 1095 + 1096 + h.sim.crash(); 1097 + h.fresh_index_dir(); 1098 + 1099 + let rebuilt = h.open_index(); 1100 + h.rebuild(&rebuilt); 1101 + 1102 + all_committed.iter().for_each(|&s| { 1103 + let cid = sim_test_cid(s); 1104 + assert!( 1105 + rebuilt.has(&cid).unwrap(), 1106 + "seed={seed} cycle={cycle} block {s} lost after rebuild" 1107 + ); 1108 + }); 1109 + 1110 + let idx = Arc::new(rebuilt); 1111 + let reader = h.make_reader(Arc::clone(&idx)); 1112 + all_committed.iter().for_each(|&s| { 1113 + let cid = sim_test_cid(s); 1114 + match reader.get(&cid) { 1115 + Ok(Some(data)) => { 1116 + assert_eq!(data.len(), 48); 1117 + assert_eq!(data[0], s as u8); 1118 + } 1119 + other => panic!("seed={seed} cycle={cycle} block {s}: {other:?}"), 1120 + } 1121 + }); 1122 + 1123 + drop(reader); 1124 + drop(idx); 1125 + }); 1126 + }); 1127 + } 1128 + 1129 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1130 + async fn corrupt_fjall_triggers_rebuild_from_hints() { 1131 + let dir = tempfile::TempDir::new().unwrap(); 1132 + let config = test_config(dir.path()); 1133 + 1134 + let cids: Vec<Cid> = { 1135 + let store = TranquilBlockStore::open(config.clone()).unwrap(); 1136 + let cids = futures::stream::iter(0u8..20) 1137 + .fold(Vec::new(), |mut acc, i| { 1138 + let store = store.clone(); 1139 + async move { 1140 + let data = vec![i; (i as usize + 1) * 16]; 1141 + acc.push(store.put(&data).await.unwrap()); 1142 + acc 1143 + } 1144 + }) 1145 + .await; 1146 + drop(store); 1147 + cids 1148 + }; 1149 + 1150 + fn corrupt_dir_recursive(dir: &Path) { 1151 + std::fs::read_dir(dir) 1152 + .unwrap() 1153 + .filter_map(|e| e.ok()) 1154 + .for_each(|entry| { 1155 + let path = entry.path(); 1156 + if path.is_file() { 1157 + std::fs::write(&path, b"corrupted").unwrap(); 1158 + } else if path.is_dir() { 1159 + corrupt_dir_recursive(&path); 1160 + } 1161 + }); 1162 + } 1163 + corrupt_dir_recursive(&config.index_dir); 1164 + 1165 + let store = TranquilBlockStore::open(config).unwrap(); 1166 + 1167 + futures::stream::iter(cids.iter()) 1168 + .fold((), |(), cid| { 1169 + let store = store.clone(); 1170 + let cid = *cid; 1171 + async move { 1172 + assert!( 1173 + store.has(&cid).await.unwrap(), 1174 + "block {cid} should be accessible after fjall rebuild" 1175 + ); 1176 + assert!( 1177 + store.get(&cid).await.unwrap().is_some(), 1178 + "block {cid} should be readable after fjall rebuild" 1179 + ); 1180 + } 1181 + }) 1182 + .await; 1183 + } 1184 + } 1185 + }
+206
crates/tranquil-store/src/blockstore/types.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 4 + pub struct DataFileId(u32); 5 + 6 + impl DataFileId { 7 + pub fn new(id: u32) -> Self { 8 + Self(id) 9 + } 10 + 11 + pub fn raw(self) -> u32 { 12 + self.0 13 + } 14 + 15 + pub fn next(self) -> Self { 16 + Self(self.0.checked_add(1).expect("DataFileId overflow")) 17 + } 18 + } 19 + 20 + impl std::fmt::Display for DataFileId { 21 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 + write!(f, "{:06}", self.0) 23 + } 24 + } 25 + 26 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 27 + pub struct BlockOffset(u64); 28 + 29 + impl BlockOffset { 30 + pub fn new(offset: u64) -> Self { 31 + Self(offset) 32 + } 33 + 34 + pub fn raw(self) -> u64 { 35 + self.0 36 + } 37 + 38 + pub fn advance(self, delta: u64) -> Self { 39 + Self(self.0.checked_add(delta).expect("BlockOffset overflow")) 40 + } 41 + } 42 + 43 + pub const MAX_BLOCK_SIZE: u32 = 4 * 1024 * 1024; 44 + 45 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 46 + pub struct BlockLength(u32); 47 + 48 + impl BlockLength { 49 + pub fn new(length: u32) -> Self { 50 + assert!( 51 + length <= MAX_BLOCK_SIZE, 52 + "BlockLength {length} exceeds MAX_BLOCK_SIZE {MAX_BLOCK_SIZE}" 53 + ); 54 + Self(length) 55 + } 56 + 57 + pub fn raw(self) -> u32 { 58 + self.0 59 + } 60 + 61 + pub fn as_u64(self) -> u64 { 62 + u64::from(self.0) 63 + } 64 + } 65 + 66 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 67 + pub struct RefCount(u32); 68 + 69 + impl RefCount { 70 + pub fn new(count: u32) -> Self { 71 + Self(count) 72 + } 73 + 74 + pub fn raw(self) -> u32 { 75 + self.0 76 + } 77 + 78 + pub fn one() -> Self { 79 + Self(1) 80 + } 81 + 82 + pub fn is_zero(self) -> bool { 83 + self.0 == 0 84 + } 85 + 86 + pub fn increment(self) -> Self { 87 + Self(self.0.checked_add(1).expect("RefCount overflow")) 88 + } 89 + 90 + pub fn decrement(self) -> Self { 91 + Self(self.0.saturating_sub(1)) 92 + } 93 + } 94 + 95 + #[must_use] 96 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 97 + pub struct BlockLocation { 98 + pub file_id: DataFileId, 99 + pub offset: BlockOffset, 100 + pub length: BlockLength, 101 + } 102 + 103 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 104 + pub struct IndexEntry { 105 + pub location: BlockLocation, 106 + pub refcount: RefCount, 107 + } 108 + 109 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 110 + pub struct WriteCursor { 111 + pub file_id: DataFileId, 112 + pub offset: BlockOffset, 113 + } 114 + 115 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 116 + pub struct HintOffset(u64); 117 + 118 + impl HintOffset { 119 + pub fn new(offset: u64) -> Self { 120 + Self(offset) 121 + } 122 + 123 + pub fn raw(self) -> u64 { 124 + self.0 125 + } 126 + 127 + pub fn advance(self, delta: u64) -> Self { 128 + Self(self.0.checked_add(delta).expect("HintOffset overflow")) 129 + } 130 + } 131 + 132 + #[cfg(test)] 133 + mod tests { 134 + use super::*; 135 + 136 + #[test] 137 + fn index_entry_postcard_round_trip() { 138 + let entry = IndexEntry { 139 + location: BlockLocation { 140 + file_id: DataFileId::new(42), 141 + offset: BlockOffset::new(1024), 142 + length: BlockLength::new(256), 143 + }, 144 + refcount: RefCount::one(), 145 + }; 146 + 147 + let bytes = postcard::to_allocvec(&entry).unwrap(); 148 + let decoded: IndexEntry = postcard::from_bytes(&bytes).unwrap(); 149 + assert_eq!(entry, decoded); 150 + } 151 + 152 + #[test] 153 + fn write_cursor_postcard_round_trip() { 154 + let cursor = WriteCursor { 155 + file_id: DataFileId::new(7), 156 + offset: BlockOffset::new(65536), 157 + }; 158 + 159 + let bytes = postcard::to_allocvec(&cursor).unwrap(); 160 + let decoded: WriteCursor = postcard::from_bytes(&bytes).unwrap(); 161 + assert_eq!(cursor, decoded); 162 + } 163 + 164 + #[test] 165 + fn data_file_id_display_zero_padded() { 166 + assert_eq!(DataFileId::new(0).to_string(), "000000"); 167 + assert_eq!(DataFileId::new(42).to_string(), "000042"); 168 + assert_eq!(DataFileId::new(999999).to_string(), "999999"); 169 + } 170 + 171 + #[test] 172 + fn data_file_id_next_increments() { 173 + assert_eq!(DataFileId::new(0).next(), DataFileId::new(1)); 174 + assert_eq!(DataFileId::new(99).next(), DataFileId::new(100)); 175 + } 176 + 177 + #[test] 178 + #[should_panic(expected = "DataFileId overflow")] 179 + fn data_file_id_overflow_panics() { 180 + DataFileId::new(u32::MAX).next(); 181 + } 182 + 183 + #[test] 184 + fn block_offset_advance() { 185 + let offset = BlockOffset::new(100); 186 + assert_eq!(offset.advance(50), BlockOffset::new(150)); 187 + } 188 + 189 + #[test] 190 + fn refcount_lifecycle() { 191 + let rc = RefCount::one(); 192 + assert!(!rc.is_zero()); 193 + assert_eq!(rc.raw(), 1); 194 + 195 + let rc2 = rc.increment(); 196 + assert_eq!(rc2.raw(), 2); 197 + 198 + let rc3 = rc2.decrement().decrement(); 199 + assert!(rc3.is_zero()); 200 + } 201 + 202 + #[test] 203 + fn refcount_underflow_saturates_at_zero() { 204 + assert!(RefCount::new(0).decrement().is_zero()); 205 + } 206 + }
+273
crates/tranquil-store/src/harness.rs
··· 1 + use std::io; 2 + use std::path::Path; 3 + 4 + use crate::io::{OpenOptions, StorageIO}; 5 + use crate::record::{RecordReader, RecordWriter}; 6 + use crate::sim::{FaultConfig, SimulatedIO}; 7 + 8 + fn setup_sim_file(sim: &SimulatedIO, name: &str) -> io::Result<(crate::io::FileId, String)> { 9 + let dir = Path::new("/harness"); 10 + sim.mkdir(dir)?; 11 + sim.sync_dir(dir)?; 12 + let path_str = format!("/harness/{name}"); 13 + let path = Path::new(&path_str); 14 + let fd = sim.open(path, OpenOptions::read_write())?; 15 + sim.sync_dir(dir)?; 16 + Ok((fd, path_str)) 17 + } 18 + 19 + fn reopen_after_crash(sim: &SimulatedIO, path: &str) -> io::Result<crate::io::FileId> { 20 + sim.open(Path::new(path), OpenOptions::read()) 21 + } 22 + 23 + pub struct CrashTestResult { 24 + pub seed: u64, 25 + pub records_written: usize, 26 + pub records_synced: usize, 27 + pub records_recovered: usize, 28 + pub corrupted_detected: usize, 29 + pub truncated_detected: usize, 30 + } 31 + 32 + pub fn run_crash_test( 33 + seed: u64, 34 + fault_config: FaultConfig, 35 + payloads: &[Vec<u8>], 36 + sync_after: usize, 37 + ) -> io::Result<CrashTestResult> { 38 + let sim = SimulatedIO::new(seed, fault_config); 39 + let (fd, path) = setup_sim_file(&sim, "crash_test.dat")?; 40 + 41 + let mut writer = RecordWriter::new(&sim, fd)?; 42 + 43 + let mut records_written = 0usize; 44 + let mut records_synced = 0usize; 45 + 46 + let _stop_reason = payloads 47 + .iter() 48 + .enumerate() 49 + .try_fold((), |(), (i, payload)| { 50 + writer.append(payload)?; 51 + records_written += 1; 52 + if sync_after > 0 53 + && (i + 1) % sync_after == 0 54 + && writer.sync().is_ok() 55 + && sim.last_sync_persisted() 56 + { 57 + records_synced = records_written; 58 + } 59 + Ok::<_, io::Error>(()) 60 + }); 61 + 62 + sim.crash(); 63 + 64 + let recovery_fd = match reopen_after_crash(&sim, &path) { 65 + Ok(fd) => fd, 66 + Err(_) => { 67 + return Ok(CrashTestResult { 68 + seed, 69 + records_written, 70 + records_synced, 71 + records_recovered: 0, 72 + corrupted_detected: 0, 73 + truncated_detected: 0, 74 + }); 75 + } 76 + }; 77 + 78 + let reader = match RecordReader::open(&sim, recovery_fd) { 79 + Ok(r) => r, 80 + Err(_) => { 81 + return Ok(CrashTestResult { 82 + seed, 83 + records_written, 84 + records_synced, 85 + records_recovered: 0, 86 + corrupted_detected: 0, 87 + truncated_detected: 0, 88 + }); 89 + } 90 + }; 91 + 92 + use crate::record::ReadRecord; 93 + 94 + let collected: Vec<_> = reader 95 + .scan(false, |stopped, record| { 96 + if *stopped { 97 + return None; 98 + } 99 + match record { 100 + ReadRecord::Valid { .. } => Some(record), 101 + other => { 102 + *stopped = true; 103 + Some(other) 104 + } 105 + } 106 + }) 107 + .collect(); 108 + 109 + let records_recovered = collected 110 + .iter() 111 + .filter(|r| matches!(r, ReadRecord::Valid { .. })) 112 + .count(); 113 + let corrupted_detected = collected.last().map_or(0, |r| { 114 + usize::from(matches!(r, ReadRecord::Corrupted { .. })) 115 + }); 116 + let truncated_detected = collected.last().map_or(0, |r| { 117 + usize::from(matches!(r, ReadRecord::Truncated { .. })) 118 + }); 119 + 120 + Ok(CrashTestResult { 121 + seed, 122 + records_written, 123 + records_synced, 124 + records_recovered, 125 + corrupted_detected, 126 + truncated_detected, 127 + }) 128 + } 129 + 130 + pub fn run_pristine_comparison( 131 + seed: u64, 132 + fault_config: FaultConfig, 133 + payloads: &[Vec<u8>], 134 + sync_after: usize, 135 + ) -> io::Result<PristineComparisonResult> { 136 + let pristine = SimulatedIO::pristine(seed); 137 + let (pristine_fd, _) = setup_sim_file(&pristine, "pristine.dat")?; 138 + let mut pristine_writer = RecordWriter::new(&pristine, pristine_fd)?; 139 + 140 + let synced_payloads = payloads 141 + .iter() 142 + .enumerate() 143 + .fold( 144 + (Vec::<Vec<u8>>::new(), Vec::<Vec<u8>>::new()), 145 + |(mut synced, mut pending), (i, payload)| { 146 + pristine_writer.append(payload).unwrap(); 147 + pending.push(payload.clone()); 148 + 149 + if sync_after > 0 && (i + 1) % sync_after == 0 { 150 + pristine_writer.sync().unwrap(); 151 + synced.append(&mut pending); 152 + } 153 + (synced, pending) 154 + }, 155 + ) 156 + .0; 157 + 158 + let faulty = SimulatedIO::new(seed, fault_config); 159 + let (faulty_fd, faulty_path) = setup_sim_file(&faulty, "faulty.dat")?; 160 + let mut faulty_writer = RecordWriter::new(&faulty, faulty_fd)?; 161 + 162 + payloads 163 + .iter() 164 + .enumerate() 165 + .try_fold((), |(), (i, payload)| { 166 + faulty_writer.append(payload)?; 167 + if sync_after > 0 && (i + 1) % sync_after == 0 { 168 + let _ = faulty_writer.sync(); 169 + } 170 + Ok::<_, io::Error>(()) 171 + }) 172 + .ok(); 173 + 174 + faulty.crash(); 175 + 176 + let recovered = match reopen_after_crash(&faulty, &faulty_path) 177 + .and_then(|fd| RecordReader::open(&faulty, fd)) 178 + { 179 + Ok(reader) => reader.valid_records(), 180 + Err(_) => Vec::new(), 181 + }; 182 + 183 + let prefix_valid = recovered 184 + .iter() 185 + .zip(synced_payloads.iter()) 186 + .all(|(r, p)| r == p); 187 + 188 + let recovery_within_bounds = recovered.len() <= payloads.len(); 189 + 190 + Ok(PristineComparisonResult { 191 + seed, 192 + synced_count: synced_payloads.len(), 193 + recovered_count: recovered.len(), 194 + prefix_matches_pristine: prefix_valid, 195 + recovery_within_bounds, 196 + }) 197 + } 198 + 199 + pub struct PristineComparisonResult { 200 + pub seed: u64, 201 + pub synced_count: usize, 202 + pub recovered_count: usize, 203 + pub prefix_matches_pristine: bool, 204 + pub recovery_within_bounds: bool, 205 + } 206 + 207 + #[cfg(test)] 208 + mod tests { 209 + use super::*; 210 + 211 + #[test] 212 + fn no_fault_recovers_all_synced() { 213 + let payloads: Vec<Vec<u8>> = (0..10) 214 + .map(|i| format!("record {i}").into_bytes()) 215 + .collect(); 216 + let result = run_crash_test(42, FaultConfig::none(), &payloads, 5).unwrap(); 217 + assert_eq!(result.records_synced, 10); 218 + assert_eq!(result.records_recovered, 10); 219 + assert_eq!(result.corrupted_detected, 0); 220 + } 221 + 222 + #[test] 223 + fn no_fault_unsynced_records_lost() { 224 + let payloads: Vec<Vec<u8>> = (0..10) 225 + .map(|i| format!("record {i}").into_bytes()) 226 + .collect(); 227 + let result = run_crash_test(42, FaultConfig::none(), &payloads, 0).unwrap(); 228 + assert_eq!(result.records_recovered, 0); 229 + } 230 + 231 + #[test] 232 + fn pristine_comparison_no_faults() { 233 + let payloads: Vec<Vec<u8>> = (0..20) 234 + .map(|i| format!("payload {i}").into_bytes()) 235 + .collect(); 236 + let result = run_pristine_comparison(42, FaultConfig::none(), &payloads, 5).unwrap(); 237 + assert!(result.prefix_matches_pristine); 238 + assert!(result.recovery_within_bounds); 239 + assert_eq!(result.synced_count, 20); 240 + assert_eq!(result.recovered_count, 20); 241 + } 242 + 243 + #[test] 244 + fn faulted_recovery_never_exceeds_written() { 245 + (0..1000).for_each(|seed| { 246 + let payloads: Vec<Vec<u8>> = (0..5).map(|i| format!("data-{i}").into_bytes()).collect(); 247 + let Ok(result) = run_crash_test(seed, FaultConfig::moderate(), &payloads, 2) else { 248 + return; 249 + }; 250 + assert!( 251 + result.records_recovered <= result.records_written, 252 + "seed {seed}: recovered {} > written {}", 253 + result.records_recovered, 254 + result.records_written, 255 + ); 256 + }); 257 + } 258 + 259 + #[test] 260 + fn pristine_comparison_with_faults() { 261 + (0..1000).for_each(|seed| { 262 + let payloads: Vec<Vec<u8>> = (0..8).map(|i| format!("item-{i}").into_bytes()).collect(); 263 + let Ok(result) = run_pristine_comparison(seed, FaultConfig::moderate(), &payloads, 4) 264 + else { 265 + return; 266 + }; 267 + assert!( 268 + result.recovery_within_bounds, 269 + "seed {seed}: recovered more than written" 270 + ); 271 + }); 272 + } 273 + }
+411
crates/tranquil-store/src/io.rs
··· 1 + use std::cell::Cell; 2 + use std::collections::HashMap; 3 + use std::fs; 4 + use std::io; 5 + use std::os::unix::fs::FileExt; 6 + use std::path::{Path, PathBuf}; 7 + use std::sync::atomic::{AtomicU64, Ordering}; 8 + use std::sync::{Arc, Mutex}; 9 + 10 + pub enum MappedFile { 11 + Mmap(memmap2::Mmap), 12 + Buffer(Vec<u8>), 13 + } 14 + 15 + impl AsRef<[u8]> for MappedFile { 16 + fn as_ref(&self) -> &[u8] { 17 + match self { 18 + MappedFile::Mmap(m) => m.as_ref(), 19 + MappedFile::Buffer(b) => b.as_ref(), 20 + } 21 + } 22 + } 23 + 24 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 25 + pub struct FileId(u64); 26 + 27 + impl FileId { 28 + pub(crate) fn new(id: u64) -> Self { 29 + Self(id) 30 + } 31 + 32 + pub fn raw(self) -> u64 { 33 + self.0 34 + } 35 + } 36 + 37 + #[derive(Debug, Clone, Copy)] 38 + pub struct OpenOptions { 39 + pub read: bool, 40 + pub write: bool, 41 + pub create: bool, 42 + pub truncate: bool, 43 + } 44 + 45 + impl OpenOptions { 46 + pub fn read() -> Self { 47 + Self { 48 + read: true, 49 + write: false, 50 + create: false, 51 + truncate: false, 52 + } 53 + } 54 + 55 + pub fn write() -> Self { 56 + Self { 57 + read: false, 58 + write: true, 59 + create: true, 60 + truncate: false, 61 + } 62 + } 63 + 64 + pub fn read_write() -> Self { 65 + Self { 66 + read: true, 67 + write: true, 68 + create: true, 69 + truncate: false, 70 + } 71 + } 72 + 73 + pub fn read_only_existing() -> Self { 74 + Self { 75 + read: true, 76 + write: false, 77 + create: false, 78 + truncate: false, 79 + } 80 + } 81 + 82 + pub fn read_write_existing() -> Self { 83 + Self { 84 + read: true, 85 + write: true, 86 + create: false, 87 + truncate: false, 88 + } 89 + } 90 + } 91 + 92 + pub trait StorageIO: Send + Sync { 93 + fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId>; 94 + fn close(&self, fd: FileId) -> io::Result<()>; 95 + fn read_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize>; 96 + fn write_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<usize>; 97 + fn sync(&self, fd: FileId) -> io::Result<()>; 98 + fn file_size(&self, fd: FileId) -> io::Result<u64>; 99 + fn truncate(&self, fd: FileId, size: u64) -> io::Result<()>; 100 + fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; 101 + fn delete(&self, path: &Path) -> io::Result<()>; 102 + fn mkdir(&self, path: &Path) -> io::Result<()>; 103 + fn sync_dir(&self, path: &Path) -> io::Result<()>; 104 + fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>; 105 + 106 + fn write_all_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<()> { 107 + let written = Cell::new(0usize); 108 + std::iter::from_fn(|| (written.get() < buf.len()).then_some(())) 109 + .try_fold(offset, |pos, ()| { 110 + let w = written.get(); 111 + let n = self.write_at(fd, pos, &buf[w..])?; 112 + match n { 113 + 0 => Err(io::Error::new( 114 + io::ErrorKind::WriteZero, 115 + "write returned 0 bytes", 116 + )), 117 + n => { 118 + written.set(w + n); 119 + Ok(pos + n as u64) 120 + } 121 + } 122 + }) 123 + .map(|_| ()) 124 + } 125 + 126 + fn read_exact_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<()> { 127 + let total = buf.len(); 128 + let progress = Cell::new(0usize); 129 + std::iter::from_fn(|| (progress.get() < total).then_some(())) 130 + .try_fold(offset, |pos, ()| { 131 + let r = progress.get(); 132 + let n = self.read_at(fd, pos, &mut buf[r..])?; 133 + match n { 134 + 0 => Err(io::Error::new( 135 + io::ErrorKind::UnexpectedEof, 136 + "unexpected eof", 137 + )), 138 + n => { 139 + progress.set(r + n); 140 + Ok(pos + n as u64) 141 + } 142 + } 143 + }) 144 + .map(|_| ()) 145 + } 146 + 147 + fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> { 148 + let size = self.file_size(fd)?; 149 + let mut buf = vec![0u8; size as usize]; 150 + self.read_exact_at(fd, 0, &mut buf)?; 151 + Ok(MappedFile::Buffer(buf)) 152 + } 153 + } 154 + 155 + impl<S: StorageIO> StorageIO for Arc<S> { 156 + fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> { 157 + (**self).open(path, opts) 158 + } 159 + fn close(&self, fd: FileId) -> io::Result<()> { 160 + (**self).close(fd) 161 + } 162 + fn read_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> { 163 + (**self).read_at(fd, offset, buf) 164 + } 165 + fn write_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> { 166 + (**self).write_at(fd, offset, buf) 167 + } 168 + fn sync(&self, fd: FileId) -> io::Result<()> { 169 + (**self).sync(fd) 170 + } 171 + fn file_size(&self, fd: FileId) -> io::Result<u64> { 172 + (**self).file_size(fd) 173 + } 174 + fn truncate(&self, fd: FileId, size: u64) -> io::Result<()> { 175 + (**self).truncate(fd, size) 176 + } 177 + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { 178 + (**self).rename(from, to) 179 + } 180 + fn delete(&self, path: &Path) -> io::Result<()> { 181 + (**self).delete(path) 182 + } 183 + fn mkdir(&self, path: &Path) -> io::Result<()> { 184 + (**self).mkdir(path) 185 + } 186 + fn sync_dir(&self, path: &Path) -> io::Result<()> { 187 + (**self).sync_dir(path) 188 + } 189 + fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> { 190 + (**self).list_dir(path) 191 + } 192 + fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> { 193 + (**self).mmap_file(fd) 194 + } 195 + } 196 + 197 + pub struct RealIO { 198 + next_id: AtomicU64, 199 + fds: Mutex<HashMap<FileId, Arc<fs::File>>>, 200 + } 201 + 202 + impl RealIO { 203 + pub fn new() -> Self { 204 + Self { 205 + next_id: AtomicU64::new(1), 206 + fds: Mutex::new(HashMap::new()), 207 + } 208 + } 209 + 210 + fn lookup(&self, id: FileId) -> io::Result<Arc<fs::File>> { 211 + self.fds 212 + .lock() 213 + .map_err(|_| io::Error::other("fd table lock poisoned"))? 214 + .get(&id) 215 + .cloned() 216 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file id")) 217 + } 218 + } 219 + 220 + impl Default for RealIO { 221 + fn default() -> Self { 222 + Self::new() 223 + } 224 + } 225 + 226 + impl StorageIO for RealIO { 227 + fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> { 228 + let file = fs::OpenOptions::new() 229 + .read(opts.read) 230 + .write(opts.write) 231 + .create(opts.create) 232 + .truncate(opts.truncate) 233 + .open(path)?; 234 + 235 + let id = FileId(self.next_id.fetch_add(1, Ordering::Relaxed)); 236 + 237 + self.fds 238 + .lock() 239 + .map_err(|_| io::Error::other("fd table lock poisoned"))? 240 + .insert(id, Arc::new(file)); 241 + 242 + Ok(id) 243 + } 244 + 245 + fn close(&self, id: FileId) -> io::Result<()> { 246 + self.fds 247 + .lock() 248 + .map_err(|_| io::Error::other("fd table lock poisoned"))? 249 + .remove(&id) 250 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file id"))?; 251 + Ok(()) 252 + } 253 + 254 + fn read_at(&self, id: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> { 255 + self.lookup(id)?.read_at(buf, offset) 256 + } 257 + 258 + fn write_at(&self, id: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> { 259 + self.lookup(id)?.write_at(buf, offset) 260 + } 261 + 262 + fn sync(&self, id: FileId) -> io::Result<()> { 263 + self.lookup(id)?.sync_data() 264 + } 265 + 266 + fn file_size(&self, id: FileId) -> io::Result<u64> { 267 + self.lookup(id)?.metadata().map(|m| m.len()) 268 + } 269 + 270 + fn truncate(&self, id: FileId, size: u64) -> io::Result<()> { 271 + self.lookup(id)?.set_len(size) 272 + } 273 + 274 + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { 275 + fs::rename(from, to) 276 + } 277 + 278 + fn delete(&self, path: &Path) -> io::Result<()> { 279 + fs::remove_file(path) 280 + } 281 + 282 + fn mkdir(&self, path: &Path) -> io::Result<()> { 283 + fs::create_dir_all(path) 284 + } 285 + 286 + fn sync_dir(&self, path: &Path) -> io::Result<()> { 287 + fs::File::open(path)?.sync_all() 288 + } 289 + 290 + fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> { 291 + fs::read_dir(path)? 292 + .map(|entry| entry.map(|e| e.path())) 293 + .collect() 294 + } 295 + 296 + fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> { 297 + let file = self.lookup(fd)?; 298 + let mmap = unsafe { memmap2::Mmap::map(&*file)? }; 299 + Ok(MappedFile::Mmap(mmap)) 300 + } 301 + } 302 + 303 + #[cfg(test)] 304 + mod tests { 305 + use super::*; 306 + 307 + #[test] 308 + fn real_io_round_trip() { 309 + let tmp = tempfile::TempDir::new().unwrap(); 310 + let path = tmp.path().join("test.dat"); 311 + let io = RealIO::new(); 312 + 313 + let fd = io.open(&path, OpenOptions::read_write()).unwrap(); 314 + 315 + let data = b"hello tranquil-store"; 316 + let written = io.write_at(fd, 0, data).unwrap(); 317 + assert_eq!(written, data.len()); 318 + 319 + io.sync(fd).unwrap(); 320 + 321 + let mut buf = vec![0u8; data.len()]; 322 + let read = io.read_at(fd, 0, &mut buf).unwrap(); 323 + assert_eq!(read, data.len()); 324 + assert_eq!(&buf, data); 325 + 326 + assert_eq!(io.file_size(fd).unwrap(), data.len() as u64); 327 + 328 + io.truncate(fd, 5).unwrap(); 329 + assert_eq!(io.file_size(fd).unwrap(), 5); 330 + 331 + io.close(fd).unwrap(); 332 + } 333 + 334 + #[test] 335 + fn real_io_write_all_at() { 336 + let tmp = tempfile::TempDir::new().unwrap(); 337 + let path = tmp.path().join("writeall.dat"); 338 + let io = RealIO::new(); 339 + let fd = io.open(&path, OpenOptions::read_write()).unwrap(); 340 + 341 + let data = b"complete write via write_all_at"; 342 + io.write_all_at(fd, 0, data).unwrap(); 343 + io.sync(fd).unwrap(); 344 + 345 + let mut buf = vec![0u8; data.len()]; 346 + io.read_exact_at(fd, 0, &mut buf).unwrap(); 347 + assert_eq!(&buf, data); 348 + 349 + io.close(fd).unwrap(); 350 + } 351 + 352 + #[test] 353 + fn real_io_rename_and_delete() { 354 + let tmp = tempfile::TempDir::new().unwrap(); 355 + let path_a = tmp.path().join("a.dat"); 356 + let path_b = tmp.path().join("b.dat"); 357 + let io = RealIO::new(); 358 + 359 + let fd = io.open(&path_a, OpenOptions::read_write()).unwrap(); 360 + io.write_all_at(fd, 0, b"data").unwrap(); 361 + io.sync(fd).unwrap(); 362 + io.close(fd).unwrap(); 363 + 364 + io.rename(&path_a, &path_b).unwrap(); 365 + assert!(!path_a.exists()); 366 + assert!(path_b.exists()); 367 + 368 + io.delete(&path_b).unwrap(); 369 + assert!(!path_b.exists()); 370 + } 371 + 372 + #[test] 373 + fn real_io_mkdir_and_sync_dir() { 374 + let tmp = tempfile::TempDir::new().unwrap(); 375 + let dir = tmp.path().join("subdir"); 376 + let io = RealIO::new(); 377 + 378 + io.mkdir(&dir).unwrap(); 379 + assert!(dir.is_dir()); 380 + 381 + io.sync_dir(&dir).unwrap(); 382 + } 383 + 384 + #[test] 385 + fn concurrent_read_write_no_deadlock() { 386 + let tmp = tempfile::TempDir::new().unwrap(); 387 + let path = tmp.path().join("concurrent.dat"); 388 + let io = Arc::new(RealIO::new()); 389 + let fd = io.open(&path, OpenOptions::read_write()).unwrap(); 390 + 391 + io.write_all_at(fd, 0, &vec![0u8; 4096]).unwrap(); 392 + io.sync(fd).unwrap(); 393 + 394 + let handles: Vec<_> = (0..4) 395 + .map(|i| { 396 + let io = Arc::clone(&io); 397 + std::thread::spawn(move || { 398 + let offset = (i * 1024) as u64; 399 + let data = vec![i as u8; 1024]; 400 + io.write_all_at(fd, offset, &data).unwrap(); 401 + 402 + let mut buf = vec![0u8; 1024]; 403 + io.read_exact_at(fd, offset, &mut buf).unwrap(); 404 + }) 405 + }) 406 + .collect(); 407 + 408 + handles.into_iter().for_each(|h| h.join().unwrap()); 409 + io.close(fd).unwrap(); 410 + } 411 + }
+19
crates/tranquil-store/src/lib.rs
··· 1 + pub mod blockstore; 2 + mod harness; 3 + mod io; 4 + mod record; 5 + #[cfg(any(test, feature = "test-harness"))] 6 + mod sim; 7 + 8 + pub use blockstore::BlocksSynced; 9 + pub use fsync_order::PostBlockstoreHook; 10 + #[cfg(any(test, feature = "test-harness"))] 11 + pub use harness::{ 12 + CrashTestResult, PristineComparisonResult, run_crash_test, run_pristine_comparison, 13 + }; 14 + pub use io::{FileId, MappedFile, OpenOptions, RealIO, StorageIO}; 15 + pub use record::{ 16 + FILE_MAGIC, FORMAT_VERSION, HEADER_SIZE, MAX_RECORD_PAYLOAD, RECORD_OVERHEAD, ReadRecord, 17 + RecordReader, RecordWriter, 18 + }; 19 + pub use sim::{FaultConfig, SimulatedIO};
+358
crates/tranquil-store/src/record.rs
··· 1 + use std::io; 2 + 3 + use crate::io::{FileId, StorageIO}; 4 + 5 + pub const FILE_MAGIC: [u8; 4] = *b"TQST"; 6 + pub const FORMAT_VERSION: u8 = 2; 7 + pub const HEADER_SIZE: usize = 5; 8 + pub const RECORD_OVERHEAD: usize = 8; 9 + pub const MAX_RECORD_PAYLOAD: usize = 16 * 1024 * 1024; 10 + 11 + fn record_checksum(length_bytes: &[u8; 4], payload: &[u8]) -> u32 { 12 + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); 13 + hasher.update(length_bytes); 14 + hasher.update(payload); 15 + hasher.digest() as u32 16 + } 17 + 18 + pub struct RecordWriter<'a, S: StorageIO> { 19 + io: &'a S, 20 + fd: FileId, 21 + position: u64, 22 + } 23 + 24 + impl<'a, S: StorageIO> RecordWriter<'a, S> { 25 + pub fn new(io: &'a S, fd: FileId) -> io::Result<Self> { 26 + let header = [ 27 + FILE_MAGIC[0], 28 + FILE_MAGIC[1], 29 + FILE_MAGIC[2], 30 + FILE_MAGIC[3], 31 + FORMAT_VERSION, 32 + ]; 33 + io.write_all_at(fd, 0, &header)?; 34 + Ok(Self { 35 + io, 36 + fd, 37 + position: HEADER_SIZE as u64, 38 + }) 39 + } 40 + 41 + pub fn resume(io: &'a S, fd: FileId, position: u64) -> Self { 42 + Self { io, fd, position } 43 + } 44 + 45 + pub fn append(&mut self, payload: &[u8]) -> io::Result<u64> { 46 + let length = u32::try_from(payload.len()) 47 + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "payload exceeds u32::MAX"))?; 48 + if payload.len() > MAX_RECORD_PAYLOAD { 49 + return Err(io::Error::new( 50 + io::ErrorKind::InvalidInput, 51 + "payload exceeds MAX_RECORD_PAYLOAD", 52 + )); 53 + } 54 + 55 + let length_bytes = length.to_le_bytes(); 56 + let checksum = record_checksum(&length_bytes, payload); 57 + let mut cursor = self.position; 58 + 59 + self.io.write_all_at(self.fd, cursor, &length_bytes)?; 60 + cursor += 4; 61 + 62 + self.io.write_all_at(self.fd, cursor, payload)?; 63 + cursor += payload.len() as u64; 64 + 65 + let checksum_bytes = checksum.to_le_bytes(); 66 + self.io.write_all_at(self.fd, cursor, &checksum_bytes)?; 67 + cursor += 4; 68 + 69 + let record_start = self.position; 70 + self.position = cursor; 71 + Ok(record_start) 72 + } 73 + 74 + pub fn sync(&self) -> io::Result<()> { 75 + self.io.sync(self.fd) 76 + } 77 + 78 + pub fn position(&self) -> u64 { 79 + self.position 80 + } 81 + } 82 + 83 + #[derive(Debug)] 84 + pub enum ReadRecord { 85 + Valid { offset: u64, payload: Vec<u8> }, 86 + Corrupted { offset: u64 }, 87 + Truncated { offset: u64 }, 88 + } 89 + 90 + pub struct RecordReader<'a, S: StorageIO> { 91 + io: &'a S, 92 + fd: FileId, 93 + position: u64, 94 + file_size: u64, 95 + } 96 + 97 + impl<'a, S: StorageIO> RecordReader<'a, S> { 98 + pub fn open(io: &'a S, fd: FileId) -> io::Result<Self> { 99 + let file_size = io.file_size(fd)?; 100 + if file_size < HEADER_SIZE as u64 { 101 + return Err(io::Error::new( 102 + io::ErrorKind::InvalidData, 103 + "file too small for header", 104 + )); 105 + } 106 + 107 + let mut header = [0u8; HEADER_SIZE]; 108 + io.read_exact_at(fd, 0, &mut header)?; 109 + 110 + if header[..4] != FILE_MAGIC { 111 + return Err(io::Error::new(io::ErrorKind::InvalidData, "bad magic")); 112 + } 113 + if header[4] != FORMAT_VERSION { 114 + return Err(io::Error::new( 115 + io::ErrorKind::InvalidData, 116 + "unsupported format version", 117 + )); 118 + } 119 + 120 + Ok(Self { 121 + io, 122 + fd, 123 + position: HEADER_SIZE as u64, 124 + file_size, 125 + }) 126 + } 127 + 128 + pub fn valid_records(self) -> Vec<Vec<u8>> { 129 + self.map_while(|r| match r { 130 + ReadRecord::Valid { payload, .. } => Some(payload), 131 + _ => None, 132 + }) 133 + .collect() 134 + } 135 + 136 + fn advance_truncated(&mut self) -> ReadRecord { 137 + let offset = self.position; 138 + self.position = self.file_size; 139 + ReadRecord::Truncated { offset } 140 + } 141 + } 142 + 143 + impl<S: StorageIO> Iterator for RecordReader<'_, S> { 144 + type Item = ReadRecord; 145 + 146 + fn next(&mut self) -> Option<Self::Item> { 147 + if self.position >= self.file_size { 148 + return None; 149 + } 150 + 151 + let remaining = self.file_size - self.position; 152 + if remaining < 4 { 153 + return Some(self.advance_truncated()); 154 + } 155 + 156 + let mut length_bytes = [0u8; 4]; 157 + if self 158 + .io 159 + .read_exact_at(self.fd, self.position, &mut length_bytes) 160 + .is_err() 161 + { 162 + return Some(self.advance_truncated()); 163 + } 164 + 165 + let length = u32::from_le_bytes(length_bytes) as u64; 166 + 167 + if length as usize > MAX_RECORD_PAYLOAD { 168 + let offset = self.position; 169 + self.position = self.file_size; 170 + return Some(ReadRecord::Corrupted { offset }); 171 + } 172 + 173 + let record_size = 4 + length + 4; 174 + 175 + if self.position + record_size > self.file_size { 176 + return Some(self.advance_truncated()); 177 + } 178 + 179 + let mut payload = vec![0u8; length as usize]; 180 + if self 181 + .io 182 + .read_exact_at(self.fd, self.position + 4, &mut payload) 183 + .is_err() 184 + { 185 + return Some(self.advance_truncated()); 186 + } 187 + 188 + let mut checksum_bytes = [0u8; 4]; 189 + if self 190 + .io 191 + .read_exact_at(self.fd, self.position + 4 + length, &mut checksum_bytes) 192 + .is_err() 193 + { 194 + return Some(self.advance_truncated()); 195 + } 196 + 197 + let stored_checksum = u32::from_le_bytes(checksum_bytes); 198 + let computed_checksum = record_checksum(&length_bytes, &payload); 199 + 200 + let offset = self.position; 201 + 202 + if stored_checksum == computed_checksum { 203 + self.position += record_size; 204 + Some(ReadRecord::Valid { offset, payload }) 205 + } else { 206 + self.position = self.file_size; 207 + Some(ReadRecord::Corrupted { offset }) 208 + } 209 + } 210 + } 211 + 212 + #[cfg(test)] 213 + mod tests { 214 + use super::*; 215 + use crate::OpenOptions; 216 + use crate::sim::SimulatedIO; 217 + use std::path::Path; 218 + 219 + fn setup() -> (SimulatedIO, FileId) { 220 + let sim = SimulatedIO::pristine(42); 221 + let dir = Path::new("/test"); 222 + sim.mkdir(dir).unwrap(); 223 + sim.sync_dir(dir).unwrap(); 224 + let fd = sim 225 + .open(Path::new("/test/records.dat"), OpenOptions::read_write()) 226 + .unwrap(); 227 + (sim, fd) 228 + } 229 + 230 + #[test] 231 + fn write_and_read_single_record() { 232 + let (sim, fd) = setup(); 233 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 234 + writer.append(b"hello world").unwrap(); 235 + writer.sync().unwrap(); 236 + 237 + let reader = RecordReader::open(&sim, fd).unwrap(); 238 + let records = reader.valid_records(); 239 + assert_eq!(records.len(), 1); 240 + assert_eq!(records[0], b"hello world"); 241 + } 242 + 243 + #[test] 244 + fn write_and_read_multiple_records() { 245 + let (sim, fd) = setup(); 246 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 247 + writer.append(b"first").unwrap(); 248 + writer.append(b"second").unwrap(); 249 + writer.append(b"third").unwrap(); 250 + writer.sync().unwrap(); 251 + 252 + let reader = RecordReader::open(&sim, fd).unwrap(); 253 + let records = reader.valid_records(); 254 + assert_eq!(records.len(), 3); 255 + assert_eq!(records[0], b"first"); 256 + assert_eq!(records[1], b"second"); 257 + assert_eq!(records[2], b"third"); 258 + } 259 + 260 + #[test] 261 + fn empty_file_has_no_records() { 262 + let (sim, fd) = setup(); 263 + RecordWriter::new(&sim, fd).unwrap(); 264 + 265 + let reader = RecordReader::open(&sim, fd).unwrap(); 266 + let records = reader.valid_records(); 267 + assert!(records.is_empty()); 268 + } 269 + 270 + #[test] 271 + fn detects_truncated_record() { 272 + let (sim, fd) = setup(); 273 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 274 + writer.append(b"complete record").unwrap(); 275 + writer.sync().unwrap(); 276 + 277 + let length_bytes = 100u32.to_le_bytes(); 278 + sim.write_all_at(fd, writer.position(), &length_bytes) 279 + .unwrap(); 280 + sim.write_all_at(fd, writer.position() + 4, b"short") 281 + .unwrap(); 282 + 283 + let mut reader = RecordReader::open(&sim, fd).unwrap(); 284 + let first = reader.next().unwrap(); 285 + assert!(matches!(first, ReadRecord::Valid { .. })); 286 + 287 + let second = reader.next().unwrap(); 288 + assert!(matches!(second, ReadRecord::Truncated { .. })); 289 + } 290 + 291 + #[test] 292 + fn crash_before_sync_loses_records() { 293 + let (sim, fd) = setup(); 294 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 295 + writer.append(b"synced").unwrap(); 296 + writer.sync().unwrap(); 297 + sim.sync_dir(Path::new("/test")).unwrap(); 298 + 299 + writer.append(b"not synced").unwrap(); 300 + 301 + sim.crash(); 302 + 303 + let fd = sim 304 + .open(Path::new("/test/records.dat"), OpenOptions::read()) 305 + .unwrap(); 306 + let reader = RecordReader::open(&sim, fd).unwrap(); 307 + let records = reader.valid_records(); 308 + assert_eq!(records.len(), 1); 309 + assert_eq!(records[0], b"synced"); 310 + } 311 + 312 + #[test] 313 + fn real_io_record_round_trip() { 314 + let tmp = tempfile::TempDir::new().unwrap(); 315 + let path = tmp.path().join("records.dat"); 316 + let real = crate::RealIO::new(); 317 + 318 + let fd = real.open(&path, OpenOptions::read_write()).unwrap(); 319 + let mut writer = RecordWriter::new(&real, fd).unwrap(); 320 + writer.append(b"real record 1").unwrap(); 321 + writer.append(b"real record 2").unwrap(); 322 + writer.sync().unwrap(); 323 + 324 + let reader = RecordReader::open(&real, fd).unwrap(); 325 + let records = reader.valid_records(); 326 + assert_eq!(records.len(), 2); 327 + assert_eq!(records[0], b"real record 1"); 328 + assert_eq!(records[1], b"real record 2"); 329 + 330 + real.close(fd).unwrap(); 331 + } 332 + 333 + #[test] 334 + fn checksum_detects_single_bit_flip() { 335 + let (sim, fd) = setup(); 336 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 337 + let payload = vec![0xAA; 256]; 338 + writer.append(&payload).unwrap(); 339 + writer.sync().unwrap(); 340 + 341 + let mut contents = sim.buffered_contents(fd).unwrap(); 342 + let payload_start = HEADER_SIZE + 4; 343 + contents[payload_start + 128] ^= 0x01; 344 + 345 + let sim2 = SimulatedIO::pristine(99); 346 + let dir2 = Path::new("/verify"); 347 + sim2.mkdir(dir2).unwrap(); 348 + sim2.sync_dir(dir2).unwrap(); 349 + let fd2 = sim2 350 + .open(Path::new("/verify/check.dat"), OpenOptions::read_write()) 351 + .unwrap(); 352 + sim2.write_all_at(fd2, 0, &contents).unwrap(); 353 + 354 + let mut reader = RecordReader::open(&sim2, fd2).unwrap(); 355 + let record = reader.next().unwrap(); 356 + assert!(matches!(record, ReadRecord::Corrupted { .. })); 357 + } 358 + }
+838
crates/tranquil-store/src/sim.rs
··· 1 + use std::collections::{HashMap, HashSet}; 2 + use std::io; 3 + use std::path::{Path, PathBuf}; 4 + use std::sync::Mutex; 5 + 6 + use crate::io::{FileId, OpenOptions, StorageIO}; 7 + 8 + #[derive(Debug, Clone, Copy)] 9 + pub struct FaultConfig { 10 + pub partial_write_probability: f64, 11 + pub bit_flip_on_read_probability: f64, 12 + pub sync_failure_probability: f64, 13 + pub dir_sync_failure_probability: f64, 14 + pub misdirected_write_probability: f64, 15 + pub io_error_probability: f64, 16 + } 17 + 18 + impl FaultConfig { 19 + pub fn none() -> Self { 20 + Self { 21 + partial_write_probability: 0.0, 22 + bit_flip_on_read_probability: 0.0, 23 + sync_failure_probability: 0.0, 24 + dir_sync_failure_probability: 0.0, 25 + misdirected_write_probability: 0.0, 26 + io_error_probability: 0.0, 27 + } 28 + } 29 + 30 + pub fn moderate() -> Self { 31 + Self { 32 + partial_write_probability: 0.05, 33 + bit_flip_on_read_probability: 0.01, 34 + sync_failure_probability: 0.03, 35 + dir_sync_failure_probability: 0.02, 36 + misdirected_write_probability: 0.01, 37 + io_error_probability: 0.02, 38 + } 39 + } 40 + 41 + pub fn aggressive() -> Self { 42 + Self { 43 + partial_write_probability: 0.15, 44 + bit_flip_on_read_probability: 0.05, 45 + sync_failure_probability: 0.10, 46 + dir_sync_failure_probability: 0.05, 47 + misdirected_write_probability: 0.05, 48 + io_error_probability: 0.08, 49 + } 50 + } 51 + } 52 + 53 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 54 + struct StorageId(u64); 55 + 56 + struct SimStorage { 57 + buffered: Vec<u8>, 58 + durable: Vec<u8>, 59 + dir_entry_durable: bool, 60 + } 61 + 62 + struct SimFd { 63 + storage_id: StorageId, 64 + readable: bool, 65 + writable: bool, 66 + } 67 + 68 + #[derive(Debug, Clone)] 69 + pub enum OpRecord { 70 + Open { 71 + fd: FileId, 72 + path: PathBuf, 73 + }, 74 + Close { 75 + fd: FileId, 76 + }, 77 + ReadAt { 78 + fd: FileId, 79 + offset: u64, 80 + len: usize, 81 + }, 82 + WriteAt { 83 + fd: FileId, 84 + offset: u64, 85 + data: Vec<u8>, 86 + actual_written: usize, 87 + }, 88 + Sync { 89 + fd: FileId, 90 + succeeded: bool, 91 + }, 92 + Truncate { 93 + fd: FileId, 94 + size: u64, 95 + }, 96 + Rename { 97 + from: PathBuf, 98 + to: PathBuf, 99 + }, 100 + Delete { 101 + path: PathBuf, 102 + }, 103 + Mkdir { 104 + path: PathBuf, 105 + }, 106 + SyncDir { 107 + path: PathBuf, 108 + }, 109 + } 110 + 111 + struct SimState { 112 + storage: HashMap<StorageId, SimStorage>, 113 + paths: HashMap<PathBuf, StorageId>, 114 + fds: HashMap<FileId, SimFd>, 115 + dirs_durable: HashSet<PathBuf>, 116 + op_log: Vec<OpRecord>, 117 + rng_counter: u64, 118 + next_fd_id: u64, 119 + next_storage_id: u64, 120 + } 121 + 122 + impl SimState { 123 + fn next_random(&mut self, seed: u64) -> f64 { 124 + let counter = self.rng_counter; 125 + self.rng_counter += 1; 126 + let mixed = splitmix64(seed.wrapping_add(counter)); 127 + (mixed >> 11) as f64 / (1u64 << 53) as f64 128 + } 129 + 130 + fn next_random_usize(&mut self, seed: u64, max: usize) -> usize { 131 + if max == 0 { 132 + return 0; 133 + } 134 + let counter = self.rng_counter; 135 + self.rng_counter += 1; 136 + let mixed = splitmix64(seed.wrapping_add(counter)); 137 + (mixed as usize) % max 138 + } 139 + 140 + fn should_fault(&mut self, seed: u64, probability: f64) -> bool { 141 + probability > 0.0 && self.next_random(seed) < probability 142 + } 143 + 144 + fn alloc_fd_id(&mut self) -> FileId { 145 + let id = self.next_fd_id; 146 + self.next_fd_id += 1; 147 + FileId::new(id) 148 + } 149 + 150 + fn alloc_storage_id(&mut self) -> StorageId { 151 + let id = self.next_storage_id; 152 + self.next_storage_id += 1; 153 + StorageId(id) 154 + } 155 + 156 + fn require_open(&self, id: FileId) -> io::Result<StorageId> { 157 + let fd_info = self 158 + .fds 159 + .get(&id) 160 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file id"))?; 161 + if !self.storage.contains_key(&fd_info.storage_id) { 162 + return Err(io::Error::new( 163 + io::ErrorKind::NotFound, 164 + "underlying storage removed", 165 + )); 166 + } 167 + Ok(fd_info.storage_id) 168 + } 169 + 170 + fn require_readable(&self, id: FileId) -> io::Result<StorageId> { 171 + let sid = self.require_open(id)?; 172 + if !self.fds[&id].readable { 173 + return Err(io::Error::new( 174 + io::ErrorKind::PermissionDenied, 175 + "file not opened for reading", 176 + )); 177 + } 178 + Ok(sid) 179 + } 180 + 181 + fn require_writable(&self, id: FileId) -> io::Result<StorageId> { 182 + let sid = self.require_open(id)?; 183 + if !self.fds[&id].writable { 184 + return Err(io::Error::new( 185 + io::ErrorKind::PermissionDenied, 186 + "file not opened for writing", 187 + )); 188 + } 189 + Ok(sid) 190 + } 191 + } 192 + 193 + pub struct SimulatedIO { 194 + state: Mutex<SimState>, 195 + fault_config: FaultConfig, 196 + rng_seed: u64, 197 + } 198 + 199 + impl SimulatedIO { 200 + pub fn new(seed: u64, fault_config: FaultConfig) -> Self { 201 + Self { 202 + state: Mutex::new(SimState { 203 + storage: HashMap::new(), 204 + paths: HashMap::new(), 205 + fds: HashMap::new(), 206 + dirs_durable: HashSet::new(), 207 + op_log: Vec::new(), 208 + rng_counter: 0, 209 + next_fd_id: 1, 210 + next_storage_id: 1, 211 + }), 212 + fault_config, 213 + rng_seed: seed, 214 + } 215 + } 216 + 217 + pub fn pristine(seed: u64) -> Self { 218 + Self::new(seed, FaultConfig::none()) 219 + } 220 + 221 + pub fn crash(&self) { 222 + let mut state = self.state.lock().unwrap(); 223 + 224 + state.fds.clear(); 225 + 226 + let orphaned: Vec<StorageId> = state 227 + .storage 228 + .iter() 229 + .filter(|(_, s)| !s.dir_entry_durable) 230 + .map(|(sid, _)| *sid) 231 + .collect(); 232 + 233 + orphaned.iter().for_each(|sid| { 234 + state.storage.remove(sid); 235 + }); 236 + 237 + let live_sids: HashSet<StorageId> = state.storage.keys().copied().collect(); 238 + state.paths.retain(|_, sid| live_sids.contains(sid)); 239 + 240 + state 241 + .storage 242 + .values_mut() 243 + .for_each(|s| s.buffered = s.durable.clone()); 244 + } 245 + 246 + pub fn op_log(&self) -> Vec<OpRecord> { 247 + self.state.lock().unwrap().op_log.clone() 248 + } 249 + 250 + pub fn durable_contents(&self, fd: FileId) -> io::Result<Vec<u8>> { 251 + let state = self.state.lock().unwrap(); 252 + let sid = state.require_open(fd)?; 253 + Ok(state.storage.get(&sid).unwrap().durable.clone()) 254 + } 255 + 256 + pub fn buffered_contents(&self, fd: FileId) -> io::Result<Vec<u8>> { 257 + let state = self.state.lock().unwrap(); 258 + let sid = state.require_open(fd)?; 259 + Ok(state.storage.get(&sid).unwrap().buffered.clone()) 260 + } 261 + 262 + pub fn last_sync_persisted(&self) -> bool { 263 + let state = self.state.lock().unwrap(); 264 + state 265 + .op_log 266 + .iter() 267 + .rev() 268 + .find_map(|op| match op { 269 + OpRecord::Sync { succeeded, .. } => Some(*succeeded), 270 + _ => None, 271 + }) 272 + .unwrap_or(false) 273 + } 274 + } 275 + 276 + impl StorageIO for SimulatedIO { 277 + fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> { 278 + let mut state = self.state.lock().unwrap(); 279 + let seed = self.rng_seed; 280 + 281 + if state.should_fault(seed, self.fault_config.io_error_probability) { 282 + return Err(io::Error::other("simulated EIO on open")); 283 + } 284 + 285 + let path_buf = path.to_path_buf(); 286 + let fd_id = state.alloc_fd_id(); 287 + 288 + match state.paths.get(&path_buf).copied() { 289 + Some(sid) => { 290 + if opts.truncate { 291 + state.storage.get_mut(&sid).unwrap().buffered.clear(); 292 + } 293 + state.fds.insert( 294 + fd_id, 295 + SimFd { 296 + storage_id: sid, 297 + readable: opts.read, 298 + writable: opts.write, 299 + }, 300 + ); 301 + } 302 + None => { 303 + if !opts.create { 304 + return Err(io::Error::new( 305 + io::ErrorKind::NotFound, 306 + "file not found and create not set", 307 + )); 308 + } 309 + 310 + let sid = state.alloc_storage_id(); 311 + state.storage.insert( 312 + sid, 313 + SimStorage { 314 + buffered: Vec::new(), 315 + durable: Vec::new(), 316 + dir_entry_durable: false, 317 + }, 318 + ); 319 + state.paths.insert(path_buf.clone(), sid); 320 + state.fds.insert( 321 + fd_id, 322 + SimFd { 323 + storage_id: sid, 324 + readable: opts.read, 325 + writable: opts.write, 326 + }, 327 + ); 328 + } 329 + }; 330 + 331 + state.op_log.push(OpRecord::Open { 332 + fd: fd_id, 333 + path: path_buf, 334 + }); 335 + Ok(fd_id) 336 + } 337 + 338 + fn close(&self, id: FileId) -> io::Result<()> { 339 + let mut state = self.state.lock().unwrap(); 340 + let fd_info = state 341 + .fds 342 + .remove(&id) 343 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "unknown file id"))?; 344 + 345 + let sid = fd_info.storage_id; 346 + let unlinked = !state.paths.values().any(|s| *s == sid); 347 + let no_remaining_fds = !state.fds.values().any(|f| f.storage_id == sid); 348 + 349 + if unlinked && no_remaining_fds { 350 + state.storage.remove(&sid); 351 + } 352 + 353 + state.op_log.push(OpRecord::Close { fd: id }); 354 + Ok(()) 355 + } 356 + 357 + fn read_at(&self, id: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> { 358 + let mut state = self.state.lock().unwrap(); 359 + let sid = state.require_readable(id)?; 360 + let seed = self.rng_seed; 361 + 362 + if state.should_fault(seed, self.fault_config.io_error_probability) { 363 + return Err(io::Error::other("simulated EIO on read")); 364 + } 365 + 366 + let storage = state.storage.get(&sid).unwrap(); 367 + 368 + let off = usize::try_from(offset) 369 + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset exceeds usize"))?; 370 + if off >= storage.buffered.len() { 371 + state.op_log.push(OpRecord::ReadAt { 372 + fd: id, 373 + offset, 374 + len: 0, 375 + }); 376 + return Ok(0); 377 + } 378 + 379 + let available = storage.buffered.len().saturating_sub(off); 380 + let to_read = buf.len().min(available); 381 + buf[..to_read].copy_from_slice(&storage.buffered[off..off + to_read]); 382 + 383 + if state.should_fault(seed, self.fault_config.bit_flip_on_read_probability) && to_read > 0 { 384 + let flip_pos = state.next_random_usize(seed, to_read); 385 + let flip_bit = state.next_random_usize(seed, 8); 386 + buf[flip_pos] ^= 1 << flip_bit; 387 + } 388 + 389 + state.op_log.push(OpRecord::ReadAt { 390 + fd: id, 391 + offset, 392 + len: to_read, 393 + }); 394 + Ok(to_read) 395 + } 396 + 397 + fn write_at(&self, id: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> { 398 + let mut state = self.state.lock().unwrap(); 399 + let sid = state.require_writable(id)?; 400 + let seed = self.rng_seed; 401 + 402 + if state.should_fault(seed, self.fault_config.io_error_probability) { 403 + return Err(io::Error::other("simulated EIO on write")); 404 + } 405 + 406 + let actual_len = if buf.len() > 1 407 + && state.should_fault(seed, self.fault_config.partial_write_probability) 408 + { 409 + let partial = state.next_random_usize(seed, buf.len()); 410 + partial.max(1) 411 + } else { 412 + buf.len() 413 + }; 414 + 415 + let misdirected = state.should_fault(seed, self.fault_config.misdirected_write_probability); 416 + let write_offset = if misdirected { 417 + let drift = state.next_random_usize(seed, 64) as u64; 418 + if state.next_random(seed) < 0.5 { 419 + offset.saturating_sub(drift) 420 + } else { 421 + offset.saturating_add(drift) 422 + } 423 + } else { 424 + offset 425 + }; 426 + 427 + let storage = state.storage.get_mut(&sid).unwrap(); 428 + 429 + let off = usize::try_from(write_offset) 430 + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset exceeds usize"))?; 431 + let end = off.saturating_add(actual_len); 432 + if end > storage.buffered.len() { 433 + storage.buffered.resize(end, 0); 434 + } 435 + storage.buffered[off..end].copy_from_slice(&buf[..actual_len]); 436 + 437 + state.op_log.push(OpRecord::WriteAt { 438 + fd: id, 439 + offset, 440 + data: buf[..actual_len].to_vec(), 441 + actual_written: actual_len, 442 + }); 443 + Ok(actual_len) 444 + } 445 + 446 + fn sync(&self, id: FileId) -> io::Result<()> { 447 + let mut state = self.state.lock().unwrap(); 448 + let sid = state.require_open(id)?; 449 + let seed = self.rng_seed; 450 + 451 + if state.should_fault(seed, self.fault_config.io_error_probability) { 452 + return Err(io::Error::other("simulated EIO on sync")); 453 + } 454 + 455 + let sync_succeeded = !state.should_fault(seed, self.fault_config.sync_failure_probability); 456 + 457 + let storage = state.storage.get_mut(&sid).unwrap(); 458 + 459 + if sync_succeeded { 460 + storage.durable = storage.buffered.clone(); 461 + } 462 + 463 + state.op_log.push(OpRecord::Sync { 464 + fd: id, 465 + succeeded: sync_succeeded, 466 + }); 467 + Ok(()) 468 + } 469 + 470 + fn file_size(&self, id: FileId) -> io::Result<u64> { 471 + let state = self.state.lock().unwrap(); 472 + let sid = state.require_open(id)?; 473 + Ok(state.storage.get(&sid).unwrap().buffered.len() as u64) 474 + } 475 + 476 + fn truncate(&self, id: FileId, size: u64) -> io::Result<()> { 477 + let mut state = self.state.lock().unwrap(); 478 + let sid = state.require_open(id)?; 479 + let storage = state.storage.get_mut(&sid).unwrap(); 480 + 481 + let target = usize::try_from(size) 482 + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "size exceeds usize"))?; 483 + storage.buffered.resize(target, 0); 484 + 485 + state.op_log.push(OpRecord::Truncate { fd: id, size }); 486 + Ok(()) 487 + } 488 + 489 + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { 490 + let mut state = self.state.lock().unwrap(); 491 + let from_buf = from.to_path_buf(); 492 + let to_buf = to.to_path_buf(); 493 + 494 + let sid = state 495 + .paths 496 + .remove(&from_buf) 497 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source file not found"))?; 498 + 499 + let storage = state.storage.get_mut(&sid).unwrap(); 500 + storage.dir_entry_durable = false; 501 + 502 + state.paths.insert(to_buf.clone(), sid); 503 + 504 + state.op_log.push(OpRecord::Rename { 505 + from: from_buf, 506 + to: to_buf, 507 + }); 508 + Ok(()) 509 + } 510 + 511 + fn delete(&self, path: &Path) -> io::Result<()> { 512 + let mut state = self.state.lock().unwrap(); 513 + let path_buf = path.to_path_buf(); 514 + 515 + let sid = state 516 + .paths 517 + .remove(&path_buf) 518 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?; 519 + 520 + let has_open_fds = state.fds.values().any(|fd_info| fd_info.storage_id == sid); 521 + 522 + if !has_open_fds { 523 + state.storage.remove(&sid); 524 + } 525 + 526 + state.op_log.push(OpRecord::Delete { path: path_buf }); 527 + Ok(()) 528 + } 529 + 530 + fn mkdir(&self, path: &Path) -> io::Result<()> { 531 + let mut state = self.state.lock().unwrap(); 532 + state.op_log.push(OpRecord::Mkdir { 533 + path: path.to_path_buf(), 534 + }); 535 + Ok(()) 536 + } 537 + 538 + fn sync_dir(&self, path: &Path) -> io::Result<()> { 539 + let mut state = self.state.lock().unwrap(); 540 + let seed = self.rng_seed; 541 + 542 + if state.should_fault(seed, self.fault_config.io_error_probability) { 543 + return Err(io::Error::other("simulated EIO on sync_dir")); 544 + } 545 + 546 + let dir_path = path.to_path_buf(); 547 + let actually_persisted = 548 + !state.should_fault(seed, self.fault_config.dir_sync_failure_probability); 549 + 550 + if actually_persisted { 551 + state.dirs_durable.insert(dir_path.clone()); 552 + 553 + let sids_in_dir: Vec<StorageId> = state 554 + .paths 555 + .iter() 556 + .filter(|(p, _)| p.parent().map(|parent| parent == path).unwrap_or(false)) 557 + .map(|(_, sid)| *sid) 558 + .collect(); 559 + 560 + sids_in_dir.iter().for_each(|sid| { 561 + if let Some(storage) = state.storage.get_mut(sid) { 562 + storage.dir_entry_durable = true; 563 + } 564 + }); 565 + } 566 + 567 + state.op_log.push(OpRecord::SyncDir { path: dir_path }); 568 + Ok(()) 569 + } 570 + 571 + fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> { 572 + let state = self.state.lock().unwrap(); 573 + let entries: Vec<PathBuf> = state 574 + .paths 575 + .keys() 576 + .filter(|p| p.parent() == Some(path)) 577 + .cloned() 578 + .collect(); 579 + Ok(entries) 580 + } 581 + } 582 + 583 + fn splitmix64(mut x: u64) -> u64 { 584 + x = x.wrapping_add(0x9e3779b97f4a7c15); 585 + x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); 586 + x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb); 587 + x ^ (x >> 31) 588 + } 589 + 590 + #[cfg(test)] 591 + mod tests { 592 + use super::*; 593 + 594 + #[test] 595 + fn pristine_round_trip() { 596 + let sim = SimulatedIO::pristine(42); 597 + let path = Path::new("/test/file.dat"); 598 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 599 + 600 + let data = b"hello simulation"; 601 + sim.write_at(fd, 0, data).unwrap(); 602 + 603 + let mut buf = vec![0u8; data.len()]; 604 + sim.read_at(fd, 0, &mut buf).unwrap(); 605 + assert_eq!(&buf, data); 606 + } 607 + 608 + #[test] 609 + fn crash_resets_to_durable() { 610 + let sim = SimulatedIO::pristine(42); 611 + let dir = Path::new("/test"); 612 + sim.mkdir(dir).unwrap(); 613 + sim.sync_dir(dir).unwrap(); 614 + 615 + let path = Path::new("/test/file.dat"); 616 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 617 + sim.write_at(fd, 0, b"durable data").unwrap(); 618 + sim.sync(fd).unwrap(); 619 + sim.sync_dir(dir).unwrap(); 620 + 621 + sim.write_at(fd, 0, b"volatile!!!!").unwrap(); 622 + sim.crash(); 623 + 624 + let fd = sim.open(path, OpenOptions::read()).unwrap(); 625 + let mut buf = vec![0u8; 12]; 626 + sim.read_at(fd, 0, &mut buf).unwrap(); 627 + assert_eq!(&buf, b"durable data"); 628 + } 629 + 630 + #[test] 631 + fn crash_with_no_sync_loses_everything() { 632 + let sim = SimulatedIO::pristine(42); 633 + let dir = Path::new("/test"); 634 + sim.mkdir(dir).unwrap(); 635 + sim.sync_dir(dir).unwrap(); 636 + 637 + let path = Path::new("/test/file.dat"); 638 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 639 + sim.write_at(fd, 0, b"never synced").unwrap(); 640 + 641 + sim.sync_dir(dir).unwrap(); 642 + sim.crash(); 643 + 644 + let fd = sim.open(path, OpenOptions::read()).unwrap(); 645 + assert_eq!(sim.file_size(fd).unwrap(), 0); 646 + } 647 + 648 + #[test] 649 + fn crash_without_dir_sync_loses_file() { 650 + let sim = SimulatedIO::pristine(42); 651 + let path = Path::new("/test/file.dat"); 652 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 653 + 654 + sim.write_at(fd, 0, b"data").unwrap(); 655 + sim.sync(fd).unwrap(); 656 + 657 + sim.crash(); 658 + 659 + let result = sim.open(path, OpenOptions::read()); 660 + assert!(result.is_err()); 661 + } 662 + 663 + #[test] 664 + fn dir_sync_makes_file_durable() { 665 + let sim = SimulatedIO::pristine(42); 666 + let dir = Path::new("/test"); 667 + sim.mkdir(dir).unwrap(); 668 + sim.sync_dir(dir).unwrap(); 669 + 670 + let path = Path::new("/test/file.dat"); 671 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 672 + sim.write_at(fd, 0, b"persistent").unwrap(); 673 + sim.sync(fd).unwrap(); 674 + sim.sync_dir(dir).unwrap(); 675 + 676 + sim.crash(); 677 + 678 + let fd = sim.open(path, OpenOptions::read()).unwrap(); 679 + let mut buf = vec![0u8; 10]; 680 + sim.read_at(fd, 0, &mut buf).unwrap(); 681 + assert_eq!(&buf, b"persistent"); 682 + } 683 + 684 + #[test] 685 + fn read_only_rejects_write() { 686 + let sim = SimulatedIO::pristine(42); 687 + let path = Path::new("/test/file.dat"); 688 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 689 + sim.write_at(fd, 0, b"data").unwrap(); 690 + 691 + let fd2 = sim.open(path, OpenOptions::read()).unwrap(); 692 + assert_ne!(fd, fd2); 693 + let result = sim.write_at(fd2, 0, b"nope"); 694 + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); 695 + } 696 + 697 + #[test] 698 + fn write_only_rejects_read() { 699 + let sim = SimulatedIO::pristine(42); 700 + let path = Path::new("/test/file.dat"); 701 + let fd = sim.open(path, OpenOptions::write()).unwrap(); 702 + sim.write_at(fd, 0, b"data").unwrap(); 703 + 704 + let mut buf = vec![0u8; 4]; 705 + let result = sim.read_at(fd, 0, &mut buf); 706 + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); 707 + } 708 + 709 + #[test] 710 + fn open_without_create_fails_for_missing_file() { 711 + let sim = SimulatedIO::pristine(42); 712 + let result = sim.open(Path::new("/nonexistent"), OpenOptions::read()); 713 + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); 714 + } 715 + 716 + #[test] 717 + fn truncate_on_open() { 718 + let sim = SimulatedIO::pristine(42); 719 + let path = Path::new("/test/file.dat"); 720 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 721 + sim.write_at(fd, 0, b"existing data").unwrap(); 722 + 723 + let opts = OpenOptions { 724 + read: true, 725 + write: true, 726 + create: true, 727 + truncate: true, 728 + }; 729 + let fd2 = sim.open(path, opts).unwrap(); 730 + assert_eq!(sim.file_size(fd2).unwrap(), 0); 731 + } 732 + 733 + #[test] 734 + fn rename_makes_entry_non_durable() { 735 + let sim = SimulatedIO::pristine(42); 736 + let dir = Path::new("/test"); 737 + sim.mkdir(dir).unwrap(); 738 + sim.sync_dir(dir).unwrap(); 739 + 740 + let path_a = Path::new("/test/a.dat"); 741 + let fd = sim.open(path_a, OpenOptions::read_write()).unwrap(); 742 + sim.write_at(fd, 0, b"data").unwrap(); 743 + sim.sync(fd).unwrap(); 744 + sim.sync_dir(dir).unwrap(); 745 + 746 + let path_b = Path::new("/test/b.dat"); 747 + sim.rename(path_a, path_b).unwrap(); 748 + 749 + sim.crash(); 750 + 751 + let result_a = sim.open(path_a, OpenOptions::read()); 752 + let result_b = sim.open(path_b, OpenOptions::read()); 753 + assert!(result_a.is_err()); 754 + assert!(result_b.is_err()); 755 + } 756 + 757 + #[test] 758 + fn durable_contents_accessible() { 759 + let sim = SimulatedIO::pristine(42); 760 + let dir = Path::new("/test"); 761 + sim.mkdir(dir).unwrap(); 762 + sim.sync_dir(dir).unwrap(); 763 + 764 + let path = Path::new("/test/file.dat"); 765 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 766 + sim.write_at(fd, 0, b"synced").unwrap(); 767 + sim.sync(fd).unwrap(); 768 + sim.sync_dir(dir).unwrap(); 769 + sim.write_at(fd, 6, b" unsynced").unwrap(); 770 + 771 + let durable = sim.durable_contents(fd).unwrap(); 772 + assert_eq!(&durable, b"synced"); 773 + 774 + let buffered = sim.buffered_contents(fd).unwrap(); 775 + assert_eq!(&buffered, b"synced unsynced"); 776 + } 777 + 778 + #[test] 779 + fn op_log_records_operations() { 780 + let sim = SimulatedIO::pristine(42); 781 + let dir = Path::new("/test"); 782 + sim.mkdir(dir).unwrap(); 783 + sim.sync_dir(dir).unwrap(); 784 + 785 + let path = Path::new("/test/file.dat"); 786 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 787 + sim.write_at(fd, 0, b"data").unwrap(); 788 + sim.sync(fd).unwrap(); 789 + sim.close(fd).unwrap(); 790 + 791 + let log = sim.op_log(); 792 + assert_eq!(log.len(), 6); 793 + assert!(matches!(log[0], OpRecord::Mkdir { .. })); 794 + assert!(matches!(log[1], OpRecord::SyncDir { .. })); 795 + assert!(matches!(log[2], OpRecord::Open { .. })); 796 + assert!(matches!(log[3], OpRecord::WriteAt { .. })); 797 + assert!(matches!( 798 + log[4], 799 + OpRecord::Sync { 800 + succeeded: true, 801 + .. 802 + } 803 + )); 804 + assert!(matches!(log[5], OpRecord::Close { .. })); 805 + } 806 + 807 + #[test] 808 + fn multiple_fds_independent_permissions() { 809 + let sim = SimulatedIO::pristine(42); 810 + let path = Path::new("/test/file.dat"); 811 + let fd_rw = sim.open(path, OpenOptions::read_write()).unwrap(); 812 + sim.write_at(fd_rw, 0, b"shared data").unwrap(); 813 + 814 + let fd_ro = sim.open(path, OpenOptions::read()).unwrap(); 815 + assert_ne!(fd_rw, fd_ro); 816 + 817 + let mut buf = vec![0u8; 11]; 818 + sim.read_at(fd_ro, 0, &mut buf).unwrap(); 819 + assert_eq!(&buf, b"shared data"); 820 + 821 + sim.write_at(fd_rw, 0, b"mutated!!!!").unwrap(); 822 + sim.read_at(fd_ro, 0, &mut buf).unwrap(); 823 + assert_eq!(&buf, b"mutated!!!!"); 824 + 825 + let result = sim.write_at(fd_ro, 0, b"nope"); 826 + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); 827 + } 828 + 829 + #[test] 830 + fn last_sync_persisted_tracks_truth() { 831 + let sim = SimulatedIO::pristine(42); 832 + let path = Path::new("/test/file.dat"); 833 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 834 + sim.write_at(fd, 0, b"data").unwrap(); 835 + sim.sync(fd).unwrap(); 836 + assert!(sim.last_sync_persisted()); 837 + } 838 + }
+304
crates/tranquil-store/tests/mst_integration.rs
··· 1 + use std::collections::BTreeMap; 2 + use std::sync::Arc; 3 + 4 + use bytes::Bytes; 5 + use cid::Cid; 6 + use futures::StreamExt; 7 + use jacquard_common::types::string::Did; 8 + use jacquard_common::types::tid::Ticker; 9 + use jacquard_repo::car::{parse_car_bytes, write_car_bytes}; 10 + use jacquard_repo::commit::Commit; 11 + use jacquard_repo::mst::Mst; 12 + use jacquard_repo::repo::CommitData; 13 + use jacquard_repo::storage::BlockStore; 14 + use multihash::Multihash; 15 + use sha2::{Digest, Sha256}; 16 + use tranquil_store::blockstore::{ 17 + BlockStoreConfig, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore, 18 + }; 19 + 20 + const DAG_CBOR_CODEC: u64 = 0x71; 21 + const SHA2_256_CODE: u64 = 0x12; 22 + 23 + fn test_config(dir: &std::path::Path) -> BlockStoreConfig { 24 + BlockStoreConfig { 25 + data_dir: dir.join("data"), 26 + index_dir: dir.join("index"), 27 + max_file_size: DEFAULT_MAX_FILE_SIZE, 28 + group_commit: GroupCommitConfig::default(), 29 + } 30 + } 31 + 32 + fn make_record(value: &str) -> Vec<u8> { 33 + serde_ipld_dagcbor::to_vec(&BTreeMap::from([ 34 + ("$type", "app.bsky.feed.post"), 35 + ("text", value), 36 + ])) 37 + .unwrap() 38 + } 39 + 40 + fn compute_cid(data: &[u8]) -> Cid { 41 + let hash = Sha256::digest(data); 42 + let multihash = Multihash::wrap(SHA2_256_CODE, &hash).unwrap(); 43 + Cid::new_v1(DAG_CBOR_CODEC, multihash) 44 + } 45 + 46 + fn test_signing_key() -> k256::ecdsa::SigningKey { 47 + k256::ecdsa::SigningKey::random(&mut rand::thread_rng()) 48 + } 49 + 50 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 51 + async fn mst_insert_commit_and_car_round_trip() { 52 + let dir = tempfile::TempDir::new().unwrap(); 53 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 54 + let storage = Arc::new(store.clone()); 55 + let mut mst = Mst::new(storage.clone()); 56 + 57 + let records: Vec<(String, Cid, Vec<u8>)> = (0..100u32) 58 + .map(|i| { 59 + let data = make_record(&format!("post number {i}")); 60 + let cid = compute_cid(&data); 61 + (format!("app.bsky.feed.post/{i:010}"), cid, data) 62 + }) 63 + .collect(); 64 + 65 + let mut record_blocks = BTreeMap::new(); 66 + for (key, cid, data) in &records { 67 + record_blocks.insert(*cid, Bytes::from(data.clone())); 68 + storage.put(data).await.unwrap(); 69 + mst = mst.add(key, *cid).await.unwrap(); 70 + } 71 + 72 + let mst_root = mst.persist().await.unwrap(); 73 + 74 + futures::stream::iter(&records) 75 + .for_each(|(key, expected_cid, _)| { 76 + let mst = &mst; 77 + async move { 78 + let found = mst.get(key).await.unwrap(); 79 + assert_eq!(found, Some(*expected_cid), "record {key} missing from MST"); 80 + } 81 + }) 82 + .await; 83 + 84 + let signing_key = test_signing_key(); 85 + let did = Did::new("did:plc:testuser123").unwrap(); 86 + let mut ticker = Ticker::new(); 87 + let rev = ticker.next(None); 88 + 89 + let commit = Commit::new_unsigned(did, mst_root, rev.clone(), None) 90 + .sign(&signing_key) 91 + .unwrap(); 92 + let commit_cbor = commit.to_cbor().unwrap(); 93 + let commit_cid = compute_cid(&commit_cbor); 94 + let commit_bytes = Bytes::from(commit_cbor); 95 + 96 + let empty_mst = Mst::new(storage.clone()); 97 + let diff = empty_mst.diff(&mst).await.unwrap(); 98 + 99 + let mut all_blocks = diff.new_mst_blocks.clone(); 100 + all_blocks.insert(commit_cid, commit_bytes); 101 + all_blocks.extend(record_blocks); 102 + 103 + let commit_data = CommitData { 104 + cid: commit_cid, 105 + rev, 106 + since: None, 107 + prev: None, 108 + data: mst_root, 109 + prev_data: None, 110 + blocks: all_blocks.clone(), 111 + relevant_blocks: BTreeMap::new(), 112 + deleted_cids: Vec::new(), 113 + }; 114 + 115 + store.apply_commit(commit_data).await.unwrap(); 116 + 117 + let car_bytes = write_car_bytes(commit_cid, all_blocks).await.unwrap(); 118 + let parsed = parse_car_bytes(&car_bytes).await.unwrap(); 119 + 120 + assert_eq!(parsed.root, commit_cid); 121 + assert!(parsed.blocks.contains_key(&commit_cid)); 122 + assert!(parsed.blocks.contains_key(&mst_root)); 123 + 124 + records.iter().for_each(|(_, record_cid, _)| { 125 + assert!( 126 + parsed.blocks.contains_key(record_cid), 127 + "record block {record_cid} missing from CAR" 128 + ); 129 + }); 130 + 131 + let parsed_commit = Commit::from_cbor(parsed.blocks.get(&commit_cid).unwrap()).unwrap(); 132 + assert_eq!(*parsed_commit.data(), mst_root); 133 + 134 + let loaded_mst = Mst::load(storage.clone(), mst_root, None); 135 + futures::stream::iter(&records) 136 + .for_each(|(key, expected_cid, _)| { 137 + let loaded_mst = &loaded_mst; 138 + async move { 139 + let found = loaded_mst.get(key).await.unwrap(); 140 + assert_eq!( 141 + found, 142 + Some(*expected_cid), 143 + "record {key} missing after reload" 144 + ); 145 + } 146 + }) 147 + .await; 148 + } 149 + 150 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 151 + async fn mst_create_update_delete_with_refcounts() { 152 + let dir = tempfile::TempDir::new().unwrap(); 153 + let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); 154 + let storage = Arc::new(store.clone()); 155 + 156 + let record_a_v1 = make_record("version 1 of record A"); 157 + let record_a_v2 = make_record("version 2 of record A"); 158 + let record_b = make_record("record B to be deleted"); 159 + let record_c = make_record("record C stays forever"); 160 + let record_shared = make_record("shared content"); 161 + 162 + let cid_a_v1 = storage.put(&record_a_v1).await.unwrap(); 163 + let cid_a_v2 = compute_cid(&record_a_v2); 164 + let cid_b = storage.put(&record_b).await.unwrap(); 165 + let cid_c = storage.put(&record_c).await.unwrap(); 166 + let cid_shared = storage.put(&record_shared).await.unwrap(); 167 + 168 + let mut mst = Mst::new(storage.clone()); 169 + mst = mst.add("app.bsky.feed.post/aaaa", cid_a_v1).await.unwrap(); 170 + mst = mst.add("app.bsky.feed.post/bbbb", cid_b).await.unwrap(); 171 + mst = mst.add("app.bsky.feed.post/cccc", cid_c).await.unwrap(); 172 + mst = mst 173 + .add("app.bsky.feed.post/dddd", cid_shared) 174 + .await 175 + .unwrap(); 176 + mst = mst 177 + .add("app.bsky.feed.post/eeee", cid_shared) 178 + .await 179 + .unwrap(); 180 + 181 + let mst_root_v1 = mst.persist().await.unwrap(); 182 + 183 + let signing_key = test_signing_key(); 184 + let mut ticker = Ticker::new(); 185 + let rev1 = ticker.next(None); 186 + 187 + let commit_v1 = Commit::new_unsigned( 188 + Did::new("did:plc:testuser123").unwrap(), 189 + mst_root_v1, 190 + rev1.clone(), 191 + None, 192 + ) 193 + .sign(&signing_key) 194 + .unwrap(); 195 + let commit_v1_cbor = commit_v1.to_cbor().unwrap(); 196 + let commit_v1_cid = compute_cid(&commit_v1_cbor); 197 + 198 + let empty_mst = Mst::new(storage.clone()); 199 + let diff_v1 = empty_mst.diff(&mst).await.unwrap(); 200 + 201 + let mut blocks_v1 = diff_v1.new_mst_blocks.clone(); 202 + blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor)); 203 + 204 + store 205 + .apply_commit(CommitData { 206 + cid: commit_v1_cid, 207 + rev: rev1.clone(), 208 + since: None, 209 + prev: None, 210 + data: mst_root_v1, 211 + prev_data: None, 212 + blocks: blocks_v1, 213 + relevant_blocks: BTreeMap::new(), 214 + deleted_cids: Vec::new(), 215 + }) 216 + .await 217 + .unwrap(); 218 + 219 + let old_mst = mst.clone(); 220 + mst = mst.add("app.bsky.feed.post/aaaa", cid_a_v2).await.unwrap(); 221 + mst = mst.delete("app.bsky.feed.post/bbbb").await.unwrap(); 222 + mst = mst.delete("app.bsky.feed.post/eeee").await.unwrap(); 223 + 224 + let mst_root_v2 = mst.persist().await.unwrap(); 225 + 226 + let diff_v2 = old_mst.diff(&mst).await.unwrap(); 227 + 228 + let rev2 = ticker.next(Some(rev1.clone())); 229 + let commit_v2 = Commit::new_unsigned( 230 + Did::new("did:plc:testuser123").unwrap(), 231 + mst_root_v2, 232 + rev2.clone(), 233 + Some(commit_v1_cid), 234 + ) 235 + .sign(&signing_key) 236 + .unwrap(); 237 + let commit_v2_cbor = commit_v2.to_cbor().unwrap(); 238 + let commit_v2_cid = compute_cid(&commit_v2_cbor); 239 + 240 + let mut blocks_v2 = diff_v2.new_mst_blocks.clone(); 241 + blocks_v2.insert(commit_v2_cid, Bytes::from(commit_v2_cbor)); 242 + blocks_v2.insert(cid_a_v2, Bytes::from(record_a_v2.clone())); 243 + 244 + let mut deleted: Vec<Cid> = diff_v2.removed_mst_blocks.clone(); 245 + deleted.extend(diff_v2.removed_cids.iter()); 246 + 247 + store 248 + .apply_commit(CommitData { 249 + cid: commit_v2_cid, 250 + rev: rev2, 251 + since: Some(rev1), 252 + prev: Some(commit_v1_cid), 253 + data: mst_root_v2, 254 + prev_data: Some(mst_root_v1), 255 + blocks: blocks_v2, 256 + relevant_blocks: BTreeMap::new(), 257 + deleted_cids: deleted, 258 + }) 259 + .await 260 + .unwrap(); 261 + 262 + let retrieved_a_v2 = store.get(&cid_a_v2).await.unwrap(); 263 + assert!(retrieved_a_v2.is_some(), "updated record A v2 should exist"); 264 + assert_eq!(&retrieved_a_v2.unwrap()[..], &record_a_v2); 265 + 266 + assert!( 267 + store.has(&cid_a_v1).await.unwrap(), 268 + "cid_a_v1 should still exist, tombstoned but not GC'd" 269 + ); 270 + assert!( 271 + store.has(&cid_b).await.unwrap(), 272 + "cid_b should still exist, tombstoned but not GC'd" 273 + ); 274 + 275 + assert!( 276 + store.has(&cid_c).await.unwrap(), 277 + "untouched record C should still exist" 278 + ); 279 + let retrieved_c = store.get(&cid_c).await.unwrap().unwrap(); 280 + assert_eq!(&retrieved_c[..], &record_c); 281 + 282 + assert!( 283 + store.has(&cid_shared).await.unwrap(), 284 + "shared-content block should still exist, tombstoned but not GC'd" 285 + ); 286 + 287 + let loaded_mst = Mst::load(storage.clone(), mst_root_v2, None); 288 + let expected_entries: Vec<(&str, Option<Cid>)> = vec![ 289 + ("app.bsky.feed.post/aaaa", Some(cid_a_v2)), 290 + ("app.bsky.feed.post/bbbb", None), 291 + ("app.bsky.feed.post/cccc", Some(cid_c)), 292 + ("app.bsky.feed.post/dddd", Some(cid_shared)), 293 + ("app.bsky.feed.post/eeee", None), 294 + ]; 295 + 296 + futures::stream::iter(expected_entries) 297 + .for_each(|(key, expected)| { 298 + let loaded_mst = &loaded_mst; 299 + async move { 300 + assert_eq!(loaded_mst.get(key).await.unwrap(), expected); 301 + } 302 + }) 303 + .await; 304 + }
+194
crates/tranquil-store/tests/proptests.rs
··· 1 + use proptest::prelude::*; 2 + use std::path::Path; 3 + 4 + use tranquil_store::{ 5 + FaultConfig, OpenOptions, ReadRecord, RecordReader, RecordWriter, SimulatedIO, StorageIO, 6 + run_crash_test, run_pristine_comparison, 7 + }; 8 + 9 + fn arb_payloads(max_count: usize, max_size: usize) -> BoxedStrategy<Vec<Vec<u8>>> { 10 + proptest::collection::vec( 11 + proptest::collection::vec(any::<u8>(), 0..max_size), 12 + 1..max_count, 13 + ) 14 + .boxed() 15 + } 16 + 17 + fn sim_with_dir(seed: u64, config: FaultConfig) -> SimulatedIO { 18 + let sim = SimulatedIO::new(seed, config); 19 + sim.mkdir(Path::new("/test")).unwrap(); 20 + sim.sync_dir(Path::new("/test")).unwrap(); 21 + sim 22 + } 23 + 24 + proptest! { 25 + #![proptest_config(ProptestConfig::with_cases(2000))] 26 + 27 + #[test] 28 + fn synced_records_survive_crash( 29 + seed in any::<u64>(), 30 + payloads in arb_payloads(20, 256), 31 + ) { 32 + let result = run_crash_test( 33 + seed, 34 + FaultConfig::none(), 35 + &payloads, 36 + 5, 37 + ).unwrap(); 38 + 39 + prop_assert_eq!(result.records_recovered, result.records_synced); 40 + prop_assert_eq!(result.corrupted_detected, 0); 41 + } 42 + 43 + #[test] 44 + fn recovered_never_exceeds_written( 45 + seed in any::<u64>(), 46 + payloads in arb_payloads(15, 128), 47 + ) { 48 + let Ok(result) = run_crash_test( 49 + seed, 50 + FaultConfig::moderate(), 51 + &payloads, 52 + 3, 53 + ) else { return Ok(()); }; 54 + 55 + prop_assert!( 56 + result.records_recovered <= result.records_written, 57 + "seed {}: recovered {} > written {}", 58 + seed, result.records_recovered, result.records_written, 59 + ); 60 + } 61 + 62 + #[test] 63 + fn no_phantom_records_after_crash( 64 + seed in any::<u64>(), 65 + payloads in arb_payloads(10, 512), 66 + ) { 67 + let Ok(result) = run_crash_test( 68 + seed, 69 + FaultConfig::aggressive(), 70 + &payloads, 71 + 0, 72 + ) else { return Ok(()); }; 73 + 74 + prop_assert!( 75 + result.records_recovered <= result.records_synced, 76 + "seed {}: recovered {} records but only {} were synced", 77 + seed, result.records_recovered, result.records_synced, 78 + ); 79 + } 80 + 81 + #[test] 82 + fn pristine_prefix_holds_under_faults( 83 + seed in any::<u64>(), 84 + payloads in arb_payloads(12, 200), 85 + ) { 86 + let Ok(result) = run_pristine_comparison( 87 + seed, 88 + FaultConfig::moderate(), 89 + &payloads, 90 + 4, 91 + ) else { return Ok(()); }; 92 + 93 + prop_assert!(result.recovery_within_bounds); 94 + } 95 + 96 + #[test] 97 + fn bit_flip_detected_by_u32_checksum( 98 + seed in any::<u64>(), 99 + payload in proptest::collection::vec(any::<u8>(), 8..1024), 100 + flip_offset in any::<usize>(), 101 + flip_bit in 0u8..8, 102 + ) { 103 + let sim = sim_with_dir(seed, FaultConfig::none()); 104 + let fd = sim.open(Path::new("/test/bitflip.dat"), OpenOptions::read_write()).unwrap(); 105 + 106 + let mut writer = RecordWriter::new(&sim, fd).unwrap(); 107 + writer.append(&payload).unwrap(); 108 + writer.sync().unwrap(); 109 + 110 + let mut contents = sim.durable_contents(fd).unwrap(); 111 + let record_region = &mut contents[HEADER_SIZE..]; 112 + let idx = flip_offset % record_region.len(); 113 + record_region[idx] ^= 1 << flip_bit; 114 + 115 + let sim2 = sim_with_dir(seed.wrapping_add(1), FaultConfig::none()); 116 + let fd2 = sim2.open(Path::new("/test/check.dat"), OpenOptions::read_write()).unwrap(); 117 + sim2.write_all_at(fd2, 0, &contents).unwrap(); 118 + sim2.sync(fd2).unwrap(); 119 + 120 + let mut reader = RecordReader::open(&sim2, fd2).unwrap(); 121 + match reader.next() { 122 + Some(ReadRecord::Valid { payload: ref recovered, .. }) => { 123 + prop_assert_eq!(recovered, &payload, "bit flip produced valid record with wrong data"); 124 + } 125 + Some(ReadRecord::Corrupted { .. }) => {} 126 + Some(ReadRecord::Truncated { .. }) => {} 127 + None => {} 128 + } 129 + } 130 + 131 + #[test] 132 + fn aggressive_faults_many_seeds( 133 + seed in 0u64..10_000, 134 + ) { 135 + let payloads: Vec<Vec<u8>> = (0..5) 136 + .map(|i| format!("aggressive-test-{i}-{seed}").into_bytes()) 137 + .collect(); 138 + let Ok(result) = run_crash_test( 139 + seed, 140 + FaultConfig::aggressive(), 141 + &payloads, 142 + 2, 143 + ) else { return Ok(()); }; 144 + 145 + prop_assert!(result.records_recovered <= payloads.len()); 146 + } 147 + 148 + #[test] 149 + fn write_all_at_handles_partial_writes( 150 + seed in any::<u64>(), 151 + data in proptest::collection::vec(any::<u8>(), 64..4096), 152 + ) { 153 + let config = FaultConfig { 154 + partial_write_probability: 0.5, 155 + ..FaultConfig::none() 156 + }; 157 + let dir = Path::new("/test"); 158 + let path = Path::new("/test/partial.dat"); 159 + let sim = sim_with_dir(seed, config); 160 + let fd = sim.open(path, OpenOptions::read_write()).unwrap(); 161 + sim.sync_dir(dir).unwrap(); 162 + 163 + sim.write_all_at(fd, 0, &data).unwrap(); 164 + sim.sync(fd).unwrap(); 165 + 166 + sim.crash(); 167 + 168 + let fd = sim.open(path, OpenOptions::read()).unwrap(); 169 + let mut buf = vec![0u8; data.len()]; 170 + sim.read_exact_at(fd, 0, &mut buf).unwrap(); 171 + prop_assert_eq!(&buf, &data, "write_all_at must produce complete writes"); 172 + } 173 + 174 + #[test] 175 + fn dir_sync_required_for_file_survival( 176 + seed in any::<u64>(), 177 + data in proptest::collection::vec(any::<u8>(), 1..256), 178 + ) { 179 + let sim = SimulatedIO::pristine(seed); 180 + sim.mkdir(Path::new("/ephemeral")).unwrap(); 181 + 182 + let fd = sim.open( 183 + Path::new("/ephemeral/file.dat"), 184 + OpenOptions::read_write(), 185 + ).unwrap(); 186 + sim.write_all_at(fd, 0, &data).unwrap(); 187 + sim.sync(fd).unwrap(); 188 + 189 + sim.crash(); 190 + 191 + let result = sim.open(Path::new("/ephemeral/file.dat"), OpenOptions::read()); 192 + prop_assert!(result.is_err(), "file must vanish without dir sync"); 193 + } 194 + }
+4 -2
crates/tranquil-sync/src/blob.rs
··· 93 93 94 94 let cids_result: Result<Vec<String>, _> = if let Some(since) = &params.since { 95 95 state 96 - .repos.blob 96 + .repos 97 + .blob 97 98 .list_blobs_since_rev(&did, since) 98 99 .await 99 100 .map(|cids| { ··· 107 108 }) 108 109 } else { 109 110 state 110 - .repos.blob 111 + .repos 112 + .blob 111 113 .list_blobs_by_user(user_id, Some(cursor_cid), limit + 1) 112 114 .await 113 115 .map(|cids| cids.into_iter().map(|c| c.to_string()).collect())
+2 -1
crates/tranquil-sync/src/commit.rs
··· 104 104 let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|s| s.parse().ok()); 105 105 let cursor_ref = cursor_did.as_ref(); 106 106 let result = state 107 - .repos.repo 107 + .repos 108 + .repo 108 109 .list_repos_paginated(cursor_ref, limit + 1) 109 110 .await; 110 111 match result {
+10 -5
crates/tranquil-sync/src/listener.rs
··· 8 8 9 9 pub async fn start_sequencer_listener(state: AppState) { 10 10 let initial_seq = state 11 - .repos.repo 11 + .repos 12 + .repo 12 13 .get_max_seq() 13 14 .await 14 15 .unwrap_or(SequenceNumber::ZERO); ··· 30 31 31 32 async fn listen_loop(state: AppState) -> anyhow::Result<()> { 32 33 let mut receiver = state 33 - .repos.event_notifier 34 + .repos 35 + .event_notifier 34 36 .subscribe() 35 37 .await 36 38 .map_err(|e| anyhow::anyhow!("Failed to subscribe to events: {:?}", e))?; 37 39 info!("Connected to database and listening for repo updates"); 38 40 let catchup_start = SequenceNumber::from_raw(LAST_BROADCAST_SEQ.load(Ordering::SeqCst)); 39 41 let events = state 40 - .repos.repo 42 + .repos 43 + .repo 41 44 .get_events_since_seq(catchup_start, None) 42 45 .await 43 46 .map_err(|e| anyhow::anyhow!("Failed to fetch catchup events: {:?}", e))?; ··· 70 73 } 71 74 if seq_id > last_seq + 1 { 72 75 let gap_events = state 73 - .repos.repo 76 + .repos 77 + .repo 74 78 .get_events_in_seq_range( 75 79 SequenceNumber::from_raw(last_seq), 76 80 SequenceNumber::from_raw(seq_id), ··· 88 92 } 89 93 } 90 94 let event = state 91 - .repos.repo 95 + .repos 96 + .repo 92 97 .get_event_by_seq(SequenceNumber::from_raw(seq_id)) 93 98 .await 94 99 .ok()
+2 -1
crates/tranquil-sync/src/repo.rs
··· 179 179 }; 180 180 181 181 let block_cid_bytes = match state 182 - .repos.repo 182 + .repos 183 + .repo 183 184 .get_user_block_cids_since_rev(user_id, since) 184 185 .await 185 186 {
+4
justfile
··· 19 19 cargo fmt -- --check 20 20 lint: fmt-check clippy 21 21 22 + test-store: 23 + SQLX_OFFLINE=true cargo nextest run -p tranquil-store --features tranquil-store/test-harness 24 + 22 25 test-unit: 23 26 SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases 24 27 ··· 50 53 ./scripts/run-tests.sh --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit 51 54 52 55 test *args: 56 + @just test-store 53 57 @just test-unit 54 58 ./scripts/run-tests.sh {{args}} 55 59