mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-24 18:26:06 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfadc936c0 | ||
|
|
80afd764d3 | ||
|
|
5bbe2146ff | ||
|
|
b009ccdaf2 | ||
|
|
39f74b5adf | ||
|
|
a231d7da29 | ||
|
|
63d84d38fb | ||
|
|
fe9b88141c | ||
|
|
72f5dce32b | ||
|
|
cd7e01100e | ||
|
|
7c248be153 | ||
|
|
91999819c6 | ||
|
|
ffce1d5d05 | ||
|
|
8e6ace2fe2 | ||
|
|
3018a20843 | ||
|
|
37fc06fb39 | ||
|
|
728a8c4d3b | ||
|
|
3d49e99cc3 | ||
|
|
7e823673ca | ||
|
|
320933598c | ||
|
|
500dc2e0e6 | ||
|
|
a220611a8b | ||
|
|
ca7a4b4b73 | ||
|
|
8ff02610e4 | ||
|
|
22f82489d5 | ||
|
|
cee483e358 | ||
|
|
7f8e858137 | ||
|
|
44d73dac58 | ||
|
|
b8cae15c12 | ||
|
|
9b58961bba | ||
|
|
31ee12ecd3 | ||
|
|
ea106d5246 | ||
|
|
4015217a2e |
@@ -106,6 +106,16 @@ slow-timeout = { period = "300s", terminate-after = 8 }
|
||||
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
|
||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(fd_lifecycle)"
|
||||
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
@@ -137,3 +147,13 @@ test-group = "heavy-load-tests"
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(repo_lifecycle)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(fd_lifecycle)"
|
||||
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
target/
|
||||
.git/
|
||||
.jj/
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
frontend/coverage/
|
||||
frontend/.pnpm-store/
|
||||
.direnv/
|
||||
result
|
||||
.env
|
||||
*.output
|
||||
reference-pds-bsky/
|
||||
@@ -4,5 +4,6 @@ target/
|
||||
result
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/coverage/
|
||||
frontend/.pnpm-store
|
||||
frontend/.npmrc
|
||||
|
||||
Generated
+170
-32
@@ -210,7 +210,7 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-derive 0.5.1",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
@@ -220,6 +220,22 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive 0.6.0",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.5.1"
|
||||
@@ -232,6 +248,18 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
@@ -1046,7 +1074,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1055,6 +1083,15 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -1970,7 +2007,21 @@ version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
@@ -2834,6 +2885,34 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3-quinn"
|
||||
version = "0.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"h3",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -4614,7 +4693,16 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5184,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
@@ -5427,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -5630,6 +5719,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser 0.18.1",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.1.0"
|
||||
@@ -7527,7 +7630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7578,7 +7681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7601,7 +7704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7615,7 +7718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7641,7 +7744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7649,7 +7752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7665,7 +7768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7682,7 +7785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7698,7 +7801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7709,7 +7812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7728,7 +7831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7751,7 +7854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7784,7 +7887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7837,6 +7940,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7877,7 +7981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7889,7 +7993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -7914,7 +8018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7930,17 +8034,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"bytes",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"h3",
|
||||
"h3-quinn",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pemfile",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7960,7 +8070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7983,7 +8093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8000,7 +8110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8049,7 +8159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8071,7 +8181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -8584,7 +8694,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"hex",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
@@ -8600,7 +8710,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
"x509-parser 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9154,17 +9264,35 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"oid-registry 0.7.1",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"data-encoding",
|
||||
"der-parser 10.0.0",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry 0.8.1",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -9193,6 +9321,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
+5
-1
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -82,6 +82,8 @@ foca = { version = "1", features = ["bincode-codec", "tracing"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
governor = "0.10"
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
hex = "0.4"
|
||||
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
||||
hkdf = "0.12"
|
||||
@@ -106,7 +108,9 @@ parking_lot = "0.12"
|
||||
multihash = "0.19"
|
||||
p256 = { version = "0.13", features = ["ecdsa"] }
|
||||
p384 = { version = "0.13", features = ["ecdsa"] }
|
||||
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] }
|
||||
rand = "0.8"
|
||||
rcgen = { version = "0.14", default-features = false, features = ["ring", "pem"] }
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||
regex = "1"
|
||||
rsa = "0.9"
|
||||
|
||||
@@ -92,6 +92,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
||||
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
||||
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
||||
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
|
||||
- [@ave.zone](https://tangled.org/did:plc:mchrltkrhuzpxleiwpmmvpar)
|
||||
- Could be your name here too!
|
||||
|
||||
### Tranquil PDS instances in the wild!
|
||||
|
||||
@@ -3,5 +3,5 @@ mod sign;
|
||||
mod submit;
|
||||
|
||||
pub use request::request_plc_operation_signature;
|
||||
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||
pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
|
||||
|
||||
@@ -6,10 +6,6 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
pub async fn request_plc_operation_signature(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
@@ -28,12 +24,13 @@ pub async fn request_plc_operation_signature(
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
|
||||
let plc_token = generate_plc_token();
|
||||
let display_token = tranquil_pds::util::generate_token_code();
|
||||
let stored_token = tranquil_pds::util::normalize_token_code(&display_token);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
state
|
||||
.repos
|
||||
.infra
|
||||
.insert_plc_token(user_id, &plc_token, expires_at)
|
||||
.insert_plc_token(user_id, &stored_token, expires_at)
|
||||
.await
|
||||
.log_db_err("creating PLC token")?;
|
||||
|
||||
@@ -42,7 +39,7 @@ pub async fn request_plc_operation_signature(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&plc_token,
|
||||
&display_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -9,7 +9,7 @@ use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
||||
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
||||
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -19,14 +19,7 @@ pub struct SignPlcOperationInput {
|
||||
pub rotation_keys: Option<Vec<String>>,
|
||||
pub also_known_as: Option<Vec<String>>,
|
||||
pub verification_methods: Option<HashMap<String, String>>,
|
||||
pub services: Option<HashMap<String, ServiceInput>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServiceInput {
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: ServiceType,
|
||||
pub endpoint: String,
|
||||
pub services: Option<HashMap<String, PlcService>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -50,9 +43,10 @@ pub async fn sign_plc_operation(
|
||||
"PLC operations are only valid for did:plc identities".into(),
|
||||
));
|
||||
}
|
||||
let token = input.token.as_ref().ok_or_else(|| {
|
||||
let raw_token = input.token.as_ref().ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
|
||||
})?;
|
||||
let token = &tranquil_pds::util::normalize_token_code(raw_token);
|
||||
|
||||
let user_id = state
|
||||
.repos
|
||||
@@ -106,25 +100,12 @@ pub async fn sign_plc_operation(
|
||||
if last_op.is_tombstone() {
|
||||
return Err(ApiError::from(PlcError::Tombstoned));
|
||||
}
|
||||
let services = input.services.map(|s| {
|
||||
s.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
PlcService {
|
||||
service_type: v.service_type,
|
||||
endpoint: v.endpoint,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let unsigned_op = create_update_op(
|
||||
&last_op,
|
||||
input.rotation_keys,
|
||||
input.verification_methods,
|
||||
input.also_known_as,
|
||||
services,
|
||||
input.services,
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
|
||||
|
||||
@@ -15,8 +15,12 @@ pub mod temp;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
pub fn api_routes() -> axum::Router<AppState> {
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
@@ -68,7 +72,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route("/com.atproto.repo.deleteRecord", post(repo::delete_record))
|
||||
.route("/com.atproto.repo.listRecords", get(repo::list_records))
|
||||
.route("/com.atproto.repo.describeRepo", get(repo::describe_repo))
|
||||
.route("/com.atproto.repo.uploadBlob", post(repo::upload_blob))
|
||||
.route(
|
||||
"/com.atproto.repo.uploadBlob",
|
||||
post(repo::upload_blob).layer(blob_body_limit),
|
||||
)
|
||||
.route("/com.atproto.repo.applyWrites", post(repo::apply_writes))
|
||||
.route(
|
||||
"/com.atproto.server.checkAccountStatus",
|
||||
@@ -247,7 +254,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
"/_identity.verifyHandleOwnership",
|
||||
post(identity::verify_handle_ownership),
|
||||
)
|
||||
.route("/com.atproto.repo.importRepo", post(repo::import_repo))
|
||||
.route(
|
||||
"/com.atproto.repo.importRepo",
|
||||
post(repo::import_repo).layer(blob_body_limit),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.admin.deleteAccount",
|
||||
post(admin::delete_account),
|
||||
|
||||
@@ -77,27 +77,6 @@ pub async fn import_repo(
|
||||
blocks.len(),
|
||||
root
|
||||
);
|
||||
let Some(root_block) = blocks.get(&root) else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Root block not found in CAR file".into(),
|
||||
));
|
||||
};
|
||||
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit
|
||||
.did()
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
|
||||
Err(e) => {
|
||||
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
|
||||
}
|
||||
};
|
||||
if commit_did != *did {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
)));
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -108,11 +87,13 @@ pub async fn import_repo(
|
||||
});
|
||||
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
|
||||
if skip_verification {
|
||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
} else if is_migration {
|
||||
debug!("Verifying CAR file structure for migration (skipping signature verification)");
|
||||
warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
} else {
|
||||
debug!(
|
||||
"Verifying CAR file structure for repo import (skipping signature and DID verification)"
|
||||
);
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car_structure_only(did, &root, &blocks) {
|
||||
match verifier.verify_car_structure_only(&root, &blocks) {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR structure verification successful: rev={}, data_cid={}",
|
||||
@@ -142,56 +123,6 @@ pub async fn import_repo(
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Verifying CAR file signature and structure for DID {}", did);
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car(did, &root, &blocks).await {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR verification successful: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::InvalidSignature) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"CAR file commit signature verification failed".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
|
||||
warn!("DID resolution failed during import verification: {}", msg);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Failed to verify DID: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::NoSigningKey) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"DID document does not contain a signing key".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"MST validation failed: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR verification error: {:?}", e);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"CAR verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let max_blocks = tranquil_config::get().import.max_blocks as usize;
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
||||
use crate::repo::record::write::CommitInfo;
|
||||
use crate::repo::record::write::{CommitInfo, ensure_record_type};
|
||||
use axum::{Json, extract::State};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,8 +14,8 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::repo::TrackingBlockStore;
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write,
|
||||
CommitResult, FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
@@ -54,6 +54,8 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let value = ensure_record_type(value, collection);
|
||||
let value = &*value;
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
@@ -72,7 +74,7 @@ async fn process_single_write(
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
@@ -90,7 +92,7 @@ async fn process_single_write(
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
let uri = AtUri::from_parts(did, collection, &rkey);
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
results.push(WriteResult::CreateResult {
|
||||
@@ -117,6 +119,8 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let value = ensure_record_type(value, collection);
|
||||
let value = &*value;
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
@@ -134,9 +138,7 @@ async fn process_single_write(
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
||||
})?
|
||||
.map_err(|e| ApiError::from_mst_error("read update target from MST", &e))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||
})?;
|
||||
@@ -151,7 +153,7 @@ async fn process_single_write(
|
||||
let new_mst = mst
|
||||
.update(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||
let uri = AtUri::from_parts(did, collection, rkey);
|
||||
backlinks_to_remove.push(uri.clone());
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
@@ -180,16 +182,14 @@ async fn process_single_write(
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
||||
})?
|
||||
.map_err(|e| ApiError::from_mst_error("read delete target from MST", &e))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Delete target record does not exist".into())
|
||||
})?;
|
||||
let new_mst = mst
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
|
||||
results.push(WriteResult::DeleteResult {});
|
||||
ops.push(RecordOp::Delete {
|
||||
@@ -232,6 +232,45 @@ async fn process_writes(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_apply_writes(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &Did,
|
||||
input: &ApplyWritesInput,
|
||||
controller_did: Option<&Did>,
|
||||
write_summary: Option<serde_json::Value>,
|
||||
) -> Result<(CommitResult, Vec<WriteResult>), ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let WriteAccumulator {
|
||||
mst: final_mst,
|
||||
results,
|
||||
ops,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = process_writes(&input.writes, mst, did, input.validate, &ctx.tracking_store).await?;
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
state,
|
||||
ctx,
|
||||
final_mst,
|
||||
FinalizeParams {
|
||||
did,
|
||||
user_id,
|
||||
controller_did,
|
||||
delegation_detail: write_summary,
|
||||
ops,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((commit_result, results))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "$type")]
|
||||
pub enum WriteOp {
|
||||
@@ -346,24 +385,6 @@ pub async fn apply_writes(
|
||||
.log_db_err("fetching user for batch write")?
|
||||
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let WriteAccumulator {
|
||||
mst: final_mst,
|
||||
results,
|
||||
ops,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = process_writes(
|
||||
&input.writes,
|
||||
mst,
|
||||
&did,
|
||||
input.validate,
|
||||
&ctx.tracking_store,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
|
||||
let writes: Vec<serde_json::Value> = input
|
||||
.writes
|
||||
@@ -397,21 +418,16 @@ pub async fn apply_writes(
|
||||
})
|
||||
});
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
ctx,
|
||||
final_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
let (commit_result, results) = with_repair_retry(&state, user_id, || {
|
||||
execute_apply_writes(
|
||||
&state,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: write_summary,
|
||||
ops,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
&did,
|
||||
&input,
|
||||
controller_did.as_ref(),
|
||||
write_summary.clone(),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApplyWritesOutput {
|
||||
|
||||
@@ -4,13 +4,15 @@ use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{Active, Auth, VerifyScope};
|
||||
use tranquil_pds::cid_types::RecordCid;
|
||||
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordInput {
|
||||
@@ -41,13 +43,30 @@ pub async fn delete_record(
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
delete_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn delete_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &DeleteRecordInput,
|
||||
) -> Result<DeleteRecordOutput, ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
let actual_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read swap target from MST", &e))?;
|
||||
if expected_cid != actual_cid {
|
||||
return Err(ApiError::InvalidSwap(Some(
|
||||
"Record has been modified or does not exist".into(),
|
||||
@@ -55,18 +74,18 @@ pub async fn delete_record(
|
||||
}
|
||||
}
|
||||
|
||||
let prev_record_cid = mst.get(&key).await.map_err(|e| {
|
||||
error!("Failed to read prev record from MST: {}", e);
|
||||
ApiError::InternalError(Some("Failed to read MST".into()))
|
||||
})?;
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read prev record from MST", &e))?;
|
||||
let Some(prev_record_cid) = prev_record_cid else {
|
||||
return Ok(Json(DeleteRecordOutput { commit: None }));
|
||||
return Ok(DeleteRecordOutput { commit: None });
|
||||
};
|
||||
|
||||
let new_mst = mst.delete(&key).await.map_err(|e| {
|
||||
error!("Failed to delete from MST: {}", e);
|
||||
ApiError::InternalError(Some("Failed to delete from MST".into()))
|
||||
})?;
|
||||
let new_mst = mst
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||
|
||||
let op = RecordOp::Delete {
|
||||
collection: input.collection.clone(),
|
||||
@@ -74,17 +93,17 @@ pub async fn delete_record(
|
||||
prev: RecordCid::from(prev_record_cid),
|
||||
};
|
||||
|
||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
let deleted_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
new_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": "delete",
|
||||
"collection": input.collection,
|
||||
@@ -99,10 +118,10 @@ pub async fn delete_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(DeleteRecordOutput {
|
||||
Ok(DeleteRecordOutput {
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use cid::Cid;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write,
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
@@ -62,6 +62,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_record_type<'a>(
|
||||
record: &'a serde_json::Value,
|
||||
collection: &Nsid,
|
||||
) -> Cow<'a, serde_json::Value> {
|
||||
let serde_json::Value::Object(map) = record else {
|
||||
return Cow::Borrowed(record);
|
||||
};
|
||||
let needs_fill = match map.get("$type") {
|
||||
None | Some(serde_json::Value::Null) => true,
|
||||
Some(serde_json::Value::String(existing)) => existing.is_empty(),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !needs_fill {
|
||||
return Cow::Borrowed(record);
|
||||
}
|
||||
let mut map = map.clone();
|
||||
map.insert(
|
||||
"$type".to_string(),
|
||||
serde_json::Value::String(collection.to_string()),
|
||||
);
|
||||
Cow::Owned(serde_json::Value::Object(map))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreateRecordInput {
|
||||
@@ -95,15 +118,32 @@ pub struct CreateRecordOutput {
|
||||
pub async fn create_record(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<CreateRecordInput>,
|
||||
Json(mut input): Json<CreateRecordInput>,
|
||||
) -> Result<Json<CreateRecordOutput>, ApiError> {
|
||||
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||
input.record = record;
|
||||
}
|
||||
let scope_proof = auth.verify_repo_create(&input.collection)?;
|
||||
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mut mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
create_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn create_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &CreateRecordInput,
|
||||
) -> Result<CreateRecordOutput, ApiError> {
|
||||
let (ctx, mut mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
@@ -119,12 +159,12 @@ pub async fn create_record(
|
||||
)
|
||||
};
|
||||
|
||||
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
|
||||
let rkey = input.rkey.clone().unwrap_or_else(Rkey::generate);
|
||||
let mut ops: Vec<RecordOp> = Vec::new();
|
||||
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
|
||||
|
||||
if !input.validate.should_skip() {
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let record_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||
let backlinks = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
if !backlinks.is_empty() {
|
||||
@@ -149,24 +189,18 @@ pub async fn create_record(
|
||||
Ok(Some(cid)) => cid,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to read conflict record from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
return Err(ApiError::InternalError(Some(
|
||||
"Failed to read conflicting record from MST".into(),
|
||||
)));
|
||||
return Err(ApiError::from_mst_error(
|
||||
&format!("read conflict record from MST {conflict_uri}"),
|
||||
&e,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
mst = mst.delete(&conflict_key).await.map_err(|e| {
|
||||
error!(
|
||||
"Failed to delete conflict from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
ApiError::InternalError(Some(
|
||||
"Failed to delete conflicting record from MST".into(),
|
||||
))
|
||||
ApiError::from_mst_error(
|
||||
&format!("delete conflict from MST {conflict_uri}"),
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
ops.push(RecordOp::Delete {
|
||||
@@ -183,7 +217,7 @@ pub async fn create_record(
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
@@ -202,7 +236,7 @@ pub async fn create_record(
|
||||
mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
|
||||
ops.push(RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
@@ -212,18 +246,18 @@ pub async fn create_record(
|
||||
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
|
||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let created_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": "create",
|
||||
"collection": input.collection,
|
||||
@@ -238,7 +272,7 @@ pub async fn create_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreateRecordOutput {
|
||||
Ok(CreateRecordOutput {
|
||||
uri: created_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: CommitInfo {
|
||||
@@ -246,7 +280,7 @@ pub async fn create_record(
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -278,15 +312,32 @@ pub struct PutRecordOutput {
|
||||
pub async fn put_record(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<PutRecordInput>,
|
||||
Json(mut input): Json<PutRecordInput>,
|
||||
) -> Result<Json<PutRecordOutput>, ApiError> {
|
||||
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||
input.record = record;
|
||||
}
|
||||
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
|
||||
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
put_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn put_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &PutRecordInput,
|
||||
) -> Result<PutRecordOutput, ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
@@ -304,9 +355,13 @@ pub async fn put_record(
|
||||
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
|
||||
let read_cid = |r: Result<Option<Cid>, jacquard_repo::error::RepoError>| {
|
||||
r.map_err(|e| ApiError::from_mst_error("read MST for put", &e))
|
||||
};
|
||||
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
let actual_cid = read_cid(mst.get(&key).await)?;
|
||||
if expected_cid != actual_cid {
|
||||
return Err(ApiError::InvalidSwap(Some(
|
||||
"Record has been modified or does not exist".into(),
|
||||
@@ -314,7 +369,7 @@ pub async fn put_record(
|
||||
}
|
||||
}
|
||||
|
||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
||||
let existing_cid = read_cid(mst.get(&key).await)?;
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -325,21 +380,21 @@ pub async fn put_record(
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
if existing_cid == Some(record_cid) {
|
||||
return Ok(Json(PutRecordOutput {
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
return Ok(PutRecordOutput {
|
||||
uri: AtUri::from_parts(did, &input.collection, &input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: None,
|
||||
validation_status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
|
||||
Some(prev_cid) => {
|
||||
let new_mst = mst
|
||||
.update(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||
let op = RecordOp::Update {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
@@ -352,7 +407,7 @@ pub async fn put_record(
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
let op = RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
@@ -366,14 +421,14 @@ pub async fn put_record(
|
||||
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
new_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": if is_update { "update" } else { "create" },
|
||||
"collection": input.collection,
|
||||
@@ -388,7 +443,7 @@ pub async fn put_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(PutRecordOutput {
|
||||
Ok(PutRecordOutput {
|
||||
uri: record_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: Some(CommitInfo {
|
||||
@@ -396,5 +451,5 @@ pub async fn put_record(
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_reset_code() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
#[serde(alias = "identifier")]
|
||||
@@ -70,12 +66,13 @@ pub async fn request_password_reset(
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
let code = generate_reset_code();
|
||||
let display_code = tranquil_pds::util::generate_token_code();
|
||||
let stored_code = tranquil_pds::util::normalize_token_code(&display_code);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.set_password_reset_code(user_id, &code, expires_at)
|
||||
.set_password_reset_code(user_id, &stored_code, expires_at)
|
||||
.await
|
||||
{
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
@@ -86,7 +83,7 @@ pub async fn request_password_reset(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&code,
|
||||
&display_code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
@@ -133,7 +130,13 @@ pub async fn reset_password(
|
||||
if let Err(e) = validate_password(password) {
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
let user = match state.repos.user.get_user_by_reset_code(token).await {
|
||||
let normalized_token = tranquil_pds::util::normalize_token_code(token);
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_by_reset_code(&normalized_token)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
|
||||
@@ -15,7 +15,10 @@ use tranquil_pds::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
|
||||
require_reauth_window,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use tranquil_pds::rate_limit::{
|
||||
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use tranquil_types::TokenId;
|
||||
@@ -168,17 +171,35 @@ pub async fn create_session(
|
||||
let has_totp = row.totp_enabled;
|
||||
let email_2fa_enabled = row.email_2fa_enabled;
|
||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
|
||||
has_totp,
|
||||
input.auth_factor_token.as_deref(),
|
||||
);
|
||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||
is_app_password: app_password_name.is_some(),
|
||||
email_2fa_enabled,
|
||||
has_totp,
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
};
|
||||
if let Some(token) = input.auth_factor_token.as_deref()
|
||||
&& has_totp
|
||||
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
|
||||
{
|
||||
check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
row.did.as_str(),
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
|
||||
state.cache.as_ref(),
|
||||
&row.did,
|
||||
&twofa_ctx,
|
||||
input.auth_factor_token.as_deref(),
|
||||
async |t: &str| {
|
||||
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -285,7 +306,7 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
if is_legacy_login {
|
||||
if is_legacy_login && !used_totp_factor {
|
||||
warn!(
|
||||
did = %row.did,
|
||||
ip = %client_ip,
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub fn create_access_token_with_delegation(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
act,
|
||||
hostname,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ pub fn create_refresh_token_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ impl TokenType {
|
||||
match self {
|
||||
Self::Access => "at+jwt",
|
||||
Self::Refresh => "refresh+jwt",
|
||||
Self::Service => "jwt",
|
||||
// RFC 7519 §5.1 recommends the uppercase "JWT".
|
||||
// and for atproto inter-service auth its a requirement.
|
||||
Self::Service => "JWT",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +292,17 @@ mod tests {
|
||||
assert!(TokenType::from_str("bearer").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_token_header_serializes_typ_as_uppercase_jwt() {
|
||||
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
|
||||
let header = Header {
|
||||
alg: SigningAlgorithm::ES256K,
|
||||
typ: TokenType::Service,
|
||||
};
|
||||
let json = serde_json::to_string(&header).expect("serialize header");
|
||||
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -513,6 +513,11 @@ pub struct TlsConfig {
|
||||
/// Path to the TLS private key.
|
||||
#[config(env = "TLS_KEY_PATH")]
|
||||
pub key_path: Option<String>,
|
||||
|
||||
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
|
||||
/// Requires cert_path and key_path.
|
||||
#[config(env = "TLS_HTTP3", default = false)]
|
||||
pub http3: bool,
|
||||
}
|
||||
|
||||
impl TlsConfig {
|
||||
@@ -532,6 +537,13 @@ impl TlsConfig {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.http3 && self.material().is_none() {
|
||||
errors.push(
|
||||
"server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \
|
||||
and erver.tls.key_path"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,6 +1811,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1810,6 +1823,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: Some("/etc/tranquil/key.pem".to_string()),
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1821,6 +1835,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
@@ -1829,6 +1844,21 @@ port = 587
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_validate_rejects_http3_without_material() {
|
||||
let mut errors = Vec::new();
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: true,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("http3")),
|
||||
"expected http3 error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmailOverrides {
|
||||
from_address: Option<&'static str>,
|
||||
|
||||
@@ -94,6 +94,7 @@ frontend = []
|
||||
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
ciborium = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
testcontainers = { workspace = true }
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ErrorBody<'a> {
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
InternalError(Option<String>),
|
||||
RepoCorruption,
|
||||
AuthenticationRequired,
|
||||
AuthenticationFailed(Option<String>),
|
||||
InvalidRequest(String),
|
||||
@@ -121,10 +122,34 @@ pub enum ApiError {
|
||||
},
|
||||
}
|
||||
|
||||
const MST_NODE_MISSING_MARKER: &str = "MST node not found";
|
||||
|
||||
impl ApiError {
|
||||
pub fn is_repo_corruption(&self) -> bool {
|
||||
matches!(self, Self::RepoCorruption)
|
||||
}
|
||||
|
||||
pub fn detail_is_repo_corruption(detail: &str) -> bool {
|
||||
detail.contains(tranquil_store::blockstore::BLOCK_CORRUPTION_MARKER)
|
||||
|| detail.contains(MST_NODE_MISSING_MARKER)
|
||||
}
|
||||
|
||||
pub fn from_mst_error(context: &str, e: &jacquard_repo::error::RepoError) -> Self {
|
||||
let detail = format!("{e:#}");
|
||||
if Self::detail_is_repo_corruption(&detail) {
|
||||
tracing::warn!("{context}: repairable MST damage: {detail}");
|
||||
Self::RepoCorruption
|
||||
} else {
|
||||
tracing::error!("{context}: {detail}");
|
||||
Self::InternalError(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
@@ -223,7 +248,9 @@ impl ApiError {
|
||||
}
|
||||
fn error_name(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
|
||||
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||
Cow::Borrowed("InternalServerError")
|
||||
}
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
Cow::Borrowed("UpstreamError")
|
||||
}
|
||||
@@ -332,6 +359,7 @@ impl ApiError {
|
||||
Self::InternalError(msg) => msg
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Internal Server Error".into()),
|
||||
Self::RepoCorruption => "Internal Server Error".into(),
|
||||
Self::AuthenticationFailed(msg) => msg
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Authentication failed".into()),
|
||||
|
||||
@@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool {
|
||||
PROTECTED_METHODS.contains(method)
|
||||
}
|
||||
|
||||
/// Fetch the `feed` generator record from the AppView and return its `did`.
|
||||
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GetFeedQuery {
|
||||
feed: String,
|
||||
}
|
||||
|
||||
let feed = serde_urlencoded::from_str::<GetFeedQuery>(query?)
|
||||
.ok()?
|
||||
.feed;
|
||||
let at_uri = crate::types::AtUri::new(feed).ok()?;
|
||||
let repo = at_uri.did()?;
|
||||
let collection = at_uri.collection()?;
|
||||
let rkey = at_uri.rkey()?;
|
||||
|
||||
let resp = proxy_client()
|
||||
.get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord"))
|
||||
.query(&[("repo", repo), ("collection", collection), ("rkey", rkey)])
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed");
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("value")?.get("did")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
pub struct XrpcProxyLayer {
|
||||
state: AppState,
|
||||
}
|
||||
@@ -287,10 +316,27 @@ async fn proxy_handler(
|
||||
},
|
||||
};
|
||||
|
||||
// getFeed must be audienced to the feed generator, not the AppView.
|
||||
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
|
||||
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||
Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"),
|
||||
None => {
|
||||
warn!(
|
||||
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||
to mint an AppView-audienced token"
|
||||
);
|
||||
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(resolved.did.clone(), method)
|
||||
};
|
||||
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&resolved.did,
|
||||
Some(method),
|
||||
&token_aud,
|
||||
Some(token_lxm),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(new_token) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const TOKEN_TTL_SECS: u64 = 900;
|
||||
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmailTokenPurpose {
|
||||
@@ -46,14 +45,6 @@ fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
|
||||
format!("email_token:{}:{}", purpose.as_str(), did)
|
||||
}
|
||||
|
||||
fn generate_short_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let token: String = (0..10)
|
||||
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
|
||||
.collect();
|
||||
format!("{}-{}", &token[0..5], &token[5..10])
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
@@ -67,9 +58,9 @@ pub async fn create_email_token(
|
||||
return Err(TokenError::CacheUnavailable);
|
||||
}
|
||||
|
||||
let token = generate_short_token();
|
||||
let token = generate_token_code();
|
||||
let data = TokenData {
|
||||
token: token.clone(),
|
||||
token: normalize_token_code(&token),
|
||||
created_at: current_timestamp(),
|
||||
};
|
||||
|
||||
@@ -108,10 +99,9 @@ pub async fn validate_email_token(
|
||||
return Err(TokenError::ExpiredToken);
|
||||
}
|
||||
|
||||
let normalized_input = token.to_uppercase().replace('-', "");
|
||||
let normalized_stored = data.token.to_uppercase().replace('-', "");
|
||||
let normalized_input = normalize_token_code(token);
|
||||
|
||||
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.token.as_bytes()) {
|
||||
return Err(TokenError::InvalidToken);
|
||||
}
|
||||
|
||||
@@ -260,20 +250,19 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_format() {
|
||||
(0..100).for_each(|_| {
|
||||
let token = generate_short_token();
|
||||
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
(0..50).for_each(|_| {
|
||||
let token = futures::executor::block_on(create_email_token(
|
||||
&cache,
|
||||
did,
|
||||
EmailTokenPurpose::UpdateEmail,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(token.len(), 11);
|
||||
assert_eq!(&token[5..6], "-");
|
||||
assert!(
|
||||
token[0..5]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert!(
|
||||
token[6..11]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert_eq!(token, token.to_uppercase());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +281,21 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
|
||||
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let no_hyphen = token.replace('-', "");
|
||||
let result =
|
||||
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_noop_cache_returns_unavailable() {
|
||||
let cache = crate::cache::NoOpCache;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::types::Did;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const CHALLENGE_TTL_SECS: u64 = 300;
|
||||
const MIN_REMAINING_TTL_SECS: u64 = 10;
|
||||
const MAX_ATTEMPTS: u8 = 5;
|
||||
const CODE_LENGTH: usize = 8;
|
||||
const COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -94,7 +93,8 @@ async fn validate_challenge_internal(
|
||||
return Err(ValidationError::ChallengeExpired);
|
||||
}
|
||||
|
||||
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
|
||||
let normalized_input = normalize_token_code(code);
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.code.as_bytes()) {
|
||||
let updated = ChallengeData {
|
||||
code: data.code,
|
||||
attempts: data.attempts + 1,
|
||||
@@ -127,17 +127,19 @@ fn cooldown_key(did: &str) -> String {
|
||||
format!("legacy_2fa_cooldown:{}", did)
|
||||
}
|
||||
|
||||
fn generate_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..CODE_LENGTH)
|
||||
.map(|_| rng.gen_range(0..10).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn looks_like_totp_token(code: &str) -> bool {
|
||||
let c = code.trim();
|
||||
(c.len() == 6 && c.bytes().all(|b| b.is_ascii_digit())) || crate::auth::is_backup_code_format(c)
|
||||
}
|
||||
|
||||
pub fn used_totp_factor(has_totp: bool, auth_factor_token: Option<&str>) -> bool {
|
||||
has_totp && auth_factor_token.is_some_and(looks_like_totp_token)
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -148,6 +150,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
== 0
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Legacy2faOutcome {
|
||||
NotRequired,
|
||||
Blocked,
|
||||
@@ -177,6 +180,7 @@ pub async fn process_legacy_2fa(
|
||||
did: &Did,
|
||||
ctx: &Legacy2faContext,
|
||||
auth_factor_token: Option<&str>,
|
||||
verify_totp: impl AsyncFnOnce(&str) -> bool,
|
||||
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
|
||||
if !ctx.requires_2fa() {
|
||||
return Ok(Legacy2faOutcome::NotRequired);
|
||||
@@ -192,8 +196,16 @@ pub async fn process_legacy_2fa(
|
||||
Ok(Legacy2faOutcome::ChallengeSent(code))
|
||||
}
|
||||
Some(token) => {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
if ctx.has_totp && looks_like_totp_token(token) {
|
||||
if verify_totp(token).await {
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
} else {
|
||||
Err(Legacy2faFlowError::Validation(ValidationError::InvalidCode))
|
||||
}
|
||||
} else {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,11 +231,11 @@ async fn create_challenge_code(
|
||||
return Err(ChallengeError::RateLimited);
|
||||
}
|
||||
|
||||
let code = generate_code();
|
||||
let display = generate_token_code();
|
||||
let now = current_timestamp();
|
||||
|
||||
let data = ChallengeData {
|
||||
code: code.clone(),
|
||||
code: normalize_token_code(&display),
|
||||
attempts: 0,
|
||||
created_at: now,
|
||||
};
|
||||
@@ -244,7 +256,7 @@ async fn create_challenge_code(
|
||||
.await
|
||||
.map_err(|_| ChallengeError::CacheError)?;
|
||||
|
||||
Ok(ChallengeCode(code))
|
||||
Ok(ChallengeCode(display))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -332,12 +344,46 @@ mod tests {
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
assert_eq!(code.as_str().len(), CODE_LENGTH);
|
||||
assert_eq!(code.as_str().len(), 11);
|
||||
|
||||
let result = validate_challenge(&cache, &did, code.as_str()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_challenge_code_format() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let code = code.as_str();
|
||||
assert_eq!(code.len(), 11);
|
||||
assert_eq!(&code[5..6], "-");
|
||||
assert_eq!(code, code.to_uppercase());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_case_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let lowercase = code.as_str().to_lowercase();
|
||||
let result = validate_challenge(&cache, &did, &lowercase).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let no_hyphen = code.as_str().replace('-', "");
|
||||
let result = validate_challenge(&cache, &did, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_code_rejected() {
|
||||
let cache = MockCache::new();
|
||||
@@ -396,15 +442,6 @@ mod tests {
|
||||
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_code_generation_is_numeric() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_code();
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
assert_eq!(code.len(), CODE_LENGTH);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_constant_time_eq() {
|
||||
assert!(constant_time_eq(b"12345678", b"12345678"));
|
||||
@@ -425,7 +462,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -440,7 +479,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -455,7 +496,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
|
||||
}
|
||||
|
||||
@@ -470,7 +513,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -485,7 +530,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -502,7 +549,7 @@ mod tests {
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
@@ -532,4 +579,153 @@ mod tests {
|
||||
let result = validate_challenge(&cache, &did, "12345678").await;
|
||||
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
|
||||
}
|
||||
|
||||
async fn reject_totp(_code: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn accept_totp(_code: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_accepted_via_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp1".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp2".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// An email challenge exists for this user.
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// Five wrong TOTP-shaped attempts. If these incremented the email attempt
|
||||
// counter, the email challenge would be exhausted (MAX_ATTEMPTS = 5).
|
||||
for _ in 0..5 {
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("000000"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::InvalidCode)
|
||||
));
|
||||
}
|
||||
|
||||
// The email challenge is still valid and consumable.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp3".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// reject_totp would fail if this routed to the verifier; it must route to email.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backup_code_shaped_token_routes_to_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp4".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// No email challenge created. If this routed to email it would be
|
||||
// ChallengeNotFound; Verified proves it went to the verifier.
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("ABCD2345"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_ignored_when_no_totp() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp5".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: false,
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
// has_totp = false -> 6-digit token routes to email path; no challenge -> NotFound.
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::ChallengeNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_looks_like_totp_token() {
|
||||
// 6-digit TOTP codes
|
||||
assert!(looks_like_totp_token("123456"));
|
||||
assert!(looks_like_totp_token(" 000000 "));
|
||||
// backup-code format (8 chars, backup alphabet)
|
||||
assert!(looks_like_totp_token("ABCD2345"));
|
||||
// email challenge codes normalize to 10 alphanumeric chars -> not TOTP-shaped
|
||||
assert!(!looks_like_totp_token("ABCDEFGHIJ"));
|
||||
assert!(!looks_like_totp_token("ABCDE-FGHIJ"));
|
||||
// wrong lengths / non-digits
|
||||
assert!(!looks_like_totp_token("12345"));
|
||||
assert!(!looks_like_totp_token("1234567"));
|
||||
assert!(!looks_like_totp_token("12345A"));
|
||||
assert!(!looks_like_totp_token(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_used_totp_factor() {
|
||||
// strong MFA factors completed the login -> true
|
||||
assert!(used_totp_factor(true, Some("123456")));
|
||||
assert!(used_totp_factor(true, Some("ABCD2345")));
|
||||
// email-shaped code, or no token, or no TOTP on the account -> false
|
||||
assert!(!used_totp_factor(true, Some("ABCDEFGHIJ")));
|
||||
assert!(!used_totp_factor(true, None));
|
||||
assert!(!used_totp_factor(false, Some("123456")));
|
||||
assert!(!used_totp_factor(true, Some("")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
auth_source: AuthSource::OAuth,
|
||||
});
|
||||
} else {
|
||||
return Err(TokenValidationError::TokenExpired);
|
||||
return Err(TokenValidationError::OAuthTokenExpired);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ pub const BUILD_VERSION: &str = concat!(
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub const GENERAL_BODY_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ExternalRoutes {
|
||||
pub xrpc: Router<AppState>,
|
||||
pub oauth: Router<AppState>,
|
||||
@@ -97,9 +99,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
|
||||
.nest("/.well-known", well_known_router)
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
.merge(external.extra)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
tranquil_config::get().server.max_blob_size as usize,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(GENERAL_BODY_LIMIT))
|
||||
.layer(axum::middleware::map_response(rewrite_extractor_errors))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
|
||||
@@ -39,27 +39,77 @@ pub enum PlcOpType {
|
||||
Tombstone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Error)]
|
||||
#[error("service type must not be empty")]
|
||||
pub struct EmptyServiceType;
|
||||
|
||||
mod custom_service_type {
|
||||
use super::EmptyServiceType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomServiceType(String);
|
||||
|
||||
impl CustomServiceType {
|
||||
pub(super) fn new(name: String) -> Result<Self, EmptyServiceType> {
|
||||
match name.as_str() {
|
||||
"" => Err(EmptyServiceType),
|
||||
_ => Ok(Self(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use custom_service_type::CustomServiceType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ServiceType {
|
||||
#[serde(rename = "AtprotoPersonalDataServer")]
|
||||
Pds,
|
||||
#[serde(rename = "AtprotoAppView")]
|
||||
AppView,
|
||||
#[serde(rename = "AtprotoLabeler")]
|
||||
Labeler,
|
||||
Other(CustomServiceType),
|
||||
}
|
||||
|
||||
impl ServiceType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Pds => "AtprotoPersonalDataServer",
|
||||
Self::AppView => "AtprotoAppView",
|
||||
Self::Labeler => "AtprotoLabeler",
|
||||
Self::Other(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pds(self) -> bool {
|
||||
matches!(self, Self::Pds)
|
||||
impl TryFrom<String> for ServiceType {
|
||||
type Error = EmptyServiceType;
|
||||
|
||||
fn try_from(name: String) -> Result<Self, Self::Error> {
|
||||
match name.as_str() {
|
||||
"AtprotoPersonalDataServer" => Ok(Self::Pds),
|
||||
"AtprotoLabeler" => Ok(Self::Labeler),
|
||||
_ => CustomServiceType::new(name).map(Self::Other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ServiceType {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ServiceType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Self::try_from(name).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,4 +675,80 @@ mod tests {
|
||||
let signed = sign_operation(&op, &key).unwrap();
|
||||
assert!(signed.get("sig").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_known_round_trip() {
|
||||
let cases = [
|
||||
(ServiceType::Pds, "\"AtprotoPersonalDataServer\""),
|
||||
(ServiceType::Labeler, "\"AtprotoLabeler\""),
|
||||
];
|
||||
cases.iter().for_each(|(variant, encoded)| {
|
||||
assert_eq!(serde_json::to_string(variant).unwrap(), *encoded);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ServiceType>(encoded).unwrap(),
|
||||
*variant
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_custom_round_trips() {
|
||||
let parsed: ServiceType = serde_json::from_str("\"ConchFeedGenerator\"").unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&parsed).unwrap(),
|
||||
"\"ConchFeedGenerator\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_custom_normalizes_known_names() {
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoPersonalDataServer".to_string()).unwrap(),
|
||||
ServiceType::Pds
|
||||
);
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoLabeler".to_string()).unwrap(),
|
||||
ServiceType::Labeler
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appview_is_not_a_named_type() {
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoAppView".to_string()).unwrap(),
|
||||
ServiceType::Other(CustomServiceType::new("AtprotoAppView".to_string()).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_rejects_empty() {
|
||||
assert!(ServiceType::try_from(String::new()).is_err());
|
||||
assert!(serde_json::from_str::<ServiceType>("\"\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plc_operation_with_custom_service_round_trips() {
|
||||
let op_json = json!({
|
||||
"type": "plc_operation",
|
||||
"rotationKeys": ["did:key:zScallop"],
|
||||
"verificationMethods": { "atproto": "did:key:zUni" },
|
||||
"alsoKnownAs": ["at://whelk.nel.pet"],
|
||||
"services": {
|
||||
"atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://nel.pet" },
|
||||
"custom_feedgen": { "type": "ConchFeedGenerator", "endpoint": "https://feed.nel.pet" }
|
||||
},
|
||||
"prev": null
|
||||
});
|
||||
let op: PlcOperation = serde_json::from_value(op_json.clone()).unwrap();
|
||||
assert_eq!(
|
||||
op.services["custom_feedgen"].service_type,
|
||||
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
|
||||
);
|
||||
assert_eq!(op.services["atproto_pds"].service_type, ServiceType::Pds);
|
||||
assert_eq!(serde_json::to_value(&op).unwrap(), op_json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ use cid::Cid;
|
||||
use jacquard_repo::error::RepoError;
|
||||
use jacquard_repo::repo::CommitData;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_store::blockstore::TranquilBlockStore;
|
||||
use tranquil_store::blockstore::{RepairOutcome, TranquilBlockStore};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum AnyBlockStore {
|
||||
Postgres(PostgresBlockStore),
|
||||
TranquilStore(TranquilBlockStore),
|
||||
TranquilStore(TranquilBlockStore<RealIO, SystemClock>),
|
||||
}
|
||||
|
||||
impl AnyBlockStore {
|
||||
@@ -23,7 +24,7 @@ impl AnyBlockStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore> {
|
||||
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore<RealIO, SystemClock>> {
|
||||
match self {
|
||||
Self::TranquilStore(s) => Some(s),
|
||||
Self::Postgres(_) => None,
|
||||
@@ -36,6 +37,38 @@ impl AnyBlockStore {
|
||||
Self::TranquilStore(s) => s.decrement_refs(cids).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn repair_structure(
|
||||
&self,
|
||||
entries: &[(String, Cid)],
|
||||
expected_root: Cid,
|
||||
) -> Result<RepairOutcome, RepoError> {
|
||||
match self {
|
||||
Self::Postgres(s) => {
|
||||
let nodes =
|
||||
tranquil_store::blockstore::rebuild_mst_nodes(entries, expected_root).await?;
|
||||
let nodes_total = nodes.len();
|
||||
let cids: Vec<Cid> = nodes.iter().map(|(cid, _)| *cid).collect();
|
||||
let present = s.get_many(&cids).await?;
|
||||
let missing: Vec<(Cid, Bytes)> = nodes
|
||||
.into_iter()
|
||||
.zip(present)
|
||||
.filter_map(|((cid, bytes), found)| found.is_none().then_some((cid, bytes)))
|
||||
.collect();
|
||||
let nodes_repaired = missing.len() as u64;
|
||||
if !missing.is_empty() {
|
||||
s.put_many(missing).await?;
|
||||
}
|
||||
Ok(RepairOutcome {
|
||||
nodes_total,
|
||||
nodes_repaired,
|
||||
})
|
||||
}
|
||||
Self::TranquilStore(s) => {
|
||||
tranquil_store::blockstore::rebuild_and_repair_mst(s, entries, expected_root).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockStore for AnyBlockStore {
|
||||
|
||||
@@ -16,7 +16,8 @@ use k256::ecdsa::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -230,16 +231,251 @@ pub async fn begin_repo_write(
|
||||
Ok((ctx, mst))
|
||||
}
|
||||
|
||||
pub async fn repair_repo_structure(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
) -> Result<tranquil_store::blockstore::RepairOutcome, ApiError> {
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
let root_cid_str = state
|
||||
.repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: DB error fetching repo root: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
|
||||
let current_root_cid = Cid::from_str(root_cid_str.as_str())
|
||||
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
|
||||
|
||||
let commit_bytes = state
|
||||
.block_store
|
||||
.get(¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: failed to load commit block: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| {
|
||||
error!("repair: failed to parse commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let data_root = commit.data;
|
||||
let repo_rev = commit.rev().to_string();
|
||||
|
||||
let records = state
|
||||
.repos
|
||||
.repo
|
||||
.get_all_records(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: get_all_records failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let entries: Vec<(String, Cid)> = records
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
Cid::from_str(r.record_cid.as_str())
|
||||
.ok()
|
||||
.map(|cid| (format!("{}/{}", r.collection, r.rkey), cid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
records = entries.len(),
|
||||
"repair: rebuilding full MST from record set"
|
||||
);
|
||||
|
||||
let outcome = state
|
||||
.block_store
|
||||
.repair_structure(&entries, data_root)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: structural repair failed: {}", e);
|
||||
ApiError::InternalError(Some("Structural repair failed".into()))
|
||||
})?;
|
||||
|
||||
if outcome.nodes_repaired > 0 {
|
||||
let block_cids =
|
||||
crate::scheduled::collect_current_repo_blocks(&state.block_store, ¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: re-walk for user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let cids = block_cids
|
||||
.iter()
|
||||
.map(|bytes| Cid::try_from(bytes.as_slice()))
|
||||
.collect::<Result<Vec<Cid>, _>>()
|
||||
.map_err(|e| {
|
||||
error!("repair: unparseable CID in repaired DAG walk: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let present = state.block_store.get_many(&cids).await.map_err(|e| {
|
||||
error!("repair: presence check during user_blocks backfill failed: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let missing: Vec<Cid> = cids
|
||||
.iter()
|
||||
.zip(present)
|
||||
.filter_map(|(cid, found)| found.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
missing = missing.len(),
|
||||
sample = ?missing.iter().take(5).map(|c| c.to_string()).collect::<Vec<_>>(),
|
||||
"repair: unrecoverable leaf data loss after structural repair"
|
||||
);
|
||||
return Err(ApiError::InternalError(Some(format!(
|
||||
"unrecoverable leaf data loss: {} record block(s) missing after structural repair",
|
||||
missing.len()
|
||||
))));
|
||||
}
|
||||
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &block_cids, &repo_rev)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
blocks = block_cids.len(),
|
||||
"repair: backfilled user_blocks from repaired DAG"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn with_repair_retry<T, F, Fut>(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
mut attempt: F,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, ApiError>>,
|
||||
{
|
||||
match attempt().await {
|
||||
Err(e) if e.is_repo_corruption() => {
|
||||
warn!(
|
||||
"structural MST damage during repo write for user {user_id}, repairing and retrying"
|
||||
);
|
||||
match repair_repo_structure(state, user_id).await {
|
||||
Ok(outcome) if outcome.nodes_repaired > 0 => attempt().await,
|
||||
Ok(_) => {
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
"structural repair rewrote no nodes; damage is not in the MST structure, returning original error without retry"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
Err(repair_err) => {
|
||||
error!(user_id = %user_id, "structural repair failed: {repair_err:?}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const REPAIR_NOOP_COOLDOWN: Duration = Duration::from_secs(600);
|
||||
|
||||
struct RepairSlot {
|
||||
in_flight: bool,
|
||||
next_allowed: Instant,
|
||||
}
|
||||
|
||||
struct RepairGuard {
|
||||
slots: parking_lot::Mutex<HashMap<Uuid, RepairSlot>>,
|
||||
}
|
||||
|
||||
impl RepairGuard {
|
||||
fn try_claim(&self, user_id: Uuid, now: Instant) -> bool {
|
||||
let mut slots = self.slots.lock();
|
||||
let slot = slots.entry(user_id).or_insert(RepairSlot {
|
||||
in_flight: false,
|
||||
next_allowed: now,
|
||||
});
|
||||
if slot.in_flight || now < slot.next_allowed {
|
||||
return false;
|
||||
}
|
||||
slot.in_flight = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn release(&self, user_id: Uuid, now: Instant, cooldown: Duration) {
|
||||
if let Some(slot) = self.slots.lock().get_mut(&user_id) {
|
||||
slot.in_flight = false;
|
||||
slot.next_allowed = now + cooldown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REPAIR_GUARD: LazyLock<RepairGuard> = LazyLock::new(|| RepairGuard {
|
||||
slots: parking_lot::Mutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
struct RepairLease {
|
||||
user_id: Uuid,
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
impl Drop for RepairLease {
|
||||
fn drop(&mut self) {
|
||||
REPAIR_GUARD.release(self.user_id, Instant::now(), self.cooldown);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
|
||||
if !REPAIR_GUARD.try_claim(user_id, Instant::now()) {
|
||||
return;
|
||||
}
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lease = RepairLease {
|
||||
user_id,
|
||||
cooldown: REPAIR_COOLDOWN,
|
||||
};
|
||||
match repair_repo_structure(&state, user_id).await {
|
||||
Ok(outcome) => {
|
||||
if outcome.nodes_repaired == 0 {
|
||||
lease.cooldown = REPAIR_NOOP_COOLDOWN;
|
||||
}
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
nodes_repaired = outcome.nodes_repaired,
|
||||
nodes_total = outcome.nodes_total,
|
||||
"background MST repair complete"
|
||||
);
|
||||
}
|
||||
Err(e) => error!(user_id = %user_id, "background MST repair failed: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn finalize_repo_write(
|
||||
state: &AppState,
|
||||
ctx: RepoWriteContext,
|
||||
mst: Mst<TrackingBlockStore>,
|
||||
params: FinalizeParams<'_>,
|
||||
) -> Result<CommitResult, ApiError> {
|
||||
let new_mst_root = mst.persist().await.map_err(|e| {
|
||||
error!("MST persist failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_mst_root = mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
|
||||
|
||||
let written_bytes = ctx.tracking_store.take_written_blocks();
|
||||
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
|
||||
@@ -861,3 +1097,117 @@ pub async fn sequence_genesis_commit(
|
||||
.await
|
||||
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod repair_guard_tests {
|
||||
use super::*;
|
||||
|
||||
fn guard() -> RepairGuard {
|
||||
RepairGuard {
|
||||
slots: parking_lot::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_dedups_in_flight_then_respects_cooldown() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(1);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0), "first claim must succeed");
|
||||
assert!(
|
||||
!g.try_claim(user, t0),
|
||||
"second claim while a repair is in flight must be rejected"
|
||||
);
|
||||
|
||||
g.release(user, t0, REPAIR_COOLDOWN);
|
||||
assert!(
|
||||
!g.try_claim(user, t0 + Duration::from_secs(1)),
|
||||
"claim within the cooldown window must be rejected"
|
||||
);
|
||||
assert!(
|
||||
g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
|
||||
"claim after the cooldown window must succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_users_do_not_block_each_other() {
|
||||
let g = guard();
|
||||
let t0 = Instant::now();
|
||||
assert!(g.try_claim(Uuid::from_u128(1), t0));
|
||||
assert!(g.try_claim(Uuid::from_u128(2), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_claims_for_one_user_admit_exactly_one() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
let g = Arc::new(guard());
|
||||
let user = Uuid::from_u128(42);
|
||||
let now = Instant::now();
|
||||
let winners = Arc::new(AtomicUsize::new(0));
|
||||
let gate = Arc::new(Barrier::new(32));
|
||||
|
||||
let handles: Vec<_> = (0..32)
|
||||
.map(|_| {
|
||||
let g = Arc::clone(&g);
|
||||
let winners = Arc::clone(&winners);
|
||||
let gate = Arc::clone(&gate);
|
||||
std::thread::spawn(move || {
|
||||
gate.wait();
|
||||
if g.try_claim(user, now) {
|
||||
winners.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.for_each(|h| h.join().expect("worker thread panicked"));
|
||||
|
||||
assert_eq!(
|
||||
winners.load(Ordering::Relaxed),
|
||||
1,
|
||||
"exactly one concurrent claim must win the dedup race"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_re_enables_claim_after_cooldown_for_recurring_corruption() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(7);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0));
|
||||
g.release(user, t0, REPAIR_COOLDOWN);
|
||||
let after_cooldown = t0 + REPAIR_COOLDOWN + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.try_claim(user, after_cooldown),
|
||||
"a fresh corruption after the cooldown must be repairable again"
|
||||
);
|
||||
assert!(
|
||||
!g.try_claim(user, after_cooldown),
|
||||
"the re-claimed repair must again dedup while in flight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_repair_uses_longer_cooldown() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(9);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0));
|
||||
g.release(user, t0, REPAIR_NOOP_COOLDOWN);
|
||||
assert!(
|
||||
!g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
|
||||
"after a no-op repair the standard cooldown must not re-admit a claim"
|
||||
);
|
||||
assert!(
|
||||
g.try_claim(user, t0 + REPAIR_NOOP_COOLDOWN + Duration::from_millis(1)),
|
||||
"after the longer no-op cooldown a fresh claim must be admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +574,10 @@ impl CompactionBlocklist {
|
||||
}
|
||||
|
||||
fn run_compaction_pass(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
liveness_threshold: f64,
|
||||
grace_period_ms: u64,
|
||||
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
|
||||
@@ -727,10 +730,53 @@ async fn delete_account_data(
|
||||
|
||||
const CAR_BLOCK_BATCH_SIZE: usize = 500;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepoCarError {
|
||||
MissingBlocks(Vec<Cid>),
|
||||
Source(anyhow::Error),
|
||||
}
|
||||
|
||||
impl RepoCarError {
|
||||
pub fn is_repairable(&self) -> bool {
|
||||
match self {
|
||||
Self::MissingBlocks(_) => true,
|
||||
Self::Source(e) => {
|
||||
crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepoCarError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingBlocks(cids) => write!(
|
||||
f,
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
cids.len(),
|
||||
cids.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Self::Source(e) => write!(f, "{e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepoCarError {}
|
||||
|
||||
impl From<anyhow::Error> for RepoCarError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
Self::Source(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_repo_car(
|
||||
block_store: &AnyBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
|
||||
let block_cids: Vec<Cid> = block_cids_bytes
|
||||
.iter()
|
||||
@@ -757,16 +803,7 @@ pub async fn generate_repo_car(
|
||||
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
missing.len(),
|
||||
missing
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
return Err(RepoCarError::MissingBlocks(missing));
|
||||
}
|
||||
|
||||
chunk
|
||||
@@ -800,7 +837,7 @@ pub async fn generate_repo_car_from_user_blocks(
|
||||
block_store: &AnyBlockStore,
|
||||
user_id: uuid::Uuid,
|
||||
_head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
use std::str::FromStr;
|
||||
|
||||
let repo_root_cid_str: String = repo_repo
|
||||
@@ -836,7 +873,10 @@ fn cid_to_bytes(cid: &Cid) -> anyhow::Result<CidBytes> {
|
||||
}
|
||||
|
||||
fn walk_repo_dag_sync(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
head_cid: &Cid,
|
||||
reachable: &mut std::collections::HashSet<CidBytes>,
|
||||
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
|
||||
@@ -960,7 +1000,10 @@ fn paginate_repos(
|
||||
}
|
||||
|
||||
pub fn run_reachability_walk(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
repo_repo: &dyn RepoRepository,
|
||||
) -> anyhow::Result<ReachabilityResult> {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
|
||||
@@ -471,7 +471,10 @@ impl AppState {
|
||||
}
|
||||
|
||||
struct TranquilStoreWiring {
|
||||
blockstore: tranquil_store::blockstore::TranquilBlockStore,
|
||||
blockstore: tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
signal_provider: Arc<dyn tranquil_signal::SignalStoreProvider>,
|
||||
repos: PostgresRepositories,
|
||||
segments_dir: PathBuf,
|
||||
|
||||
@@ -92,30 +92,20 @@ impl CarVerifier {
|
||||
|
||||
pub fn verify_car_structure_only(
|
||||
&self,
|
||||
expected_did: &Did,
|
||||
root_cid: &Cid,
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
) -> Result<VerifiedCar, VerifyError> {
|
||||
) -> Result<StructureVerifiedCar, VerifyError> {
|
||||
let root_block = blocks
|
||||
.get(root_cid)
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
|
||||
let commit =
|
||||
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
let commit_did = commit.did().as_str();
|
||||
if commit_did != expected_did.as_str() {
|
||||
return Err(VerifyError::DidMismatch {
|
||||
commit_did: commit_did.to_string(),
|
||||
expected_did: expected_did.to_string(),
|
||||
});
|
||||
}
|
||||
let commit_did = commit.did().to_string().into();
|
||||
let data_cid = commit.data();
|
||||
self.verify_mst_structure(data_cid, blocks)?;
|
||||
debug!(
|
||||
"MST structure verified for DID {} (signature verification skipped for migration)",
|
||||
expected_did
|
||||
);
|
||||
Ok(VerifiedCar {
|
||||
did: expected_did.clone(),
|
||||
debug!("MST structure verified for commit: {:?}", commit);
|
||||
Ok(StructureVerifiedCar {
|
||||
did: commit_did,
|
||||
rev: commit.rev().to_string(),
|
||||
data_cid: *data_cid,
|
||||
prev: commit.prev().cloned(),
|
||||
@@ -289,6 +279,14 @@ impl CarVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructureVerifiedCar {
|
||||
pub did: Did,
|
||||
pub rev: String,
|
||||
pub data_cid: Cid,
|
||||
pub prev: Option<Cid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedCar {
|
||||
pub did: Did,
|
||||
|
||||
@@ -33,7 +33,20 @@ pub fn generate_token_code() -> String {
|
||||
.map(|_| chars[rng.gen_range(0..chars.len())])
|
||||
.collect()
|
||||
};
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng))
|
||||
// Human-entered short codes are displayed in uppercase; base32 digits are
|
||||
// unaffected by the conversion.
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng)).to_uppercase()
|
||||
}
|
||||
|
||||
/// Normalize a user-entered short code so that codes are accepted
|
||||
/// case-insensitively and regardless of the separating hyphen or surrounding
|
||||
/// whitespace.
|
||||
pub fn normalize_token_code(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
}
|
||||
|
||||
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
|
||||
@@ -491,10 +504,44 @@ mod tests {
|
||||
assert!(
|
||||
code.chars()
|
||||
.filter(|&c| c != '-')
|
||||
.all(|c| BASE32_ALPHABET.contains(c))
|
||||
.all(|c| BASE32_ALPHABET.to_uppercase().contains(c))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_token_code_is_uppercase() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_token_code();
|
||||
assert_eq!(code, code.to_uppercase(), "code must be uppercase: {code}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_hyphen_and_uppercases() {
|
||||
assert_eq!(normalize_token_code("k7m2p-q9rst"), "K7M2PQ9RST");
|
||||
assert_eq!(normalize_token_code("K7M2P-Q9RST"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_whitespace() {
|
||||
assert_eq!(normalize_token_code(" k7m2p-q9rst \n"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_is_idempotent() {
|
||||
let once = normalize_token_code("k7m2p-q9rst");
|
||||
assert_eq!(normalize_token_code(&once), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_code_round_trips_through_normalize() {
|
||||
let code = generate_token_code();
|
||||
// A user re-typing the displayed code lowercased and without the hyphen
|
||||
// must normalize to the same canonical form as the code itself.
|
||||
let retyped = code.to_lowercase().replace('-', "");
|
||||
assert_eq!(normalize_token_code(&code), normalize_token_code(&retyped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_cid_link() {
|
||||
let json = serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_pds::scheduled::{RepoCarError, generate_repo_car};
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
|
||||
const RECORD_COUNT: usize = 200;
|
||||
|
||||
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 64 * 1024,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
|
||||
}
|
||||
|
||||
async fn build_tree(any: &AnyBlockStore) -> Cid {
|
||||
let mut mst = Mst::new(Arc::new(any.clone()));
|
||||
for i in 0..RECORD_COUNT {
|
||||
let key = format!("app.bsky.feed.post/{i:0>6}");
|
||||
let cid = any
|
||||
.put(format!("record body {i}").as_bytes())
|
||||
.await
|
||||
.expect("put record");
|
||||
mst.add_mut(&key, cid).await.expect("mst add");
|
||||
}
|
||||
mst.persist().await.expect("persist mst")
|
||||
}
|
||||
|
||||
fn shred_data_files(data_dir: &std::path::Path) {
|
||||
let mut shredded = false;
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut off = 5usize;
|
||||
while off + 48 < bytes.len() {
|
||||
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
|
||||
off += 192;
|
||||
shredded = true;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
}
|
||||
assert!(shredded, "no .tqb data file was corrupted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn car_export_error_is_classified_as_repo_corruption() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let root = build_tree(&any).await;
|
||||
|
||||
generate_repo_car(&any, &root)
|
||||
.await
|
||||
.expect("pristine CAR must generate");
|
||||
|
||||
shred_data_files(&dir.path().join("data"));
|
||||
|
||||
let err = generate_repo_car(&any, &root)
|
||||
.await
|
||||
.expect_err("corrupt CAR export must error");
|
||||
assert!(
|
||||
err.is_repairable(),
|
||||
"{err} should classify as repairable so the sync path can self-heal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn car_export_missing_block_is_repairable() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let source = open_store(dir.path());
|
||||
let root = build_tree(&source).await;
|
||||
|
||||
let pristine = open_store(&dir.path().join("pristine"));
|
||||
let head_block = source
|
||||
.get(&root)
|
||||
.await
|
||||
.expect("read root")
|
||||
.expect("root present");
|
||||
pristine.put(&head_block).await.expect("seed root only");
|
||||
|
||||
let err = generate_repo_car(&pristine, &root)
|
||||
.await
|
||||
.expect_err("CAR export over a store missing MST children must error");
|
||||
assert!(
|
||||
matches!(err, RepoCarError::MissingBlocks(ref cids) if !cids.is_empty()),
|
||||
"{err} should surface as MissingBlocks when referenced blocks are absent"
|
||||
);
|
||||
assert!(err.is_repairable());
|
||||
}
|
||||
@@ -30,6 +30,7 @@ static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CLUSTER: OnceLock<Vec<ServerInstance>> = OnceLock::new();
|
||||
static TEST_REPOS: OnceLock<Arc<tranquil_db::PostgresRepositories>> = OnceLock::new();
|
||||
static TEST_BLOCK_STORE: OnceLock<tranquil_pds::repo::AnyBlockStore> = OnceLock::new();
|
||||
static TEST_APP_STATE: OnceLock<AppState> = OnceLock::new();
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_store_backend() -> bool {
|
||||
@@ -130,6 +131,13 @@ pub fn pds_endpoint() -> String {
|
||||
format!("https://{}", pds_hostname())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn store_data_dir() -> Option<PathBuf> {
|
||||
std::env::var("TRANQUIL_STORE_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub async fn base_url() -> &'static str {
|
||||
SERVER_URL.get_or_init(|| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
@@ -579,6 +587,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
|
||||
if let Some((cache, distributed_rate_limiter)) = config.cache {
|
||||
state = state.with_cache(cache, distributed_rate_limiter);
|
||||
}
|
||||
TEST_APP_STATE.set(state.clone()).ok();
|
||||
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
|
||||
let app = tranquil_pds::app_with_routes(
|
||||
state,
|
||||
@@ -921,6 +930,14 @@ pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStor
|
||||
.expect("TEST_BLOCK_STORE not initialized")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_test_app_state() -> &'static AppState {
|
||||
base_url().await;
|
||||
TEST_APP_STATE
|
||||
.get()
|
||||
.expect("TEST_APP_STATE not initialized")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn flushed_max_seq(
|
||||
repos: &tranquil_db::PostgresRepositories,
|
||||
@@ -950,8 +967,7 @@ pub async fn sequenced_event_for_did(
|
||||
.await
|
||||
.expect("get_events_since_seq")
|
||||
.into_iter()
|
||||
.filter(|event| &event.did == did)
|
||||
.last()
|
||||
.rfind(|event| &event.did == did)
|
||||
.unwrap_or_else(|| panic!("event for did {did} not found after flush"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
mod common;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use common::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
fn decode_jwt_claims(jwt: &str) -> Value {
|
||||
let payload = jwt
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.expect("malformed jwt: no claims segment");
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.expect("malformed jwt: claims not base64url");
|
||||
serde_json::from_slice(&bytes).expect("malformed jwt: claims not json")
|
||||
}
|
||||
|
||||
struct CaptureAuth(Arc<Mutex<Option<String>>>);
|
||||
|
||||
impl Respond for CaptureAuth {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let auth = req
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
*self.0.lock().unwrap() = auth;
|
||||
ResponseTemplate::new(200).set_body_json(json!({ "feed": [] }))
|
||||
}
|
||||
}
|
||||
|
||||
/// getFeed's service-auth token must be audienced to the feed generator, not the AppView.
|
||||
#[tokio::test]
|
||||
async fn get_feed_service_auth_is_audienced_to_feed_generator() {
|
||||
let client = client();
|
||||
let (token, _did) = create_account_and_login(&client).await;
|
||||
|
||||
// One mock server doubles as the AppView: did:web doc, getRecord, and getFeed.
|
||||
let appview = MockServer::start().await;
|
||||
let appview_uri = appview.uri();
|
||||
let host = appview_uri
|
||||
.strip_prefix("http://")
|
||||
.expect("mock uri should be http");
|
||||
// Literal-colon host so did:web resolves over http to the local mock.
|
||||
let appview_did = format!("did:web:{host}");
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/did.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": appview_did,
|
||||
"service": [{
|
||||
"id": "#bsky_appview",
|
||||
"type": "BskyAppView",
|
||||
"serviceEndpoint": appview_uri,
|
||||
}]
|
||||
})))
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
let feed_did = "did:web:feedgen.example.com";
|
||||
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
|
||||
|
||||
// The feed generator record resolves to its service DID.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"uri": feed_uri,
|
||||
"value": { "$type": "app.bsky.feed.generator", "did": feed_did },
|
||||
})))
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
// Capture the Authorization header the AppView is handed for getFeed.
|
||||
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/app.bsky.feed.getFeed"))
|
||||
.respond_with(CaptureAuth(captured.clone()))
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
// Send the params a real client sends, so feed extraction must ignore extras.
|
||||
let res = client
|
||||
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
|
||||
.query(&[("feed", feed_uri), ("limit", "30"), ("cursor", "abc123")])
|
||||
.header("authorization", format!("Bearer {}", token))
|
||||
.header("atproto-proxy", format!("{}#bsky_appview", appview_did))
|
||||
.send()
|
||||
.await
|
||||
.expect("getFeed proxy request failed");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"getFeed proxy should succeed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
|
||||
let auth = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("AppView received no Authorization header");
|
||||
let jwt = auth
|
||||
.strip_prefix("Bearer ")
|
||||
.expect("forwarded auth should be a bearer token");
|
||||
let claims = decode_jwt_claims(jwt);
|
||||
|
||||
assert_eq!(
|
||||
claims["aud"].as_str(),
|
||||
Some(feed_did),
|
||||
"service-auth token must be audienced to the feed generator, got {:?}",
|
||||
claims["aud"]
|
||||
);
|
||||
assert_eq!(
|
||||
claims["lxm"].as_str(),
|
||||
Some("app.bsky.feed.getFeedSkeleton"),
|
||||
"service-auth token lxm must be getFeedSkeleton, got {:?}",
|
||||
claims["lxm"]
|
||||
);
|
||||
}
|
||||
|
||||
/// An unresolvable feed generator must be refused, not forwarded with an AppView aud.
|
||||
#[tokio::test]
|
||||
async fn get_feed_refuses_when_feed_generator_unresolvable() {
|
||||
let client = client();
|
||||
let (token, _did) = create_account_and_login(&client).await;
|
||||
|
||||
let appview = MockServer::start().await;
|
||||
let appview_uri = appview.uri();
|
||||
let host = appview_uri
|
||||
.strip_prefix("http://")
|
||||
.expect("mock uri should be http");
|
||||
let appview_did = format!("did:web:{host}");
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/did.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": appview_did,
|
||||
"service": [{
|
||||
"id": "#bsky_appview",
|
||||
"type": "BskyAppView",
|
||||
"serviceEndpoint": appview_uri,
|
||||
}]
|
||||
})))
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
// getRecord fails, so the feed generator DID can't be resolved.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
|
||||
"error": "RecordNotFound",
|
||||
})))
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
// getFeed must never be reached with an AppView-audienced token.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/app.bsky.feed.getFeed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] })))
|
||||
.expect(0)
|
||||
.mount(&appview)
|
||||
.await;
|
||||
|
||||
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
|
||||
let res = client
|
||||
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
|
||||
.query(&[("feed", feed_uri)])
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("atproto-proxy", format!("{appview_did}#bsky_appview"))
|
||||
.send()
|
||||
.await
|
||||
.expect("getFeed proxy request failed");
|
||||
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unresolvable feed should be rejected, got {}",
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use common::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run_compaction(store: &tranquil_store::blockstore::TranquilBlockStore) {
|
||||
fn run_compaction(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
) {
|
||||
let liveness = store.compaction_liveness(0).unwrap();
|
||||
liveness
|
||||
.iter()
|
||||
|
||||
@@ -73,7 +73,7 @@ fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_rejects_car_for_different_user() {
|
||||
async fn test_import_doesnt_reject_car_for_different_user() {
|
||||
let client = client();
|
||||
let (token_a, _did_a) = create_account_and_login(&client).await;
|
||||
let (_token_b, did_b) = create_account_and_login(&client).await;
|
||||
@@ -99,15 +99,9 @@ async fn test_import_rejects_car_for_different_user() {
|
||||
.send()
|
||||
.await
|
||||
.expect("Import failed");
|
||||
assert_eq!(import_res.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(import_res.status(), StatusCode::OK);
|
||||
let body: serde_json::Value = import_res.json().await.unwrap();
|
||||
assert!(
|
||||
body["error"] == "InvalidRepo"
|
||||
|| body["error"] == "InvalidRequest"
|
||||
|| body["error"] == "DidMismatch",
|
||||
"Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}",
|
||||
body
|
||||
);
|
||||
assert!(body.is_object() && body.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -40,19 +40,17 @@ async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
comms.first().and_then(|c| {
|
||||
c.body
|
||||
.lines()
|
||||
.find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8)
|
||||
.map(|s: &str| s.to_string())
|
||||
.or_else(|| {
|
||||
c.body
|
||||
.split_whitespace()
|
||||
.find(|word: &&str| {
|
||||
word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8
|
||||
})
|
||||
.map(|s: &str| s.to_string())
|
||||
})
|
||||
c.body.split_whitespace().find_map(|word: &str| {
|
||||
let candidate = word.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
|
||||
let normalized = candidate.replace('-', "");
|
||||
if normalized.len() == 10 && normalized.chars().all(|ch| ALPHABET.contains(ch)) {
|
||||
Some(candidate.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -773,3 +773,166 @@ async fn test_list_records_comprehensive() {
|
||||
.expect("Failed with nonexistent repo");
|
||||
assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_type_is_filled_from_collection() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("missing-type").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": { "text": "no type set", "createdAt": now }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create record without $type");
|
||||
assert_eq!(
|
||||
create_res.status(),
|
||||
StatusCode::OK,
|
||||
"createRecord should fill missing $type from collection"
|
||||
);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let create_rkey = create_body["uri"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let get_created = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
("rkey", &create_rkey),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get created record");
|
||||
let created_body: Value = get_created.json().await.unwrap();
|
||||
assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post");
|
||||
|
||||
let put_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.putRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.actor.profile",
|
||||
"rkey": "self",
|
||||
"record": { "displayName": "No Type" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put record without $type");
|
||||
assert_eq!(
|
||||
put_res.status(),
|
||||
StatusCode::OK,
|
||||
"putRecord should fill missing $type from collection"
|
||||
);
|
||||
let get_put = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.actor.profile"),
|
||||
("rkey", "self"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get put record");
|
||||
let put_body: Value = get_put.json().await.unwrap();
|
||||
assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile");
|
||||
|
||||
let apply_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"writes": [
|
||||
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to apply writes without $type");
|
||||
assert_eq!(
|
||||
apply_res.status(),
|
||||
StatusCode::OK,
|
||||
"applyWrites should fill missing $type from collection"
|
||||
);
|
||||
let get_batch = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
("rkey", "batch-no-type"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get batch record");
|
||||
let batch_body: Value = get_batch.json().await.unwrap();
|
||||
assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post");
|
||||
|
||||
let mismatch_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send mismatch request");
|
||||
assert_eq!(
|
||||
mismatch_res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"explicit mismatched $type should still be rejected"
|
||||
);
|
||||
|
||||
let non_string_type_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": { "$type": 123, "text": "non-string type", "createdAt": now }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send non-string type request");
|
||||
assert_eq!(
|
||||
non_string_type_res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"present non-string $type should be rejected, not overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_store::blockstore::{
|
||||
BLOCK_HEADER_SIZE, BlockStoreConfig, CID_SIZE, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
|
||||
const RECORD_COUNT: usize = 300;
|
||||
|
||||
fn open_store(dir: &Path) -> AnyBlockStore {
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 64 * 1024,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
|
||||
}
|
||||
|
||||
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
|
||||
let mut mst = Mst::new(Arc::new(any.clone()));
|
||||
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
|
||||
for i in 0..RECORD_COUNT {
|
||||
let key = format!("app.bsky.feed.post/{i:0>6}");
|
||||
let body = format!("record body number {i}").into_bytes();
|
||||
let cid = any.put(&body).await.expect("put record");
|
||||
mst.add_mut(&key, cid).await.expect("mst add");
|
||||
entries.push((key, cid));
|
||||
}
|
||||
let data_root = mst.persist().await.expect("persist mst");
|
||||
(data_root, entries)
|
||||
}
|
||||
|
||||
fn shred_data_files(data_dir: &Path) {
|
||||
let mut shredded = false;
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut off = 5usize;
|
||||
while off + 48 < bytes.len() {
|
||||
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
|
||||
off += 192;
|
||||
shredded = true;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
}
|
||||
assert!(shredded, "no .tqb data file was corrupted");
|
||||
}
|
||||
|
||||
fn corrupt_block_with_cid(data_dir: &Path, target: &[u8]) -> bool {
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut pos = BLOCK_HEADER_SIZE;
|
||||
while pos + CID_SIZE + 4 <= bytes.len() {
|
||||
let cid = bytes[pos..pos + CID_SIZE].to_vec();
|
||||
let len = u32::from_le_bytes(
|
||||
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
) as usize;
|
||||
let data_start = pos + CID_SIZE + 4;
|
||||
let rec_end = data_start + len + 4;
|
||||
if rec_end > bytes.len() {
|
||||
break;
|
||||
}
|
||||
if cid.as_slice() == target && len > 0 {
|
||||
bytes[data_start] ^= 0xFF;
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
return true;
|
||||
}
|
||||
pos = rec_end;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_mst_node_classifies_as_repo_corruption() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let (root, entries) = build_repo(&any).await;
|
||||
|
||||
shred_data_files(&dir.path().join("data"));
|
||||
|
||||
let mst = Mst::load(Arc::new(any.clone()), root, None);
|
||||
let mut classified_corruption = false;
|
||||
for (key, _) in &entries {
|
||||
if let Err(e) = mst.get(key).await {
|
||||
assert!(
|
||||
ApiError::from_mst_error("audit", &e).is_repo_corruption(),
|
||||
"corrupt MST node must classify as RepoCorruption via from_mst_error; raw error: {e}"
|
||||
);
|
||||
assert!(
|
||||
ApiError::detail_is_repo_corruption(&e.to_string()),
|
||||
"to_string of corrupt-node error must carry the marker; raw error: {e}"
|
||||
);
|
||||
classified_corruption = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
classified_corruption,
|
||||
"shredded tree must produce at least one corrupt-node read error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_mst_node_classifies_as_repo_corruption() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let (root, entries) = build_repo(&any).await;
|
||||
|
||||
let empty_dir = tempfile::tempdir().expect("empty tempdir");
|
||||
let empty = open_store(empty_dir.path());
|
||||
let mst = Mst::load(Arc::new(empty.clone()), root, None);
|
||||
|
||||
let err = mst
|
||||
.get(&entries[0].0)
|
||||
.await
|
||||
.expect_err("loading a root absent from the store must error");
|
||||
|
||||
assert!(
|
||||
ApiError::from_mst_error("audit", &err).is_repo_corruption(),
|
||||
"a missing MST node must classify as repairable so self-heal triggers; raw error: {err}"
|
||||
);
|
||||
assert!(
|
||||
ApiError::detail_is_repo_corruption(&format!("{err:#}")),
|
||||
"missing-node error must carry a repairable marker; raw error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaf_block_corruption_is_not_repaired_by_structural_repair() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let (root, entries) = build_repo(&any).await;
|
||||
|
||||
let (_, rec_cid) = &entries[0];
|
||||
assert!(
|
||||
any.get(rec_cid).await.expect("read leaf").is_some(),
|
||||
"leaf must be readable before corruption"
|
||||
);
|
||||
|
||||
let target = rec_cid.to_bytes();
|
||||
assert!(
|
||||
corrupt_block_with_cid(&dir.path().join("data"), &target),
|
||||
"must locate the record leaf block to corrupt"
|
||||
);
|
||||
|
||||
let read_err = any
|
||||
.get(rec_cid)
|
||||
.await
|
||||
.expect_err("corrupt leaf must fail to read");
|
||||
assert!(
|
||||
ApiError::detail_is_repo_corruption(&read_err.to_string()),
|
||||
"corrupt leaf read error must carry the marker; raw error: {read_err}"
|
||||
);
|
||||
|
||||
let outcome = any
|
||||
.repair_structure(&entries, root)
|
||||
.await
|
||||
.expect("structural repair must succeed");
|
||||
assert_eq!(
|
||||
outcome.nodes_repaired, 0,
|
||||
"structural repair only touches MST nodes, so a leaf-only corruption yields zero repairs"
|
||||
);
|
||||
|
||||
assert!(
|
||||
any.get(rec_cid).await.is_err(),
|
||||
"leaf corruption is NOT healed by structural repair"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
|
||||
const RECORD_COUNT: usize = 300;
|
||||
|
||||
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 64 * 1024,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
|
||||
}
|
||||
|
||||
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
|
||||
let mut mst = Mst::new(Arc::new(any.clone()));
|
||||
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
|
||||
for i in 0..RECORD_COUNT {
|
||||
let key = format!("app.bsky.feed.post/{i:0>6}");
|
||||
let body = format!("record body number {i}").into_bytes();
|
||||
let cid = any.put(&body).await.expect("put record");
|
||||
mst.add_mut(&key, cid).await.expect("mst add");
|
||||
entries.push((key, cid));
|
||||
}
|
||||
let data_root = mst.persist().await.expect("persist mst");
|
||||
(data_root, entries)
|
||||
}
|
||||
|
||||
fn shred_data_files(data_dir: &std::path::Path) {
|
||||
let mut shredded = false;
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut off = 5usize;
|
||||
while off + 48 < bytes.len() {
|
||||
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
|
||||
off += 192;
|
||||
shredded = true;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
}
|
||||
assert!(shredded, "no .tqb data file was corrupted");
|
||||
}
|
||||
|
||||
async fn walk_all(
|
||||
any: &AnyBlockStore,
|
||||
root: Cid,
|
||||
entries: &[(String, Cid)],
|
||||
) -> Result<usize, String> {
|
||||
let mst = Mst::load(Arc::new(any.clone()), root, None);
|
||||
let mut resolved = 0usize;
|
||||
for (key, expected) in entries {
|
||||
match mst.get(key).await {
|
||||
Ok(Some(cid)) if cid == *expected => resolved += 1,
|
||||
Ok(Some(cid)) => {
|
||||
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
|
||||
}
|
||||
Ok(None) => return Err(format!("{key}: missing")),
|
||||
Err(e) => return Err(format!("{key}: read error {e}")),
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) {
|
||||
std::fs::create_dir_all(dst).expect("create dst dir");
|
||||
for entry in std::fs::read_dir(src).expect("read src dir") {
|
||||
let entry = entry.expect("dir entry");
|
||||
let from = entry.path();
|
||||
let to = dst.join(entry.file_name());
|
||||
if from.is_dir() {
|
||||
copy_dir_recursive(&from, &to);
|
||||
} else {
|
||||
std::fs::copy(&from, &to).expect("copy file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pristine_repo_survives_copy_and_reopen() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let (data_root, entries) = build_repo(&any).await;
|
||||
assert_eq!(
|
||||
walk_all(&any, data_root, &entries).await.expect("pristine"),
|
||||
RECORD_COUNT
|
||||
);
|
||||
|
||||
let snap = tempfile::tempdir().expect("snap tempdir");
|
||||
copy_dir_recursive(dir.path(), snap.path());
|
||||
|
||||
let reopened = open_store(snap.path());
|
||||
assert_eq!(
|
||||
walk_all(&reopened, data_root, &entries)
|
||||
.await
|
||||
.expect("pristine repo must survive copy+reopen"),
|
||||
RECORD_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_survives_crash_and_reopen() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
|
||||
let (data_root, entries) = build_repo(&any).await;
|
||||
assert_eq!(
|
||||
walk_all(&any, data_root, &entries).await.expect("pristine"),
|
||||
RECORD_COUNT
|
||||
);
|
||||
|
||||
shred_data_files(&dir.path().join("data"));
|
||||
assert!(
|
||||
walk_all(&any, data_root, &entries).await.is_err(),
|
||||
"corruption must break the walk"
|
||||
);
|
||||
|
||||
let outcome = any
|
||||
.repair_structure(&entries, data_root)
|
||||
.await
|
||||
.expect("repair_structure");
|
||||
assert!(outcome.nodes_repaired > 0, "repair must rewrite a node");
|
||||
|
||||
assert_eq!(
|
||||
walk_all(&any, data_root, &entries)
|
||||
.await
|
||||
.expect("in-process walk after repair"),
|
||||
RECORD_COUNT
|
||||
);
|
||||
|
||||
let snap = tempfile::tempdir().expect("snap tempdir");
|
||||
copy_dir_recursive(dir.path(), snap.path());
|
||||
|
||||
let reopened = open_store(snap.path());
|
||||
assert_eq!(
|
||||
walk_all(&reopened, data_root, &entries)
|
||||
.await
|
||||
.expect("every key must resolve after crash-recovery of a repair"),
|
||||
RECORD_COUNT,
|
||||
"repair did not survive reopen"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
mod common;
|
||||
|
||||
use cid::Cid;
|
||||
use common::{base_url, client, create_account_and_login, store_data_dir};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
use tranquil_store::blockstore::{BLOCK_HEADER_SIZE, CID_SIZE};
|
||||
|
||||
#[ctor::ctor]
|
||||
fn force_store_backend() {
|
||||
unsafe {
|
||||
std::env::set_var("TRANQUIL_TEST_BACKEND", "store");
|
||||
}
|
||||
}
|
||||
|
||||
const COLLECTION: &str = "app.bsky.feed.post";
|
||||
|
||||
fn post_record(i: usize) -> Value {
|
||||
json!({
|
||||
"$type": COLLECTION,
|
||||
"text": format!("self-heal record {i}"),
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_creates(token: &str, did: &str, start: usize, count: usize) {
|
||||
let writes: Vec<Value> = (start..start + count)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": COLLECTION,
|
||||
"rkey": format!("selfheal{i:05}"),
|
||||
"value": post_record(i)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let res = client()
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites send");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"applyWrites failed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
}
|
||||
|
||||
async fn latest_commit_cid(did: &str) -> Cid {
|
||||
let res = client()
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getLatestCommit?did={did}",
|
||||
base_url().await
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("getLatestCommit send");
|
||||
assert_eq!(res.status(), StatusCode::OK, "getLatestCommit failed");
|
||||
let body: Value = res.json().await.expect("getLatestCommit json");
|
||||
Cid::from_str(body["cid"].as_str().expect("commit cid")).expect("parse commit cid")
|
||||
}
|
||||
|
||||
fn collect_tqb(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_tqb(&path, out);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("tqb") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn corrupt_every_block_except(data_dir: &std::path::Path, keep: &[u8]) -> usize {
|
||||
let mut corrupted = 0usize;
|
||||
let mut files = Vec::new();
|
||||
collect_tqb(data_dir, &mut files);
|
||||
for path in files {
|
||||
let mut bytes = std::fs::read(&path).expect("read tqb");
|
||||
let mut pos = BLOCK_HEADER_SIZE;
|
||||
while pos + CID_SIZE + 4 <= bytes.len() {
|
||||
let cid = &bytes[pos..pos + CID_SIZE];
|
||||
let len = u32::from_le_bytes(
|
||||
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
) as usize;
|
||||
let data_start = pos + CID_SIZE + 4;
|
||||
let rec_end = data_start + len + 4;
|
||||
if rec_end > bytes.len() {
|
||||
break;
|
||||
}
|
||||
if cid != keep && len > 0 {
|
||||
bytes[data_start] ^= 0xFF;
|
||||
corrupted += 1;
|
||||
}
|
||||
pos = rec_end;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted tqb");
|
||||
}
|
||||
corrupted
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_self_heals_after_mst_node_corruption() {
|
||||
let client = client();
|
||||
let (token, did) = create_account_and_login(&client).await;
|
||||
|
||||
apply_creates(&token, &did, 0, 150).await;
|
||||
apply_creates(&token, &did, 150, 150).await;
|
||||
|
||||
let commit_cid = latest_commit_cid(&did).await;
|
||||
let commit_bytes = commit_cid.to_bytes();
|
||||
|
||||
let data_dir = store_data_dir().expect("store backend data dir");
|
||||
let corrupted = corrupt_every_block_except(&data_dir, &commit_bytes);
|
||||
assert!(corrupted > 0, "expected to corrupt committed blocks");
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": COLLECTION,
|
||||
"validate": false,
|
||||
"record": post_record(9999)
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createRecord send");
|
||||
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"write should self-heal corrupted MST and succeed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
|
||||
const RECORD_COUNT: usize = 300;
|
||||
|
||||
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 64 * 1024,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
|
||||
}
|
||||
|
||||
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
|
||||
let mut mst = Mst::new(Arc::new(any.clone()));
|
||||
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
|
||||
for i in 0..RECORD_COUNT {
|
||||
let key = format!("app.bsky.feed.post/{i:0>6}");
|
||||
let body = format!("record body number {i}").into_bytes();
|
||||
let cid = any.put(&body).await.expect("put record");
|
||||
mst.add_mut(&key, cid).await.expect("mst add");
|
||||
entries.push((key, cid));
|
||||
}
|
||||
let data_root = mst.persist().await.expect("persist mst");
|
||||
(data_root, entries)
|
||||
}
|
||||
|
||||
fn shred_data_files(data_dir: &std::path::Path) {
|
||||
let mut shredded = false;
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut off = 5usize;
|
||||
while off + 48 < bytes.len() {
|
||||
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
|
||||
off += 192;
|
||||
shredded = true;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
}
|
||||
assert!(shredded, "no .tqb data file was corrupted");
|
||||
}
|
||||
|
||||
async fn walk_all(
|
||||
any: &AnyBlockStore,
|
||||
root: Cid,
|
||||
entries: &[(String, Cid)],
|
||||
) -> Result<usize, String> {
|
||||
let mst = Mst::load(Arc::new(any.clone()), root, None);
|
||||
let mut resolved = 0usize;
|
||||
for (key, expected) in entries {
|
||||
match mst.get(key).await {
|
||||
Ok(Some(cid)) if cid == *expected => resolved += 1,
|
||||
Ok(Some(cid)) => {
|
||||
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
|
||||
}
|
||||
Ok(None) => return Err(format!("{key}: missing")),
|
||||
Err(e) => return Err(format!("{key}: read error {e}")),
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_restores_mst_after_node_corruption() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
|
||||
let (data_root, entries) = build_repo(&any).await;
|
||||
|
||||
let resolved = walk_all(&any, data_root, &entries)
|
||||
.await
|
||||
.expect("pristine tree must resolve every key");
|
||||
assert_eq!(resolved, RECORD_COUNT);
|
||||
|
||||
shred_data_files(&dir.path().join("data"));
|
||||
|
||||
let broken = walk_all(&any, data_root, &entries).await;
|
||||
assert!(
|
||||
broken.is_err(),
|
||||
"corruption must break the MST walk, got {broken:?}"
|
||||
);
|
||||
|
||||
let outcome = any
|
||||
.repair_structure(&entries, data_root)
|
||||
.await
|
||||
.expect("repair_structure");
|
||||
assert!(
|
||||
outcome.nodes_repaired > 0,
|
||||
"repair must rewrite at least one node, got {outcome:?}"
|
||||
);
|
||||
|
||||
let resolved = walk_all(&any, data_root, &entries)
|
||||
.await
|
||||
.expect("every key must resolve after repair");
|
||||
assert_eq!(resolved, RECORD_COUNT);
|
||||
}
|
||||
@@ -45,9 +45,12 @@ async fn test_request_password_reset_creates_code() {
|
||||
.expect("user not found");
|
||||
assert!(info.code.is_some());
|
||||
assert!(info.expires_at.is_some());
|
||||
// The stored code is normalized: uppercase base32, 10 chars, no hyphen.
|
||||
// The hyphenated display form only appears in the email.
|
||||
let code = info.code.unwrap();
|
||||
assert!(code.contains('-'));
|
||||
assert_eq!(code.len(), 11);
|
||||
assert!(!code.contains('-'));
|
||||
assert_eq!(code.len(), 10);
|
||||
assert_eq!(code, code.to_uppercase());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -109,7 +112,10 @@ async fn test_reset_password_with_valid_token() {
|
||||
.await
|
||||
.expect("failed to look up user")
|
||||
.expect("user not found");
|
||||
let token = info.code.expect("No reset code");
|
||||
let stored = info.code.expect("No reset code");
|
||||
// Submit a variant a user might actually type: lowercased, with the display
|
||||
// hyphen re-inserted. Normalization must still accept it.
|
||||
let token = format!("{}-{}", &stored[0..5], &stored[5..10]).to_lowercase();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.resetPassword",
|
||||
|
||||
@@ -188,12 +188,22 @@ async fn test_plc_token_lifecycle() {
|
||||
"PLC token should be created in database"
|
||||
);
|
||||
let first = &tokens[0];
|
||||
// The token is persisted in canonical (normalized) form: uppercase base32,
|
||||
// 10 chars, no hyphen. The hyphenated display form only appears in the email.
|
||||
assert_eq!(
|
||||
first.token.len(),
|
||||
11,
|
||||
"Token should be in format xxxxx-xxxxx"
|
||||
10,
|
||||
"Stored token should be the 10-char canonical form"
|
||||
);
|
||||
assert!(
|
||||
!first.token.contains('-'),
|
||||
"Stored token should not contain a hyphen"
|
||||
);
|
||||
assert_eq!(
|
||||
first.token,
|
||||
first.token.to_uppercase(),
|
||||
"Stored token should be uppercase"
|
||||
);
|
||||
assert!(first.token.contains('-'), "Token should contain hyphen");
|
||||
assert!(
|
||||
first.expires_at > chrono::Utc::now(),
|
||||
"Token should not be expired"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
|
||||
use cid::Cid;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use tranquil_types::Did;
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_fails_loud_on_missing_leaf_block() {
|
||||
let client = client();
|
||||
let repos = get_test_repos().await;
|
||||
let block_store = get_test_block_store().await;
|
||||
let state = get_test_app_state().await;
|
||||
|
||||
let Some(pg) = block_store.as_postgres() else {
|
||||
eprintln!(
|
||||
"repair_fails_loud_on_missing_leaf_block: requires postgres backend, skipping under store backend"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let pool = pg.pool();
|
||||
|
||||
let (did, jwt) = setup_new_user("repair-leaf-loss").await;
|
||||
let writes: Vec<serde_json::Value> = (0..6)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": format!("leafloss{i:05}"),
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": format!("repair leaf loss {i}"),
|
||||
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.applyWrites",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
|
||||
.send()
|
||||
.await
|
||||
.expect("applyWrites send");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
reqwest::StatusCode::OK,
|
||||
"applyWrites failed: {:?}",
|
||||
res.text().await
|
||||
);
|
||||
|
||||
let user_id = repos
|
||||
.user
|
||||
.get_id_by_did(&Did::new(did.clone()).unwrap())
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("user not found");
|
||||
|
||||
let root_str = repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("repo root not found");
|
||||
let commit_cid = Cid::from_str(&root_str).expect("parse commit cid");
|
||||
let commit_bytes = block_store
|
||||
.get(&commit_cid)
|
||||
.await
|
||||
.expect("read commit")
|
||||
.expect("commit present");
|
||||
let mst_root_cid = Commit::from_cbor(&commit_bytes).expect("parse commit").data;
|
||||
|
||||
let records = repos
|
||||
.repo
|
||||
.get_all_records(user_id)
|
||||
.await
|
||||
.expect("get_all_records");
|
||||
assert!(!records.is_empty(), "repo must contain records");
|
||||
let leaf_cid = Cid::from_str(records[0].record_cid.as_str()).expect("parse leaf cid");
|
||||
assert!(
|
||||
block_store
|
||||
.get(&leaf_cid)
|
||||
.await
|
||||
.expect("read leaf")
|
||||
.is_some(),
|
||||
"leaf must be present before corruption"
|
||||
);
|
||||
|
||||
repos
|
||||
.repo
|
||||
.delete_user_blocks(user_id, &[leaf_cid.to_bytes()])
|
||||
.await
|
||||
.expect("clear leaf user_blocks row");
|
||||
|
||||
sqlx::query("DELETE FROM blocks WHERE cid = $1")
|
||||
.bind(mst_root_cid.to_bytes())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete mst root node block");
|
||||
sqlx::query("DELETE FROM blocks WHERE cid = $1")
|
||||
.bind(leaf_cid.to_bytes())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete leaf record block");
|
||||
|
||||
assert!(
|
||||
block_store
|
||||
.get(&mst_root_cid)
|
||||
.await
|
||||
.expect("read")
|
||||
.is_none(),
|
||||
"mst root node must be gone to force a structural repair"
|
||||
);
|
||||
assert!(
|
||||
block_store.get(&leaf_cid).await.expect("read").is_none(),
|
||||
"leaf block must be gone to simulate data loss"
|
||||
);
|
||||
|
||||
let err = tranquil_pds::repo_ops::repair_repo_structure(state, user_id)
|
||||
.await
|
||||
.expect_err("repair must fail loud when a leaf block is unrecoverable");
|
||||
let detail = format!("{err:?}");
|
||||
assert!(
|
||||
detail.contains("leaf data loss"),
|
||||
"expected an unrecoverable-leaf-loss error, got: {detail}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
block_store
|
||||
.get(&mst_root_cid)
|
||||
.await
|
||||
.expect("read")
|
||||
.is_some(),
|
||||
"structural repair must still re-insert the regenerable MST node"
|
||||
);
|
||||
|
||||
let recorded = repos
|
||||
.repo
|
||||
.get_user_block_cids_since_rev(user_id, "")
|
||||
.await
|
||||
.expect("read user_blocks");
|
||||
assert!(
|
||||
!recorded.contains(&leaf_cid.to_bytes()),
|
||||
"missing leaf must not be phantom-inserted into user_blocks"
|
||||
);
|
||||
}
|
||||
@@ -171,7 +171,7 @@ async fn fetch_lexicon_via_atproto(nsid: &str) -> Result<LexiconDoc, ScopeExpans
|
||||
return Err(ScopeExpansionError::InvalidNsid(nsid.to_string()));
|
||||
}
|
||||
|
||||
let authority = parts[..2]
|
||||
let authority = parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
@@ -661,26 +661,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_authority(nsid: &str) -> String {
|
||||
let parts: Vec<&str> = nsid.split('.').collect();
|
||||
parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nsid_authority_extraction_for_dns() {
|
||||
let nsid = "io.atcr.authFullApp";
|
||||
let parts: Vec<&str> = nsid.split('.').collect();
|
||||
let authority = parts[..2]
|
||||
.iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
assert_eq!(authority, "atcr.io");
|
||||
|
||||
let nsid2 = "app.bsky.feed.post";
|
||||
let parts2: Vec<&str> = nsid2.split('.').collect();
|
||||
let authority2 = parts2[..2]
|
||||
.iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
assert_eq!(authority2, "bsky.app");
|
||||
assert_eq!(dns_authority("io.atcr.authFullApp"), "atcr.io");
|
||||
assert_eq!(dns_authority("app.bsky.feed.post"), "feed.bsky.app");
|
||||
assert_eq!(
|
||||
dns_authority("community.lexicon.bookmarks.authManageBookmarks"),
|
||||
"bookmarks.lexicon.community"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,18 @@ tranquil-signal = { workspace = true }
|
||||
|
||||
arc-swap = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
h3 = { workspace = true }
|
||||
h3-quinn = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
http = { workspace = true }
|
||||
hyper = { workspace = true }
|
||||
hyper-util = { workspace = true }
|
||||
quinn = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
rustls-pemfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -31,6 +36,9 @@ tower = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["frontend", "s3", "valkey"]
|
||||
frontend = ["tranquil-pds/frontend"]
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use bytes::{Buf, Bytes};
|
||||
use futures_util::StreamExt;
|
||||
use http::header::ALT_SVC;
|
||||
use http::{HeaderValue, Request, Response, StatusCode};
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
use quinn::{Endpoint, Incoming, ServerConfig, TransportConfig, VarInt};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use tower::ServiceExt;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::tls::{ReloadableCertResolver, TlsError};
|
||||
|
||||
const MAX_CONCURRENT_BIDI_STREAMS: u32 = 256;
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
|
||||
const ALT_SVC_MAX_AGE_SECS: u32 = 86_400;
|
||||
|
||||
pub fn build_quic_server_config(
|
||||
resolver: Arc<ReloadableCertResolver>,
|
||||
) -> Result<ServerConfig, TlsError> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut crypto = rustls::ServerConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|e| TlsError::Config(e.to_string()))?
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(resolver);
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
|
||||
let quic_crypto =
|
||||
QuicServerConfig::try_from(crypto).map_err(|e| TlsError::Config(e.to_string()))?;
|
||||
let mut config = ServerConfig::with_crypto(Arc::new(quic_crypto));
|
||||
|
||||
let mut transport = TransportConfig::default();
|
||||
transport.max_concurrent_bidi_streams(VarInt::from_u32(MAX_CONCURRENT_BIDI_STREAMS));
|
||||
transport.max_idle_timeout(Some(
|
||||
IDLE_TIMEOUT
|
||||
.try_into()
|
||||
.expect("idle timeout fits in varint"),
|
||||
));
|
||||
config.transport_config(Arc::new(transport));
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn alt_svc_header(port: u16) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!("h3=\":{port}\"; ma={ALT_SVC_MAX_AGE_SECS}"))
|
||||
.expect("alt-svc header value is valid ascii")
|
||||
}
|
||||
|
||||
pub fn with_alt_svc(app: Router, port: u16) -> Router {
|
||||
let value = alt_svc_header(port);
|
||||
app.layer(axum::middleware::map_response(
|
||||
move |mut response: Response<Body>| {
|
||||
let value = value.clone();
|
||||
async move {
|
||||
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
|
||||
response.headers_mut().insert(ALT_SVC, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn with_host_from_authority(app: Router) -> Router {
|
||||
app.layer(axum::middleware::map_request(
|
||||
|mut request: Request<Body>| async move {
|
||||
let authority = request
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|a| HeaderValue::from_str(a.as_str()));
|
||||
match (request.headers().contains_key(http::header::HOST), authority) {
|
||||
(false, Some(Ok(value))) => {
|
||||
request.headers_mut().insert(http::header::HOST, value);
|
||||
request
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn serve_http3(endpoint: Endpoint, app: Router, shutdown: CancellationToken) {
|
||||
let tracker = TaskTracker::new();
|
||||
let conn_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
incoming = endpoint.accept() => {
|
||||
let Some(incoming) = incoming else { break };
|
||||
let Ok(permit) = conn_limiter.clone().try_acquire_owned() else {
|
||||
debug!(
|
||||
peer = %incoming.remote_address(),
|
||||
max = MAX_CONCURRENT_CONNECTIONS,
|
||||
"refusing h3 connection: limit reached"
|
||||
);
|
||||
incoming.refuse();
|
||||
continue;
|
||||
};
|
||||
let app = app.clone();
|
||||
let conn_shutdown = shutdown.clone();
|
||||
let conn_tracker = tracker.clone();
|
||||
tracker.spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(e) = serve_connection(incoming, app, conn_shutdown, conn_tracker).await {
|
||||
debug!("h3 connection ended: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracker.close();
|
||||
if tokio::time::timeout(SHUTDOWN_GRACE, tracker.wait())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("h3 connections did not drain within grace, closing");
|
||||
}
|
||||
endpoint.close(0u32.into(), b"shutdown");
|
||||
endpoint.wait_idle().await;
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
incoming: Incoming,
|
||||
app: Router,
|
||||
shutdown: CancellationToken,
|
||||
tracker: TaskTracker,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let conn = incoming.await?;
|
||||
let remote = conn.remote_address();
|
||||
let mut h3_conn =
|
||||
h3::server::Connection::<_, Bytes>::new(h3_quinn::Connection::new(conn)).await?;
|
||||
|
||||
let mut draining = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled(), if !draining => {
|
||||
draining = true;
|
||||
let _ = h3_conn.shutdown(0).await;
|
||||
}
|
||||
resolved = h3_conn.accept() => match resolved {
|
||||
Ok(Some(resolver)) => {
|
||||
let app = app.clone();
|
||||
tracker.spawn(async move {
|
||||
if let Err(e) = serve_request(resolver, app, remote).await {
|
||||
debug!(peer = %remote, "h3 request failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
debug!(peer = %remote, "h3 accept error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_request(
|
||||
resolver: h3::server::RequestResolver<h3_quinn::Connection, Bytes>,
|
||||
app: Router,
|
||||
remote: SocketAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (req, stream) = resolver.resolve_request().await?;
|
||||
let (mut send, recv) = stream.split();
|
||||
|
||||
let (mut parts, ()) = req.into_parts();
|
||||
parts.extensions.insert(ConnectInfo(remote));
|
||||
let request = Request::from_parts(parts, request_body(recv));
|
||||
|
||||
let response = match app.oneshot(request).await {
|
||||
Ok(response) => response,
|
||||
Err(infallible) => match infallible {},
|
||||
};
|
||||
|
||||
let (parts, body) = response.into_parts();
|
||||
send.send_response(Response::from_parts(parts, ())).await?;
|
||||
|
||||
let mut data = body.into_data_stream();
|
||||
while let Some(chunk) = data.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) if bytes.has_remaining() => send.send_data(bytes).await?,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
debug!(peer = %remote, "h3 response body error: {e}");
|
||||
send.stop_stream(h3::error::Code::H3_INTERNAL_ERROR);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
send.finish().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RecvGuard {
|
||||
stream: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>,
|
||||
ended: bool,
|
||||
}
|
||||
|
||||
impl Drop for RecvGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.ended {
|
||||
self.stream.stop_sending(h3::error::Code::H3_NO_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body(recv: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>) -> Body {
|
||||
let guard = RecvGuard {
|
||||
stream: recv,
|
||||
ended: false,
|
||||
};
|
||||
let stream = futures_util::stream::unfold(Some(guard), |state| async move {
|
||||
let mut guard = state?;
|
||||
match guard.stream.recv_data().await {
|
||||
Ok(Some(mut buf)) => {
|
||||
let bytes = buf.copy_to_bytes(buf.remaining());
|
||||
Some((Ok::<Bytes, std::io::Error>(bytes), Some(guard)))
|
||||
}
|
||||
Ok(None) => {
|
||||
guard.ended = true;
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
guard.ended = true;
|
||||
Some((Err(std::io::Error::other(e.to_string())), None))
|
||||
}
|
||||
}
|
||||
});
|
||||
Body::from_stream(stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::routing::get;
|
||||
use rustls::pki_types::{
|
||||
CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime,
|
||||
};
|
||||
|
||||
fn self_signed_resolver() -> Arc<ReloadableCertResolver> {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
|
||||
let cert_der = cert.cert.der().clone();
|
||||
let key_der =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
|
||||
let certified = rustls::sign::CertifiedKey::new(vec![cert_der], signing_key);
|
||||
Arc::new(ReloadableCertResolver::new(certified))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkipServerVerification(Arc<rustls::crypto::CryptoProvider>);
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.0.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.0.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.0.signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
fn client_endpoint() -> Endpoint {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut crypto = rustls::ClientConfig::builder_with_provider(provider.clone())
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification(provider)))
|
||||
.with_no_client_auth();
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
|
||||
endpoint
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_get_roundtrips_through_router() {
|
||||
let app = Router::new().route("/", get(|| async { "ok" }));
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
|
||||
let response = stream.recv_response().await.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
assert_eq!(body.as_slice(), b"ok");
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_svc_header_advertises_h3() {
|
||||
assert_eq!(
|
||||
alt_svc_header(443).to_str().unwrap(),
|
||||
"h3=\":443\"; ma=86400"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alt_svc_added_to_responses_except_switching_protocols() {
|
||||
let app = with_alt_svc(
|
||||
Router::new().route("/ok", get(|| async { "ok" })).route(
|
||||
"/upgrade",
|
||||
get(|| async {
|
||||
Response::builder()
|
||||
.status(StatusCode::SWITCHING_PROTOCOLS)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}),
|
||||
),
|
||||
443,
|
||||
);
|
||||
|
||||
let normal = app
|
||||
.clone()
|
||||
.oneshot(Request::get("/ok").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
normal.headers().get(ALT_SVC).and_then(|v| v.to_str().ok()),
|
||||
Some("h3=\":443\"; ma=86400")
|
||||
);
|
||||
|
||||
let upgrade = app
|
||||
.oneshot(Request::get("/upgrade").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
upgrade.headers().get(ALT_SVC).is_none(),
|
||||
"101 responses must not carry Alt-Svc"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cert(dns: &str) -> (rustls::sign::CertifiedKey, CertificateDer<'static>) {
|
||||
let cert = rcgen::generate_simple_self_signed(vec![dns.to_string()]).unwrap();
|
||||
let cert_der = cert.cert.der().clone();
|
||||
let key_der =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
|
||||
let certified = rustls::sign::CertifiedKey::new(vec![cert_der.clone()], signing_key);
|
||||
(certified, cert_der)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingVerifier {
|
||||
provider: Arc<rustls::crypto::CryptoProvider>,
|
||||
seen: Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for RecordingVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
*self.seen.lock().unwrap() = end_entity.as_ref().to_vec();
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_server_cert(addr: SocketAddr) -> Vec<u8> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let verifier = Arc::new(RecordingVerifier {
|
||||
provider: provider.clone(),
|
||||
seen: seen.clone(),
|
||||
});
|
||||
let mut crypto = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
|
||||
let mut endpoint = Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
|
||||
let conn = endpoint.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
conn.close(0u32.into(), b"done");
|
||||
endpoint.wait_idle().await;
|
||||
seen.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quic_handshake_observes_reloaded_certificate() {
|
||||
let (cert_a, der_a) = make_cert("localhost");
|
||||
let (cert_b, der_b) = make_cert("localhost");
|
||||
assert_ne!(der_a, der_b, "test must use two distinct certs");
|
||||
|
||||
let resolver = Arc::new(ReloadableCertResolver::new(cert_a));
|
||||
let server_config = build_quic_server_config(resolver.clone()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
let app = Router::new().route("/", get(|| async { "ok" }));
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let before = observe_server_cert(addr).await;
|
||||
assert_eq!(
|
||||
before.as_slice(),
|
||||
der_a.as_ref(),
|
||||
"first handshake must present the original cert"
|
||||
);
|
||||
|
||||
resolver.store(cert_b);
|
||||
|
||||
let after = observe_server_cert(addr).await;
|
||||
assert_eq!(
|
||||
after.as_slice(),
|
||||
der_b.as_ref(),
|
||||
"handshake after reload must present the new cert"
|
||||
);
|
||||
assert_ne!(before, after, "reload must change the presented cert");
|
||||
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_requests_carry_remote_addr_connect_info() {
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(|ConnectInfo(addr): ConnectInfo<SocketAddr>| async move { addr.to_string() }),
|
||||
);
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let client_port = client.local_addr().unwrap().port();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
let reported: SocketAddr = String::from_utf8(body).unwrap().parse().unwrap();
|
||||
assert!(reported.ip().is_loopback());
|
||||
assert_eq!(
|
||||
reported.port(),
|
||||
client_port,
|
||||
"handlers must see the QUIC remote address via ConnectInfo"
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_header_filled_from_authority() {
|
||||
let app = with_host_from_authority(Router::new().route(
|
||||
"/",
|
||||
get(|headers: http::HeaderMap| async move {
|
||||
headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
));
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
assert_eq!(
|
||||
String::from_utf8(body).unwrap(),
|
||||
"localhost",
|
||||
"handlers must see the authority as the Host header"
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_body_error_resets_stream_instead_of_truncating() {
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(|| async {
|
||||
Body::from_stream(futures_util::stream::iter(vec![
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"partial")),
|
||||
Err(std::io::Error::other("body source failed")),
|
||||
]))
|
||||
}),
|
||||
);
|
||||
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
tokio::spawn(serve_http3(server, app, shutdown.clone()));
|
||||
|
||||
let client = client_endpoint();
|
||||
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
|
||||
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
|
||||
.await
|
||||
.unwrap();
|
||||
let drive =
|
||||
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
|
||||
|
||||
let req = Request::get("https://localhost/").body(()).unwrap();
|
||||
let mut stream = send_request.send_request(req).await.unwrap();
|
||||
stream.finish().await.unwrap();
|
||||
|
||||
let outcome = async {
|
||||
stream.recv_response().await?;
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
match stream.recv_data().await {
|
||||
Ok(Some(mut chunk)) => {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
Ok(None) => return Ok(body),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"a mid-body error must reset the stream, not end the body cleanly after {} bytes",
|
||||
outcome.map(|b| b.len()).unwrap_or(0)
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
drive.abort();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use tranquil_pds::scheduled::{
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
mod http3;
|
||||
mod tls;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -259,7 +260,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
shutdown.clone(),
|
||||
));
|
||||
|
||||
let app = tranquil_pds::app_with_routes(
|
||||
let app = http3::with_host_from_authority(tranquil_pds::app_with_routes(
|
||||
state,
|
||||
tranquil_pds::ExternalRoutes {
|
||||
xrpc: tranquil_api::api_routes().merge(tranquil_sync::sync_routes()),
|
||||
@@ -270,7 +271,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.merge(tranquil_api::webhook_routes())
|
||||
.merge(tranquil_oauth_server::frontend_client_metadata_route()),
|
||||
},
|
||||
);
|
||||
));
|
||||
|
||||
let cfg = tranquil_config::get();
|
||||
let host = &cfg.server.host;
|
||||
@@ -286,6 +287,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
|
||||
|
||||
let mut http3_handle: Option<tokio::task::JoinHandle<()>> = None;
|
||||
|
||||
let server_handle = match cfg.server.tls.material() {
|
||||
Some((cert_path, key_path)) => {
|
||||
let initial = tls::load_certified_key(cert_path, key_path)
|
||||
@@ -296,14 +299,35 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map_err(|e| format!("Failed to build TLS configuration: {e}"))?,
|
||||
);
|
||||
tls::spawn_reload_handler(
|
||||
resolver,
|
||||
resolver.clone(),
|
||||
cert_path.to_string(),
|
||||
key_path.to_string(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
|
||||
let tcp_app = if cfg.server.tls.http3 {
|
||||
let quic_config = http3::build_quic_server_config(resolver)
|
||||
.map_err(|e| format!("Failed to build HTTP/3 configuration: {e}"))?;
|
||||
let endpoint = quinn::Endpoint::server(quic_config, addr)
|
||||
.map_err(|e| format!("Failed to bind HTTP/3 endpoint on {addr}: {e}"))?;
|
||||
let h3_port = endpoint
|
||||
.local_addr()
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to read HTTP/3 local address: {e}"))?;
|
||||
info!("HTTP/3 enabled on udp/{h3_port}");
|
||||
http3_handle = Some(tokio::spawn(http3::serve_http3(
|
||||
endpoint,
|
||||
app.clone(),
|
||||
shutdown.clone(),
|
||||
)));
|
||||
http3::with_alt_svc(app, h3_port)
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
info!("TLS termination enabled (h2, http/1.1), reload with SIGHUP");
|
||||
let shutdown = shutdown.clone();
|
||||
tokio::spawn(tls::serve_tls(listener, app, server_config, shutdown))
|
||||
tokio::spawn(tls::serve_tls(listener, tcp_app, server_config, shutdown))
|
||||
}
|
||||
None => {
|
||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||
@@ -332,6 +356,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|e| format!("Server task panicked: {}", e))?;
|
||||
|
||||
if let Some(handle) = http3_handle {
|
||||
handle.await.ok();
|
||||
}
|
||||
|
||||
comms_handle.await.ok();
|
||||
|
||||
if let Some(handle) = crawlers_handle {
|
||||
|
||||
@@ -211,6 +211,9 @@ pub async fn serve_tls(
|
||||
async move {
|
||||
match accepted {
|
||||
Ok((tcp, peer)) => {
|
||||
if let Err(e) = tcp.set_nodelay(true) {
|
||||
debug!("failed to set nodelay for {peer}: {e}");
|
||||
}
|
||||
let permit = tokio::select! {
|
||||
biased;
|
||||
_ = conn_shutdown.cancelled() => return,
|
||||
|
||||
@@ -38,11 +38,13 @@ tempfile = { version = "3", optional = true }
|
||||
clap = { workspace = true, optional = true }
|
||||
toml = { version = "0.8", optional = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true }
|
||||
tikv-jemallocator = { version = "0.6", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
test-harness = ["dep:tempfile"]
|
||||
gauntlet-cli = ["test-harness", "dep:clap", "dep:toml", "dep:tracing-subscriber"]
|
||||
jemalloc = ["dep:tikv-jemallocator"]
|
||||
gauntlet-cli = ["test-harness", "dep:clap", "dep:toml", "dep:tracing-subscriber", "jemalloc"]
|
||||
gauntlet-jemalloc-prof = []
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -13,6 +13,11 @@ use sha2::{Digest, Sha256};
|
||||
use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
#[cfg(feature = "jemalloc")]
|
||||
#[global_allocator]
|
||||
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
||||
|
||||
const DAG_CBOR_CODEC: u64 = 0x71;
|
||||
const SHA2_256_CODE: u64 = 0x12;
|
||||
@@ -60,11 +65,11 @@ fn compute_stats(durations: &mut [Duration]) -> Option<LatencyStats> {
|
||||
})
|
||||
}
|
||||
|
||||
fn open_store(dir: &Path) -> TranquilBlockStore {
|
||||
fn open_store(dir: &Path) -> TranquilBlockStore<RealIO, SystemClock> {
|
||||
open_store_sharded(dir, 1)
|
||||
}
|
||||
|
||||
fn open_store_sharded(dir: &Path, shard_count: u8) -> TranquilBlockStore {
|
||||
fn open_store_sharded(dir: &Path, shard_count: u8) -> TranquilBlockStore<RealIO, SystemClock> {
|
||||
TranquilBlockStore::open(BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
@@ -183,7 +188,9 @@ async fn bench_read_throughput(block_count: usize, concurrency: usize) {
|
||||
cids
|
||||
};
|
||||
|
||||
let run_reads = |label: &'static str, store: TranquilBlockStore, cids: Vec<Cid>| async move {
|
||||
let run_reads = |label: &'static str,
|
||||
store: TranquilBlockStore<RealIO, SystemClock>,
|
||||
cids: Vec<Cid>| async move {
|
||||
let start = Instant::now();
|
||||
|
||||
let handles: Vec<_> = (0..concurrency)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
scenario = "moderate-faults"
|
||||
seeds = 128
|
||||
dump_regressions = "/root/gauntlet-regressions"
|
||||
|
||||
[base_overrides]
|
||||
op_count = 20000
|
||||
|
||||
[axes]
|
||||
fault_density_scale = [0.0, 1.0]
|
||||
advance_time = [20, 60]
|
||||
advance_max_secs = [3600, 86400, 604800]
|
||||
@@ -0,0 +1,7 @@
|
||||
scenario = "firehose-fanout"
|
||||
seeds = 128
|
||||
dump_regressions = "/root/gauntlet-regressions"
|
||||
|
||||
[axes]
|
||||
advance_time = [20, 60]
|
||||
advance_max_secs = [3600, 86400, 604800]
|
||||
@@ -0,0 +1,7 @@
|
||||
scenario = "eventlog-time-travel-chaos"
|
||||
seeds = 256
|
||||
dump_regressions = "/root/gauntlet-regressions"
|
||||
|
||||
[overrides]
|
||||
op_count = 4000
|
||||
fault_density_scale = 0.1
|
||||
@@ -0,0 +1,7 @@
|
||||
scenario = "eventlog-time-travel-chaos"
|
||||
seeds = 256
|
||||
dump_regressions = "/root/gauntlet-regressions"
|
||||
|
||||
[overrides]
|
||||
op_count = 4000
|
||||
fault_density_scale = 1.0
|
||||
@@ -0,0 +1,6 @@
|
||||
scenario = "eventlog-time-travel-chaos"
|
||||
seeds = 64
|
||||
|
||||
[axes]
|
||||
fault_density_scale = [0.0, 1.0]
|
||||
advance_time = [0, 20]
|
||||
@@ -8,6 +8,7 @@ use crate::blockstore::{
|
||||
BlockOffset, BlockstoreSnapshot, CommitEpoch, CommitError, DataFileId, QuiesceGuard,
|
||||
RebuildError, TranquilBlockStore,
|
||||
};
|
||||
use crate::clock::SystemClock;
|
||||
use crate::eventlog::{
|
||||
EventLog, EventLogConfig, EventLogFreezeGuard, EventLogSnapshotState, EventSequence,
|
||||
EventWithMutations, SegmentId, SegmentOffset,
|
||||
@@ -157,14 +158,14 @@ enum BackupLineage<'a> {
|
||||
}
|
||||
|
||||
pub struct BackupCoordinator<'a, S: StorageIO> {
|
||||
blockstore: &'a TranquilBlockStore,
|
||||
blockstore: &'a TranquilBlockStore<RealIO, SystemClock>,
|
||||
eventlog: &'a EventLog<S>,
|
||||
metastore: &'a Metastore,
|
||||
}
|
||||
|
||||
impl<'a, S: StorageIO + Send + Sync + 'static> BackupCoordinator<'a, S> {
|
||||
pub fn new(
|
||||
blockstore: &'a TranquilBlockStore,
|
||||
blockstore: &'a TranquilBlockStore<RealIO, SystemClock>,
|
||||
eventlog: &'a EventLog<S>,
|
||||
metastore: &'a Metastore,
|
||||
) -> Self {
|
||||
@@ -312,7 +313,7 @@ impl<'a, S: StorageIO + Send + Sync + 'static> BackupCoordinator<'a, S> {
|
||||
|
||||
BackupManifest {
|
||||
version: BACKUP_FORMAT_VERSION,
|
||||
created_at_ms: crate::wall_clock_ms().raw(),
|
||||
created_at_ms: crate::blockstore::WallClockMs::now().raw(),
|
||||
blockstore: {
|
||||
let max_cursor = bs
|
||||
.shard_cursors
|
||||
|
||||
@@ -14,6 +14,10 @@ use tranquil_store::gauntlet::{
|
||||
shrink::{DEFAULT_MAX_SHRINK_ITERATIONS, shrink_failure},
|
||||
};
|
||||
|
||||
#[cfg(feature = "jemalloc")]
|
||||
#[global_allocator]
|
||||
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
||||
|
||||
const MAX_HOURS: f64 = 1.0e6;
|
||||
const DEFAULT_SWEEP_RUN_CAP: u64 = 10_000;
|
||||
|
||||
@@ -223,6 +227,10 @@ struct SweepAxes {
|
||||
commit_batch_size: Vec<usize>,
|
||||
#[serde(default)]
|
||||
max_file_size: Vec<u64>,
|
||||
#[serde(default)]
|
||||
advance_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
advance_max_secs: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -235,6 +243,8 @@ struct SweepAxisValues {
|
||||
restart_every_n_ops: Option<usize>,
|
||||
commit_batch_size: Option<usize>,
|
||||
max_file_size: Option<u64>,
|
||||
advance_time: Option<u32>,
|
||||
advance_max_secs: Option<u32>,
|
||||
}
|
||||
|
||||
impl SweepAxisValues {
|
||||
@@ -263,51 +273,61 @@ impl SweepAxisValues {
|
||||
if let Some(v) = self.max_file_size {
|
||||
o.store.max_file_size = Some(v);
|
||||
}
|
||||
if let Some(v) = self.advance_time {
|
||||
o.advance_time = Some(v);
|
||||
}
|
||||
if let Some(v) = self.advance_max_secs {
|
||||
o.advance_max_secs = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepAxes {
|
||||
fn axis_values(&self) -> Vec<SweepAxisValues> {
|
||||
expand(&self.writer_concurrency)
|
||||
.into_iter()
|
||||
.flat_map(|wc| {
|
||||
expand(&self.key_space).into_iter().flat_map(move |ks| {
|
||||
expand(&self.value_bytes).into_iter().flat_map(move |vb| {
|
||||
expand(&self.fault_density_scale)
|
||||
.into_iter()
|
||||
.flat_map(move |fds| {
|
||||
expand(&self.fault_density_uniform).into_iter().flat_map(
|
||||
move |fdu| {
|
||||
expand(&self.restart_every_n_ops).into_iter().flat_map(
|
||||
move |rc| {
|
||||
expand(&self.commit_batch_size)
|
||||
.into_iter()
|
||||
.flat_map(move |cb| {
|
||||
expand(&self.max_file_size).into_iter().map(
|
||||
move |mfs| SweepAxisValues {
|
||||
writer_concurrency: wc,
|
||||
key_space: ks,
|
||||
value_bytes: vb,
|
||||
fault_density_scale: fds,
|
||||
fault_density_uniform: fdu,
|
||||
restart_every_n_ops: rc,
|
||||
commit_batch_size: cb,
|
||||
max_file_size: mfs,
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let base = vec![SweepAxisValues::default()];
|
||||
let base = cross(base, &self.writer_concurrency, |a, v| {
|
||||
a.writer_concurrency = Some(v)
|
||||
});
|
||||
let base = cross(base, &self.key_space, |a, v| a.key_space = Some(v));
|
||||
let base = cross(base, &self.value_bytes, |a, v| a.value_bytes = Some(v));
|
||||
let base = cross(base, &self.fault_density_scale, |a, v| {
|
||||
a.fault_density_scale = Some(v)
|
||||
});
|
||||
let base = cross(base, &self.fault_density_uniform, |a, v| {
|
||||
a.fault_density_uniform = Some(v)
|
||||
});
|
||||
let base = cross(base, &self.restart_every_n_ops, |a, v| {
|
||||
a.restart_every_n_ops = Some(v)
|
||||
});
|
||||
let base = cross(base, &self.commit_batch_size, |a, v| {
|
||||
a.commit_batch_size = Some(v)
|
||||
});
|
||||
let base = cross(base, &self.max_file_size, |a, v| a.max_file_size = Some(v));
|
||||
let base = cross(base, &self.advance_time, |a, v| a.advance_time = Some(v));
|
||||
cross(base, &self.advance_max_secs, |a, v| {
|
||||
a.advance_max_secs = Some(v)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn cross<T: Copy>(
|
||||
acc: Vec<SweepAxisValues>,
|
||||
values: &[T],
|
||||
set: impl Fn(&mut SweepAxisValues, T) + Copy,
|
||||
) -> Vec<SweepAxisValues> {
|
||||
acc.into_iter()
|
||||
.flat_map(|base| {
|
||||
expand(values).into_iter().map(move |opt| {
|
||||
let mut next = base;
|
||||
if let Some(v) = opt {
|
||||
set(&mut next, v);
|
||||
}
|
||||
next
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expand<T: Copy>(values: &[T]) -> Vec<Option<T>> {
|
||||
if values.is_empty() {
|
||||
vec![None]
|
||||
@@ -613,7 +633,22 @@ fn install_interrupt(rt: &Runtime) -> Arc<AtomicBool> {
|
||||
flag
|
||||
}
|
||||
|
||||
fn raise_fd_limit() {
|
||||
let mut lim = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
let read = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0;
|
||||
if read && lim.rlim_cur < lim.rlim_max {
|
||||
lim.rlim_cur = lim.rlim_max;
|
||||
unsafe {
|
||||
let _ = libc::setrlimit(libc::RLIMIT_NOFILE, &lim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
raise_fd_limit();
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::hint::{HintFileWriter, hint_file_path};
|
||||
use super::manager::DataFileManager;
|
||||
use super::types::{
|
||||
BlockLocation, CidBytes, CommitEpoch, CompactionResult, CompactionStats, DataFileId,
|
||||
WallClockMs,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -65,6 +66,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
active_files: &ActiveFileSet,
|
||||
hint_positions: &super::group_commit::ShardHintPositions,
|
||||
epoch: &super::types::EpochCounter,
|
||||
now: WallClockMs,
|
||||
) -> Result<CompactionResult, CompactionError> {
|
||||
if active_files.contains(source_file_id) {
|
||||
return Err(CompactionError::ActiveFileCannotBeCompacted);
|
||||
@@ -89,6 +91,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
new_file_id,
|
||||
current_epoch,
|
||||
grace_period_ms,
|
||||
now,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -183,6 +186,112 @@ fn purge_phantom_file<S: StorageIO>(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn repair_blocks_on_writer_thread<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
blocks: &[(CidBytes, Vec<u8>)],
|
||||
current_epoch: CommitEpoch,
|
||||
file_ids: &FileIdAllocator,
|
||||
hint_positions: &super::group_commit::ShardHintPositions,
|
||||
epoch: &super::types::EpochCounter,
|
||||
) -> Result<u64, CompactionError> {
|
||||
if blocks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let new_file_id = file_ids.allocate();
|
||||
let new_handle = manager.open_for_append(new_file_id)?;
|
||||
let mut writer = DataFileWriter::new(manager.io(), new_handle.fd(), new_file_id)?;
|
||||
|
||||
let hint_path = hint_file_path(manager.data_dir(), new_file_id);
|
||||
let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?;
|
||||
let mut hint_writer = HintFileWriter::new(manager.io(), hint_fd);
|
||||
|
||||
let mut relocations: Vec<(CidBytes, BlockLocation)> = Vec::with_capacity(blocks.len());
|
||||
let write_result = blocks.iter().try_for_each(|(cid, data)| {
|
||||
let refcount = index.get(cid).map(|e| e.refcount.raw()).unwrap_or(1).max(1);
|
||||
let loc = writer.append_block(cid, data)?;
|
||||
hint_writer.append_relocate(cid, &loc, refcount)?;
|
||||
relocations.push((*cid, loc));
|
||||
Ok::<_, CompactionError>(())
|
||||
});
|
||||
|
||||
let record_count = u32::try_from(relocations.len()).unwrap_or(u32::MAX);
|
||||
let writer_position = writer.position();
|
||||
let finalize = write_result
|
||||
.and_then(|()| writer.sync().map_err(CompactionError::from))
|
||||
.and_then(|()| {
|
||||
hint_writer
|
||||
.append_commit_marker(
|
||||
current_epoch.raw(),
|
||||
record_count,
|
||||
new_file_id,
|
||||
writer_position,
|
||||
)
|
||||
.map_err(CompactionError::from)
|
||||
})
|
||||
.and_then(|()| hint_writer.sync().map_err(CompactionError::from))
|
||||
.and_then(|()| {
|
||||
manager
|
||||
.io()
|
||||
.sync_dir(manager.data_dir())
|
||||
.map_err(CompactionError::from)
|
||||
})
|
||||
.and_then(|()| manager.io().barrier().map_err(CompactionError::from));
|
||||
|
||||
let final_hint_offset = hint_writer.position();
|
||||
let _ = manager.io().close(hint_fd);
|
||||
|
||||
if let Err(e) =
|
||||
finalize.and_then(|()| verify_repaired_blocks(manager, new_file_id, &relocations))
|
||||
{
|
||||
manager.delete_data_file(new_file_id).ok();
|
||||
manager
|
||||
.io()
|
||||
.delete(&hint_file_path(manager.data_dir(), new_file_id))
|
||||
.ok();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
hint_positions.record_extra(new_file_id, final_hint_offset);
|
||||
index.apply_compaction(&relocations, &[]);
|
||||
index
|
||||
.write_checkpoint(epoch.current(), hint_positions)
|
||||
.map_err(CompactionError::Io)?;
|
||||
|
||||
tracing::info!(
|
||||
dest = %new_file_id,
|
||||
repaired = relocations.len(),
|
||||
"structural repair complete"
|
||||
);
|
||||
|
||||
Ok(relocations.len() as u64)
|
||||
}
|
||||
|
||||
fn verify_repaired_blocks<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
file_id: DataFileId,
|
||||
relocations: &[(CidBytes, BlockLocation)],
|
||||
) -> Result<(), CompactionError> {
|
||||
let handle = manager.open_for_read(file_id)?;
|
||||
let file_size = manager.io().file_size(handle.fd())?;
|
||||
relocations.iter().try_for_each(|(cid, loc)| {
|
||||
match super::data_file::decode_block_record(
|
||||
manager.io(),
|
||||
handle.fd(),
|
||||
loc.offset,
|
||||
file_size,
|
||||
) {
|
||||
Ok(Some(ReadBlockRecord::Valid { cid_bytes, .. })) if cid_bytes == *cid => Ok(()),
|
||||
_ => Err(CompactionError::Io(io::Error::other(
|
||||
"repaired block failed read-back verification",
|
||||
))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stream_compact<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
@@ -191,9 +300,9 @@ fn stream_compact<S: StorageIO>(
|
||||
new_file_id: DataFileId,
|
||||
current_epoch: CommitEpoch,
|
||||
grace_period_ms: u64,
|
||||
now: WallClockMs,
|
||||
) -> Result<(u64, u64, u64, super::types::HintOffset), CompactionError> {
|
||||
let mut reader = DataFileReader::open(manager.io(), source_fd)?;
|
||||
let now = crate::wall_clock_ms();
|
||||
|
||||
let new_handle = manager.open_for_append(new_file_id)?;
|
||||
let mut writer = DataFileWriter::new(manager.io(), new_handle.fd(), new_file_id)?;
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
use crate::clock::{Clock, LogicalNanos};
|
||||
use crate::fsync_order::PostBlockstoreHook;
|
||||
|
||||
use super::BlocksSynced;
|
||||
@@ -13,7 +14,7 @@ use crate::io::{FileId, OpenOptions, StorageIO};
|
||||
|
||||
use super::data_file::{CID_SIZE, DataFileWriter, ReadBlockRecord, decode_block_record};
|
||||
use super::hash_index::{BlockIndex, BlockIndexError, CheckpointPositions};
|
||||
use super::hint::{HintFileWriter, hint_file_path};
|
||||
use super::hint::{HINT_RECORD_SIZE, HintFileWriter, hint_file_path};
|
||||
use super::manager::DataFileManager;
|
||||
use super::types::{
|
||||
BlockLocation, BlockOffset, BlockstoreSnapshot, CommitEpoch, DataFileId, EpochCounter,
|
||||
@@ -174,6 +175,9 @@ type PutResponse = tokio::sync::oneshot::Sender<Result<Vec<BlockLocation>, Commi
|
||||
type ApplyResponse = tokio::sync::oneshot::Sender<Result<(), CommitError>>;
|
||||
type CompactResponse = tokio::sync::oneshot::Sender<Result<CompactionResult, CompactionError>>;
|
||||
type RepairResponse = tokio::sync::oneshot::Sender<Result<u64, CommitError>>;
|
||||
type RepairBlocksResponse = tokio::sync::oneshot::Sender<Result<u64, CompactionError>>;
|
||||
type RepairBlockSet = Vec<(CidBytes, Vec<u8>)>;
|
||||
type DeferredRepairBlock = (RepairBlockSet, RepairBlocksResponse);
|
||||
type QuiesceResponse = tokio::sync::oneshot::Sender<BlockstoreSnapshot>;
|
||||
type QuiesceResume = tokio::sync::oneshot::Receiver<()>;
|
||||
|
||||
@@ -196,6 +200,10 @@ pub enum CommitRequest {
|
||||
leaked_cids: Vec<(CidBytes, RefCount)>,
|
||||
response: RepairResponse,
|
||||
},
|
||||
RepairBlocks {
|
||||
blocks: RepairBlockSet,
|
||||
response: RepairBlocksResponse,
|
||||
},
|
||||
Quiesce {
|
||||
response: QuiesceResponse,
|
||||
resume: QuiesceResume,
|
||||
@@ -210,6 +218,7 @@ pub struct GroupCommitConfig {
|
||||
pub checkpoint_interval_ms: u64,
|
||||
pub checkpoint_write_threshold: u64,
|
||||
pub verify_persisted_blocks: bool,
|
||||
pub synchronous: bool,
|
||||
}
|
||||
|
||||
impl Default for GroupCommitConfig {
|
||||
@@ -220,17 +229,19 @@ impl Default for GroupCommitConfig {
|
||||
checkpoint_interval_ms: 60_000,
|
||||
checkpoint_write_threshold: 100_000,
|
||||
verify_persisted_blocks: false,
|
||||
synchronous: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShardContext {
|
||||
struct ShardContext<C: Clock> {
|
||||
shard_id: ShardId,
|
||||
epoch: EpochCounter,
|
||||
file_ids: Arc<FileIdAllocator>,
|
||||
active_files: Arc<ActiveFileSet>,
|
||||
hint_positions: Arc<ShardHintPositions>,
|
||||
verify_persisted_blocks: bool,
|
||||
clock: C,
|
||||
}
|
||||
|
||||
struct ActiveState {
|
||||
@@ -250,14 +261,55 @@ fn log_thread_panic(payload: Box<dyn std::any::Any + Send>, context: &str) {
|
||||
tracing::error!(panic = msg, "{context}");
|
||||
}
|
||||
|
||||
struct SingleShardWriter {
|
||||
sender: flume::Sender<CommitRequest>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
trait InlineHandler: Send {
|
||||
fn handle(&mut self, req: CommitRequest);
|
||||
fn finalize(&mut self);
|
||||
}
|
||||
|
||||
struct InlineState<S: StorageIO + 'static, C: Clock> {
|
||||
manager: DataFileManager<S>,
|
||||
index: Arc<BlockIndex>,
|
||||
config: GroupCommitConfig,
|
||||
state: ActiveState,
|
||||
ctx: ShardContext<C>,
|
||||
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
|
||||
last_checkpoint: LogicalNanos,
|
||||
writes_since_checkpoint: u64,
|
||||
}
|
||||
|
||||
impl<S: StorageIO + 'static, C: Clock> InlineHandler for InlineState<S, C> {
|
||||
fn handle(&mut self, req: CommitRequest) {
|
||||
handle_request(
|
||||
req,
|
||||
&self.manager,
|
||||
&self.index,
|
||||
&self.config,
|
||||
&mut self.state,
|
||||
self.post_sync_hook.as_deref(),
|
||||
&self.ctx,
|
||||
&mut self.last_checkpoint,
|
||||
&mut self.writes_since_checkpoint,
|
||||
);
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
shutdown_checkpoint(&self.index, &self.ctx.epoch, &self.ctx.hint_positions);
|
||||
}
|
||||
}
|
||||
|
||||
enum SingleShardWriter {
|
||||
Threaded {
|
||||
sender: flume::Sender<CommitRequest>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
},
|
||||
Inline {
|
||||
handler: Mutex<Box<dyn InlineHandler>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SingleShardWriter {
|
||||
fn spawn<S: StorageIO + 'static>(
|
||||
ctx: ShardContext,
|
||||
fn spawn<S: StorageIO + 'static, C: Clock>(
|
||||
ctx: ShardContext<C>,
|
||||
manager: DataFileManager<S>,
|
||||
index: Arc<BlockIndex>,
|
||||
config: GroupCommitConfig,
|
||||
@@ -269,6 +321,23 @@ impl SingleShardWriter {
|
||||
ctx.hint_positions
|
||||
.update(ctx.shard_id, state.file_id, state.hint_position);
|
||||
|
||||
if config.synchronous {
|
||||
let last_checkpoint = ctx.clock.monotonic();
|
||||
let inline = InlineState {
|
||||
manager,
|
||||
index,
|
||||
config,
|
||||
state,
|
||||
ctx,
|
||||
post_sync_hook,
|
||||
last_checkpoint,
|
||||
writes_since_checkpoint: 0,
|
||||
};
|
||||
return Ok(Self::Inline {
|
||||
handler: Mutex::new(Box::new(inline)),
|
||||
});
|
||||
}
|
||||
|
||||
let (sender, receiver) = flume::bounded(config.channel_capacity);
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
@@ -286,29 +355,48 @@ impl SingleShardWriter {
|
||||
})
|
||||
.map_err(|e| CommitError::from(io::Error::other(e)))?;
|
||||
|
||||
Ok(Self {
|
||||
Ok(Self::Threaded {
|
||||
sender,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn submit(&self, request: CommitRequest) -> Result<(), CommitError> {
|
||||
match self {
|
||||
Self::Threaded { sender, .. } => {
|
||||
sender.send(request).map_err(|_| CommitError::ChannelClosed)
|
||||
}
|
||||
Self::Inline { handler } => {
|
||||
handler.lock().handle(request);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) {
|
||||
let _ = self.sender.send(CommitRequest::Shutdown);
|
||||
if let Some(handle) = self.handle.take()
|
||||
&& let Err(payload) = handle.join()
|
||||
{
|
||||
log_thread_panic(payload, "group commit thread panicked");
|
||||
match self {
|
||||
Self::Threaded { sender, handle } => {
|
||||
let _ = sender.send(CommitRequest::Shutdown);
|
||||
if let Some(handle) = handle.take()
|
||||
&& let Err(payload) = handle.join()
|
||||
{
|
||||
log_thread_panic(payload, "group commit thread panicked");
|
||||
}
|
||||
}
|
||||
Self::Inline { handler } => handler.lock().finalize(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SingleShardWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.sender.try_send(CommitRequest::Shutdown);
|
||||
if let Some(handle) = self.handle.take()
|
||||
&& let Err(payload) = handle.join()
|
||||
{
|
||||
log_thread_panic(payload, "group commit thread panicked during drop");
|
||||
if let Self::Threaded { sender, handle } = self {
|
||||
let _ = sender.try_send(CommitRequest::Shutdown);
|
||||
if let Some(handle) = handle.take()
|
||||
&& let Err(payload) = handle.join()
|
||||
{
|
||||
log_thread_panic(payload, "group commit thread panicked during drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,20 +447,22 @@ pub struct GroupCommitWriter {
|
||||
}
|
||||
|
||||
impl GroupCommitWriter {
|
||||
pub fn spawn<S: StorageIO + 'static>(
|
||||
pub fn spawn<S: StorageIO + 'static, C: Clock>(
|
||||
make_manager: impl Fn() -> DataFileManager<S>,
|
||||
index: Arc<BlockIndex>,
|
||||
config: GroupCommitConfig,
|
||||
clock: C,
|
||||
) -> Result<Self, CommitError> {
|
||||
Self::spawn_sharded(make_manager, index, config, None, None, 1, None)
|
||||
Self::spawn_sharded(make_manager, index, config, None, None, 1, None, clock)
|
||||
}
|
||||
|
||||
pub fn spawn_with_hook<S: StorageIO + 'static>(
|
||||
pub fn spawn_with_hook<S: StorageIO + 'static, C: Clock>(
|
||||
make_manager: impl Fn() -> DataFileManager<S>,
|
||||
index: Arc<BlockIndex>,
|
||||
config: GroupCommitConfig,
|
||||
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
|
||||
initial_epoch: Option<CommitEpoch>,
|
||||
clock: C,
|
||||
) -> Result<Self, CommitError> {
|
||||
Self::spawn_sharded(
|
||||
make_manager,
|
||||
@@ -382,10 +472,12 @@ impl GroupCommitWriter {
|
||||
initial_epoch,
|
||||
1,
|
||||
None,
|
||||
clock,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn spawn_sharded<F, S>(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_sharded<F, S, C>(
|
||||
make_manager: F,
|
||||
index: Arc<BlockIndex>,
|
||||
config: GroupCommitConfig,
|
||||
@@ -393,9 +485,11 @@ impl GroupCommitWriter {
|
||||
initial_epoch: Option<CommitEpoch>,
|
||||
shard_count: u8,
|
||||
checkpoint_positions: Option<&CheckpointPositions>,
|
||||
clock: C,
|
||||
) -> Result<Self, CommitError>
|
||||
where
|
||||
S: StorageIO + 'static,
|
||||
C: Clock,
|
||||
F: Fn() -> DataFileManager<S>,
|
||||
{
|
||||
let shard_count = shard_count.max(1);
|
||||
@@ -435,6 +529,7 @@ impl GroupCommitWriter {
|
||||
active_files: Arc::clone(&active_files),
|
||||
hint_positions: Arc::clone(&hint_positions),
|
||||
verify_persisted_blocks: config.verify_persisted_blocks,
|
||||
clock: clock.clone(),
|
||||
};
|
||||
SingleShardWriter::spawn(
|
||||
ctx,
|
||||
@@ -462,19 +557,49 @@ impl GroupCommitWriter {
|
||||
&self.epoch
|
||||
}
|
||||
|
||||
pub fn sender_round_robin(&self) -> &flume::Sender<CommitRequest> {
|
||||
let idx = self
|
||||
.round_robin
|
||||
fn round_robin_index(&self) -> usize {
|
||||
self.round_robin
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.wrapping_rem(self.shard_count) as usize;
|
||||
&self.shards[idx].sender
|
||||
.wrapping_rem(self.shard_count) as usize
|
||||
}
|
||||
|
||||
fn shard_index_for(&self, request: &CommitRequest) -> usize {
|
||||
match request {
|
||||
CommitRequest::PutBlocks { blocks, .. } => {
|
||||
pick_shard_for_blocks(blocks, self.shard_count)
|
||||
}
|
||||
CommitRequest::ApplyCommit {
|
||||
blocks,
|
||||
deleted_cids,
|
||||
..
|
||||
} => pick_shard_for_apply(blocks, deleted_cids, self.shard_count),
|
||||
CommitRequest::Compact { .. }
|
||||
| CommitRequest::RepairLeaked { .. }
|
||||
| CommitRequest::RepairBlocks { .. }
|
||||
| CommitRequest::Quiesce { .. }
|
||||
| CommitRequest::Shutdown => self.round_robin_index(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_blocking(&self, request: CommitRequest) -> Result<(), CommitError> {
|
||||
let idx = self.shard_index_for(&request);
|
||||
self.shards[idx].submit(request)
|
||||
}
|
||||
|
||||
fn threaded_sender(shard: &SingleShardWriter) -> &flume::Sender<CommitRequest> {
|
||||
match shard {
|
||||
SingleShardWriter::Threaded { sender, .. } => sender,
|
||||
SingleShardWriter::Inline { .. } => unreachable!(
|
||||
"async commit path requires threaded group-commit; synchronous mode is gauntlet-blocking-only"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sender_for_blocks(
|
||||
&self,
|
||||
blocks: &[([u8; CID_SIZE], Vec<u8>)],
|
||||
) -> &flume::Sender<CommitRequest> {
|
||||
&self.shards[pick_shard_for_blocks(blocks, self.shard_count)].sender
|
||||
Self::threaded_sender(&self.shards[pick_shard_for_blocks(blocks, self.shard_count)])
|
||||
}
|
||||
|
||||
pub fn sender_for_apply(
|
||||
@@ -482,7 +607,9 @@ impl GroupCommitWriter {
|
||||
blocks: &[([u8; CID_SIZE], Vec<u8>)],
|
||||
deleted_cids: &[[u8; CID_SIZE]],
|
||||
) -> &flume::Sender<CommitRequest> {
|
||||
&self.shards[pick_shard_for_apply(blocks, deleted_cids, self.shard_count)].sender
|
||||
Self::threaded_sender(
|
||||
&self.shards[pick_shard_for_apply(blocks, deleted_cids, self.shard_count)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn quiesce_all(
|
||||
@@ -494,13 +621,18 @@ impl GroupCommitWriter {
|
||||
.map(|shard| {
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
|
||||
shard
|
||||
.sender
|
||||
.send(CommitRequest::Quiesce {
|
||||
response: response_tx,
|
||||
resume: resume_rx,
|
||||
})
|
||||
.map_err(|_| CommitError::ChannelClosed)?;
|
||||
let req = CommitRequest::Quiesce {
|
||||
response: response_tx,
|
||||
resume: resume_rx,
|
||||
};
|
||||
match shard {
|
||||
SingleShardWriter::Threaded { sender, .. } => {
|
||||
sender.send(req).map_err(|_| CommitError::ChannelClosed)?;
|
||||
}
|
||||
SingleShardWriter::Inline { handler } => {
|
||||
handler.lock().handle(req);
|
||||
}
|
||||
}
|
||||
Ok((response_rx, resume_tx))
|
||||
})
|
||||
.collect();
|
||||
@@ -551,10 +683,13 @@ impl GroupCommitWriter {
|
||||
impl Drop for GroupCommitWriter {
|
||||
fn drop(&mut self) {
|
||||
self.shards.iter_mut().for_each(|s| {
|
||||
let _ = s.sender.try_send(CommitRequest::Shutdown);
|
||||
if let SingleShardWriter::Threaded { sender, .. } = s {
|
||||
let _ = sender.try_send(CommitRequest::Shutdown);
|
||||
}
|
||||
});
|
||||
self.shards.iter_mut().for_each(|s| {
|
||||
if let Some(handle) = s.handle.take()
|
||||
if let SingleShardWriter::Threaded { handle, .. } = s
|
||||
&& let Some(handle) = handle.take()
|
||||
&& let Err(payload) = handle.join()
|
||||
{
|
||||
log_thread_panic(payload, "group commit thread panicked during drop");
|
||||
@@ -596,13 +731,18 @@ fn initialize_active_state<S: StorageIO>(
|
||||
let hint_path = hint_file_path(data_dir, wc.file_id);
|
||||
let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?;
|
||||
let hint_size = manager.io().file_size(hint_fd)?;
|
||||
let aligned_hint = hint_size - hint_size % HINT_RECORD_SIZE as u64;
|
||||
if aligned_hint != hint_size {
|
||||
manager.io().truncate(hint_fd, aligned_hint)?;
|
||||
manager.io().sync(hint_fd)?;
|
||||
}
|
||||
|
||||
Ok(ActiveState {
|
||||
file_id: wc.file_id,
|
||||
fd,
|
||||
position,
|
||||
hint_fd,
|
||||
hint_position: HintOffset::new(hint_size),
|
||||
hint_position: HintOffset::new(aligned_hint),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
@@ -654,6 +794,10 @@ enum ClassifyResult {
|
||||
leaked_cids: Vec<(CidBytes, RefCount)>,
|
||||
response: RepairResponse,
|
||||
},
|
||||
RepairBlocks {
|
||||
blocks: RepairBlockSet,
|
||||
response: RepairBlocksResponse,
|
||||
},
|
||||
Quiesce {
|
||||
response: QuiesceResponse,
|
||||
resume: QuiesceResume,
|
||||
@@ -690,6 +834,9 @@ fn classify_request(req: CommitRequest) -> ClassifyResult {
|
||||
leaked_cids,
|
||||
response,
|
||||
},
|
||||
CommitRequest::RepairBlocks { blocks, response } => {
|
||||
ClassifyResult::RepairBlocks { blocks, response }
|
||||
}
|
||||
CommitRequest::Quiesce { response, resume } => ClassifyResult::Quiesce { response, resume },
|
||||
CommitRequest::Shutdown => ClassifyResult::Shutdown,
|
||||
}
|
||||
@@ -706,6 +853,7 @@ struct DrainResult {
|
||||
shutdown: bool,
|
||||
deferred_compacts: Vec<(DataFileId, u64, CompactResponse)>,
|
||||
deferred_repairs: Vec<(Vec<(CidBytes, RefCount)>, RepairResponse)>,
|
||||
deferred_repair_blocks: Vec<DeferredRepairBlock>,
|
||||
deferred_quiesces: Vec<(QuiesceResponse, QuiesceResume)>,
|
||||
}
|
||||
|
||||
@@ -721,6 +869,7 @@ fn drain_batch(
|
||||
shutdown: true,
|
||||
deferred_compacts: Vec::new(),
|
||||
deferred_repairs: Vec::new(),
|
||||
deferred_repair_blocks: Vec::new(),
|
||||
deferred_quiesces: Vec::new(),
|
||||
};
|
||||
}
|
||||
@@ -734,6 +883,7 @@ fn drain_batch(
|
||||
shutdown: false,
|
||||
deferred_compacts: vec![(file_id, grace_period_ms, response)],
|
||||
deferred_repairs: Vec::new(),
|
||||
deferred_repair_blocks: Vec::new(),
|
||||
deferred_quiesces: Vec::new(),
|
||||
};
|
||||
}
|
||||
@@ -746,6 +896,17 @@ fn drain_batch(
|
||||
shutdown: false,
|
||||
deferred_compacts: Vec::new(),
|
||||
deferred_repairs: vec![(leaked_cids, response)],
|
||||
deferred_repair_blocks: Vec::new(),
|
||||
deferred_quiesces: Vec::new(),
|
||||
};
|
||||
}
|
||||
ClassifyResult::RepairBlocks { blocks, response } => {
|
||||
return DrainResult {
|
||||
entries: Vec::new(),
|
||||
shutdown: false,
|
||||
deferred_compacts: Vec::new(),
|
||||
deferred_repairs: Vec::new(),
|
||||
deferred_repair_blocks: vec![(blocks, response)],
|
||||
deferred_quiesces: Vec::new(),
|
||||
};
|
||||
}
|
||||
@@ -755,6 +916,7 @@ fn drain_batch(
|
||||
shutdown: false,
|
||||
deferred_compacts: Vec::new(),
|
||||
deferred_repairs: Vec::new(),
|
||||
deferred_repair_blocks: Vec::new(),
|
||||
deferred_quiesces: vec![(response, resume)],
|
||||
};
|
||||
}
|
||||
@@ -767,6 +929,7 @@ fn drain_batch(
|
||||
shutdown: false,
|
||||
deferred_compacts: Vec::new(),
|
||||
deferred_repairs: Vec::new(),
|
||||
deferred_repair_blocks: Vec::new(),
|
||||
deferred_quiesces: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -789,6 +952,10 @@ fn drain_batch(
|
||||
r.deferred_repairs.push((leaked_cids, response));
|
||||
false
|
||||
}
|
||||
ClassifyResult::RepairBlocks { blocks, response } => {
|
||||
r.deferred_repair_blocks.push((blocks, response));
|
||||
false
|
||||
}
|
||||
ClassifyResult::Quiesce { response, resume } => {
|
||||
r.deferred_quiesces.push((response, resume));
|
||||
false
|
||||
@@ -843,23 +1010,24 @@ fn handle_quiesce<S: StorageIO>(
|
||||
let _ = resume.blocking_recv();
|
||||
}
|
||||
|
||||
fn maybe_checkpoint(
|
||||
fn maybe_checkpoint<C: Clock>(
|
||||
index: &BlockIndex,
|
||||
epoch: &EpochCounter,
|
||||
config: &GroupCommitConfig,
|
||||
last_checkpoint: &mut std::time::Instant,
|
||||
clock: &C,
|
||||
last_checkpoint: &mut LogicalNanos,
|
||||
writes_since_checkpoint: &mut u64,
|
||||
hint_positions: &ShardHintPositions,
|
||||
) {
|
||||
let interval = std::time::Duration::from_millis(config.checkpoint_interval_ms);
|
||||
let elapsed = last_checkpoint.elapsed() >= interval;
|
||||
let interval = LogicalNanos::from_millis(config.checkpoint_interval_ms);
|
||||
let elapsed = clock.monotonic().saturating_sub(*last_checkpoint) >= interval;
|
||||
let threshold = *writes_since_checkpoint >= config.checkpoint_write_threshold;
|
||||
if !elapsed && !threshold {
|
||||
return;
|
||||
}
|
||||
match index.write_checkpoint(epoch.current(), hint_positions) {
|
||||
Ok(()) => {
|
||||
*last_checkpoint = std::time::Instant::now();
|
||||
*last_checkpoint = clock.monotonic();
|
||||
*writes_since_checkpoint = 0;
|
||||
tracing::debug!("periodic checkpoint written");
|
||||
}
|
||||
@@ -880,17 +1048,17 @@ fn shutdown_checkpoint(
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_loop<S: StorageIO>(
|
||||
fn commit_loop<S: StorageIO, C: Clock>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
receiver: &flume::Receiver<CommitRequest>,
|
||||
config: &GroupCommitConfig,
|
||||
state: &mut ActiveState,
|
||||
post_sync_hook: Option<&dyn PostBlockstoreHook>,
|
||||
ctx: &ShardContext,
|
||||
ctx: &ShardContext<C>,
|
||||
) {
|
||||
let epoch = &ctx.epoch;
|
||||
let mut last_checkpoint = std::time::Instant::now();
|
||||
let mut last_checkpoint = ctx.clock.monotonic();
|
||||
let mut writes_since_checkpoint: u64 = 0;
|
||||
|
||||
loop {
|
||||
@@ -914,6 +1082,7 @@ fn commit_loop<S: StorageIO>(
|
||||
&ctx.active_files,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(result);
|
||||
continue;
|
||||
@@ -925,11 +1094,24 @@ fn commit_loop<S: StorageIO>(
|
||||
let repaired = index.repair_leaked_refcounts(
|
||||
&leaked_cids,
|
||||
epoch.current(),
|
||||
crate::wall_clock_ms(),
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(Ok(repaired));
|
||||
continue;
|
||||
}
|
||||
Ok(CommitRequest::RepairBlocks { blocks, response }) => {
|
||||
let result = compaction::repair_blocks_on_writer_thread(
|
||||
manager,
|
||||
index,
|
||||
&blocks,
|
||||
epoch.current(),
|
||||
&ctx.file_ids,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
);
|
||||
let _ = response.send(result);
|
||||
continue;
|
||||
}
|
||||
Ok(CommitRequest::Quiesce { response, resume }) => {
|
||||
handle_quiesce(manager, state, epoch, response, resume);
|
||||
continue;
|
||||
@@ -985,6 +1167,7 @@ fn commit_loop<S: StorageIO>(
|
||||
&ctx.active_files,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(result);
|
||||
});
|
||||
@@ -996,15 +1179,32 @@ fn commit_loop<S: StorageIO>(
|
||||
let repaired = index.repair_leaked_refcounts(
|
||||
&leaked_cids,
|
||||
epoch.current(),
|
||||
crate::wall_clock_ms(),
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(Ok(repaired));
|
||||
});
|
||||
|
||||
drain
|
||||
.deferred_repair_blocks
|
||||
.into_iter()
|
||||
.for_each(|(blocks, response)| {
|
||||
let result = compaction::repair_blocks_on_writer_thread(
|
||||
manager,
|
||||
index,
|
||||
&blocks,
|
||||
epoch.current(),
|
||||
&ctx.file_ids,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
);
|
||||
let _ = response.send(result);
|
||||
});
|
||||
|
||||
maybe_checkpoint(
|
||||
index,
|
||||
epoch,
|
||||
config,
|
||||
&ctx.clock,
|
||||
&mut last_checkpoint,
|
||||
&mut writes_since_checkpoint,
|
||||
&ctx.hint_positions,
|
||||
@@ -1024,6 +1224,95 @@ fn commit_loop<S: StorageIO>(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_request<S: StorageIO, C: Clock>(
|
||||
req: CommitRequest,
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
config: &GroupCommitConfig,
|
||||
state: &mut ActiveState,
|
||||
post_sync_hook: Option<&dyn PostBlockstoreHook>,
|
||||
ctx: &ShardContext<C>,
|
||||
last_checkpoint: &mut LogicalNanos,
|
||||
writes_since_checkpoint: &mut u64,
|
||||
) {
|
||||
let epoch = &ctx.epoch;
|
||||
match classify_request(req) {
|
||||
ClassifyResult::Shutdown => {}
|
||||
ClassifyResult::Compact {
|
||||
file_id,
|
||||
grace_period_ms,
|
||||
response,
|
||||
} => {
|
||||
let result = compaction::compact_on_writer_thread(
|
||||
manager,
|
||||
index,
|
||||
file_id,
|
||||
epoch.current(),
|
||||
grace_period_ms,
|
||||
&ctx.file_ids,
|
||||
&ctx.active_files,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(result);
|
||||
}
|
||||
ClassifyResult::Repair {
|
||||
leaked_cids,
|
||||
response,
|
||||
} => {
|
||||
let repaired = index.repair_leaked_refcounts(
|
||||
&leaked_cids,
|
||||
epoch.current(),
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(Ok(repaired));
|
||||
}
|
||||
ClassifyResult::RepairBlocks { blocks, response } => {
|
||||
let result = compaction::repair_blocks_on_writer_thread(
|
||||
manager,
|
||||
index,
|
||||
&blocks,
|
||||
epoch.current(),
|
||||
&ctx.file_ids,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
);
|
||||
let _ = response.send(result);
|
||||
}
|
||||
ClassifyResult::Quiesce { response, resume } => {
|
||||
let snapshot = capture_snapshot(manager, state, epoch);
|
||||
let _ = response.send(snapshot);
|
||||
drop(resume);
|
||||
}
|
||||
ClassifyResult::Batch(entry) => {
|
||||
let entries = vec![entry];
|
||||
let result = process_batch(manager, index, &entries, state, ctx);
|
||||
if let Ok((ref _dedup, ref proof)) = result {
|
||||
run_post_sync_hook(post_sync_hook, proof);
|
||||
}
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!(error = %e, "commit batch failed");
|
||||
}
|
||||
if let Ok((ref dedup, _)) = result {
|
||||
*writes_since_checkpoint =
|
||||
writes_since_checkpoint.saturating_add(dedup.len() as u64);
|
||||
}
|
||||
dispatch_responses(entries, result.map(|(dedup, _proof)| dedup));
|
||||
maybe_checkpoint(
|
||||
index,
|
||||
epoch,
|
||||
config,
|
||||
&ctx.clock,
|
||||
last_checkpoint,
|
||||
writes_since_checkpoint,
|
||||
&ctx.hint_positions,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_post_sync_hook(hook: Option<&dyn PostBlockstoreHook>, proof: &BlocksSynced) {
|
||||
if let Some(hook) = hook
|
||||
&& let Err(e) = hook.on_blocks_synced(proof)
|
||||
@@ -1032,18 +1321,19 @@ fn run_post_sync_hook(hook: Option<&dyn PostBlockstoreHook>, proof: &BlocksSynce
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_and_process_remaining<S: StorageIO>(
|
||||
fn drain_and_process_remaining<S: StorageIO, C: Clock>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
receiver: &flume::Receiver<CommitRequest>,
|
||||
state: &mut ActiveState,
|
||||
post_sync_hook: Option<&dyn PostBlockstoreHook>,
|
||||
ctx: &ShardContext,
|
||||
ctx: &ShardContext<C>,
|
||||
) {
|
||||
let epoch = &ctx.epoch;
|
||||
let mut entries: Vec<BatchEntry> = Vec::new();
|
||||
let mut compacts: Vec<(DataFileId, u64, CompactResponse)> = Vec::new();
|
||||
let mut repairs: Vec<(Vec<(CidBytes, RefCount)>, RepairResponse)> = Vec::new();
|
||||
let mut repair_blocks: Vec<DeferredRepairBlock> = Vec::new();
|
||||
|
||||
std::iter::from_fn(|| receiver.try_recv().ok()).for_each(|req| match classify_request(req) {
|
||||
ClassifyResult::Batch(entry) => entries.push(entry),
|
||||
@@ -1056,6 +1346,7 @@ fn drain_and_process_remaining<S: StorageIO>(
|
||||
leaked_cids,
|
||||
response,
|
||||
} => repairs.push((leaked_cids, response)),
|
||||
ClassifyResult::RepairBlocks { blocks, response } => repair_blocks.push((blocks, response)),
|
||||
ClassifyResult::Shutdown | ClassifyResult::Quiesce { .. } => {}
|
||||
});
|
||||
|
||||
@@ -1082,16 +1373,30 @@ fn drain_and_process_remaining<S: StorageIO>(
|
||||
&ctx.active_files,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
ctx.clock.wall_millis(),
|
||||
);
|
||||
let _ = response.send(result);
|
||||
});
|
||||
|
||||
repairs.into_iter().for_each(|(leaked_cids, response)| {
|
||||
let repaired =
|
||||
index.repair_leaked_refcounts(&leaked_cids, epoch.current(), crate::wall_clock_ms());
|
||||
index.repair_leaked_refcounts(&leaked_cids, epoch.current(), ctx.clock.wall_millis());
|
||||
let _ = response.send(Ok(repaired));
|
||||
});
|
||||
|
||||
repair_blocks.into_iter().for_each(|(blocks, response)| {
|
||||
let result = compaction::repair_blocks_on_writer_thread(
|
||||
manager,
|
||||
index,
|
||||
&blocks,
|
||||
epoch.current(),
|
||||
&ctx.file_ids,
|
||||
&ctx.hint_positions,
|
||||
epoch,
|
||||
);
|
||||
let _ = response.send(result);
|
||||
});
|
||||
|
||||
shutdown_checkpoint(index, epoch, &ctx.hint_positions);
|
||||
}
|
||||
|
||||
@@ -1114,17 +1419,32 @@ fn verify_persisted_blocks<S: StorageIO>(
|
||||
|
||||
by_file.into_iter().try_for_each(|(file_id, locations)| {
|
||||
let path = manager.data_file_path(file_id);
|
||||
let fd = match manager.io().open(&path, OpenOptions::read_only_existing()) {
|
||||
Ok(fd) => fd,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let file_size = match manager.io().file_size(fd) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = manager.io().close(fd);
|
||||
return Ok(());
|
||||
let probe_offset = locations[0].1.offset;
|
||||
let fd = match (0..VERIFY_RETRY_ATTEMPTS).find_map(|_| {
|
||||
manager
|
||||
.io()
|
||||
.open(&path, OpenOptions::read_only_existing())
|
||||
.ok()
|
||||
}) {
|
||||
Some(fd) => fd,
|
||||
None => {
|
||||
return Err(CommitError::VerifyFailed {
|
||||
file_id,
|
||||
offset: probe_offset,
|
||||
});
|
||||
}
|
||||
};
|
||||
let file_size =
|
||||
match (0..VERIFY_RETRY_ATTEMPTS).find_map(|_| manager.io().file_size(fd).ok()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let _ = manager.io().close(fd);
|
||||
return Err(CommitError::VerifyFailed {
|
||||
file_id,
|
||||
offset: probe_offset,
|
||||
});
|
||||
}
|
||||
};
|
||||
let result = locations.into_iter().try_for_each(|(expected_cid, loc)| {
|
||||
verify_block_at(manager, fd, file_size, expected_cid, loc)
|
||||
});
|
||||
@@ -1137,6 +1457,7 @@ fn verify_persisted_blocks<S: StorageIO>(
|
||||
enum VerifyOutcome {
|
||||
NoFaultDetected,
|
||||
Faulted,
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
fn verify_block_at<S: StorageIO>(
|
||||
@@ -1146,15 +1467,16 @@ fn verify_block_at<S: StorageIO>(
|
||||
expected_cid: &[u8; CID_SIZE],
|
||||
loc: BlockLocation,
|
||||
) -> Result<(), CommitError> {
|
||||
let passed = (0..VERIFY_RETRY_ATTEMPTS).any(|_| {
|
||||
matches!(
|
||||
verify_once(manager, fd, file_size, expected_cid, loc),
|
||||
VerifyOutcome::NoFaultDetected
|
||||
)
|
||||
let verdict = (0..VERIFY_RETRY_ATTEMPTS).find_map(|_| {
|
||||
match verify_once(manager, fd, file_size, expected_cid, loc) {
|
||||
VerifyOutcome::NoFaultDetected => Some(true),
|
||||
VerifyOutcome::Faulted => Some(false),
|
||||
VerifyOutcome::Inconclusive => None,
|
||||
}
|
||||
});
|
||||
match passed {
|
||||
true => Ok(()),
|
||||
false => Err(CommitError::VerifyFailed {
|
||||
match verdict {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) | None => Err(CommitError::VerifyFailed {
|
||||
file_id: loc.file_id,
|
||||
offset: loc.offset,
|
||||
}),
|
||||
@@ -1189,23 +1511,33 @@ fn verify_once<S: StorageIO>(
|
||||
);
|
||||
VerifyOutcome::Faulted
|
||||
}
|
||||
Err(_) => VerifyOutcome::NoFaultDetected,
|
||||
Err(_) => VerifyOutcome::Inconclusive,
|
||||
}
|
||||
}
|
||||
|
||||
const VERIFY_RETRY_ATTEMPTS: u32 = 4;
|
||||
|
||||
const ROLLBACK_TRUNCATE_ATTEMPTS: u32 = 8;
|
||||
|
||||
fn truncate_and_sync_durably<S: StorageIO>(io: &S, fd: FileId, offset: u64) {
|
||||
let ok = (0..ROLLBACK_TRUNCATE_ATTEMPTS)
|
||||
.any(|_| io.truncate(fd, offset).and_then(|()| io.sync(fd)).is_ok());
|
||||
if !ok {
|
||||
tracing::error!(
|
||||
offset,
|
||||
"rollback could not durably truncate uncommitted tail; recovery may resurrect it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_batch<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
state: &ActiveState,
|
||||
rotations: &[RotationState<S>],
|
||||
) {
|
||||
let _ = manager.io().truncate(state.fd, state.position.raw());
|
||||
let _ = manager.io().sync(state.fd);
|
||||
let _ = manager
|
||||
.io()
|
||||
.truncate(state.hint_fd, state.hint_position.raw());
|
||||
let _ = manager.io().sync(state.hint_fd);
|
||||
truncate_and_sync_durably(manager.io(), state.fd, state.position.raw());
|
||||
truncate_and_sync_durably(manager.io(), state.hint_fd, state.hint_position.raw());
|
||||
let _ = manager.io().barrier();
|
||||
rotations.iter().for_each(|rot| {
|
||||
manager.rollback_rotation(rot.file_id);
|
||||
let _ = manager.io().close(rot.hint_fd);
|
||||
@@ -1215,12 +1547,12 @@ fn rollback_batch<S: StorageIO>(
|
||||
});
|
||||
}
|
||||
|
||||
fn process_batch<S: StorageIO>(
|
||||
fn process_batch<S: StorageIO, C: Clock>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
batch: &[BatchEntry],
|
||||
state: &mut ActiveState,
|
||||
ctx: &ShardContext,
|
||||
ctx: &ShardContext<C>,
|
||||
) -> Result<(HashMap<[u8; CID_SIZE], BlockLocation>, BlocksSynced), CommitError> {
|
||||
let epoch = &ctx.epoch;
|
||||
let batch_start = std::time::Instant::now();
|
||||
@@ -1330,7 +1662,7 @@ fn process_batch<S: StorageIO>(
|
||||
let write_nanos = batch_start.elapsed().as_nanos() as u64;
|
||||
|
||||
let current_epoch = epoch.current();
|
||||
let now = crate::wall_clock_ms();
|
||||
let now = ctx.clock.wall_millis();
|
||||
|
||||
let rollback_on_err = |e: CommitError| -> CommitError {
|
||||
rollback_batch(manager, state, &rotations);
|
||||
|
||||
@@ -707,6 +707,22 @@ impl HashTable {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn indexed_file_ends(&self) -> HashMap<DataFileId, BlockOffset> {
|
||||
self.iter().fold(HashMap::new(), |mut ends, s| {
|
||||
let end = s
|
||||
.offset
|
||||
.advance(super::data_file::BLOCK_RECORD_OVERHEAD as u64 + s.length.as_u64());
|
||||
ends.entry(s.file_id)
|
||||
.and_modify(|cur| {
|
||||
if end > *cur {
|
||||
*cur = end;
|
||||
}
|
||||
})
|
||||
.or_insert(end);
|
||||
ends
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_leaked_refcounts(
|
||||
&self,
|
||||
is_reachable: impl Fn(&CidBytes) -> bool,
|
||||
@@ -1504,6 +1520,10 @@ impl BlockIndex {
|
||||
.liveness_by_file(current_epoch, now, grace_period_ms)
|
||||
}
|
||||
|
||||
pub fn indexed_file_ends(&self) -> HashMap<DataFileId, BlockOffset> {
|
||||
self.table.read().indexed_file_ends()
|
||||
}
|
||||
|
||||
pub fn find_leaked_refcounts(
|
||||
&self,
|
||||
is_reachable: impl Fn(&CidBytes) -> bool,
|
||||
|
||||
@@ -469,12 +469,16 @@ impl<'a, S: StorageIO> HintFileReader<'a, S> {
|
||||
}
|
||||
|
||||
pub fn resume(io: &'a S, fd: FileId, position: HintOffset) -> io::Result<Self> {
|
||||
assert!(
|
||||
position.raw().is_multiple_of(HINT_RECORD_SIZE as u64),
|
||||
"hint resume position {} not aligned to HINT_RECORD_SIZE {}",
|
||||
position.raw(),
|
||||
HINT_RECORD_SIZE,
|
||||
);
|
||||
if !position.raw().is_multiple_of(HINT_RECORD_SIZE as u64) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"hint resume position {} not aligned to HINT_RECORD_SIZE {}",
|
||||
position.raw(),
|
||||
HINT_RECORD_SIZE,
|
||||
),
|
||||
));
|
||||
}
|
||||
let file_size = io.file_size(fd)?;
|
||||
Ok(Self {
|
||||
io,
|
||||
@@ -1200,6 +1204,15 @@ mod tests {
|
||||
assert_eq!(valid_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_reader_resume_rejects_unaligned_position_without_panicking() {
|
||||
let (sim, fd) = setup();
|
||||
match HintFileReader::resume(&sim, fd, HintOffset::new(HINT_RECORD_SIZE as u64 + 5)) {
|
||||
Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData),
|
||||
Ok(_) => panic!("non-aligned resume position must surface as a recoverable error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_reader_empty_file() {
|
||||
let (sim, fd) = setup();
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod hash_index;
|
||||
mod hint;
|
||||
mod manager;
|
||||
mod reader;
|
||||
mod repair;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
@@ -25,7 +26,8 @@ pub use hint::{
|
||||
ReadHintRecord, RebuildError, decode_hint_record, hint_file_path, scan_hints_to_memory,
|
||||
};
|
||||
pub use manager::{CachedHandle, DEFAULT_MAX_FILE_SIZE, DataFileManager};
|
||||
pub use reader::{BlockStoreReader, ReadError};
|
||||
pub use reader::{BLOCK_CORRUPTION_MARKER, BlockStoreReader, ReadError};
|
||||
pub use repair::{RepairOutcome, rebuild_and_repair_mst, rebuild_mst_nodes};
|
||||
pub use store::QuiesceGuard;
|
||||
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, OpenRetryPolicy, TranquilBlockStore};
|
||||
pub use types::{
|
||||
|
||||
@@ -11,6 +11,8 @@ use super::hash_index::BlockIndex;
|
||||
use super::manager::DataFileManager;
|
||||
use super::types::{BlockLocation, BlockOffset, DataFileId};
|
||||
|
||||
pub const BLOCK_CORRUPTION_MARKER: &str = "corrupted block at";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ReadError {
|
||||
Io(Arc<io::Error>),
|
||||
@@ -25,7 +27,7 @@ impl std::fmt::Display for ReadError {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "io: {e}"),
|
||||
Self::Corrupted { file_id, offset } => {
|
||||
write!(f, "corrupted block at {file_id}:{}", offset.raw())
|
||||
write!(f, "{BLOCK_CORRUPTION_MARKER} {file_id}:{}", offset.raw())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +73,7 @@ impl<S: StorageIO> BlockStoreReader<S> {
|
||||
|
||||
pub fn get(&self, cid: &[u8; CID_SIZE]) -> Result<Option<Bytes>, ReadError> {
|
||||
match self.index.get(cid) {
|
||||
Some(e) => self.read_block_at(e.location).map(Some),
|
||||
Some(e) => self.read_block_at(e.location, cid).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -83,10 +85,10 @@ impl<S: StorageIO> BlockStoreReader<S> {
|
||||
pub fn get_many(&self, cids: &[[u8; CID_SIZE]]) -> Result<Vec<Option<Bytes>>, ReadError> {
|
||||
let mut results: Vec<Option<Bytes>> = vec![None; cids.len()];
|
||||
|
||||
let index_lookups: Vec<(usize, BlockLocation)> = cids
|
||||
let index_lookups: Vec<(usize, [u8; CID_SIZE], BlockLocation)> = cids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, cid)| self.index.get(cid).map(|entry| (i, entry.location)))
|
||||
.filter_map(|(i, cid)| self.index.get(cid).map(|entry| (i, *cid, entry.location)))
|
||||
.collect();
|
||||
self.read_locations_into(&index_lookups, &mut results)?;
|
||||
|
||||
@@ -95,30 +97,38 @@ impl<S: StorageIO> BlockStoreReader<S> {
|
||||
|
||||
fn read_locations_into(
|
||||
&self,
|
||||
lookups: &[(usize, BlockLocation)],
|
||||
lookups: &[(usize, [u8; CID_SIZE], BlockLocation)],
|
||||
results: &mut [Option<Bytes>],
|
||||
) -> Result<(), ReadError> {
|
||||
let mut by_file: HashMap<DataFileId, Vec<(usize, BlockLocation)>> = HashMap::new();
|
||||
lookups.iter().for_each(|&(idx, loc)| {
|
||||
by_file.entry(loc.file_id).or_default().push((idx, loc));
|
||||
let mut by_file: HashMap<DataFileId, Vec<(usize, [u8; CID_SIZE], BlockLocation)>> =
|
||||
HashMap::new();
|
||||
lookups.iter().for_each(|&(idx, cid, loc)| {
|
||||
by_file
|
||||
.entry(loc.file_id)
|
||||
.or_default()
|
||||
.push((idx, cid, loc));
|
||||
});
|
||||
|
||||
by_file.into_iter().try_for_each(|(file_id, mut entries)| {
|
||||
let handle = self.manager.open_for_read(file_id)?;
|
||||
let file_size = self.manager.io().file_size(handle.fd())?;
|
||||
entries.sort_by_key(|(_, loc)| loc.offset);
|
||||
entries.into_iter().try_for_each(|(orig_idx, loc)| {
|
||||
let data = self.decode_and_validate(handle.fd(), file_size, loc)?;
|
||||
entries.sort_by_key(|(_, _, loc)| loc.offset);
|
||||
entries.into_iter().try_for_each(|(orig_idx, cid, loc)| {
|
||||
let data = self.decode_and_validate(handle.fd(), file_size, loc, &cid)?;
|
||||
results[orig_idx] = Some(data);
|
||||
Ok::<_, ReadError>(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn read_block_at(&self, location: BlockLocation) -> Result<Bytes, ReadError> {
|
||||
fn read_block_at(
|
||||
&self,
|
||||
location: BlockLocation,
|
||||
expected_cid: &[u8; CID_SIZE],
|
||||
) -> Result<Bytes, ReadError> {
|
||||
let handle = self.manager.open_for_read(location.file_id)?;
|
||||
let file_size = self.manager.io().file_size(handle.fd())?;
|
||||
self.decode_and_validate(handle.fd(), file_size, location)
|
||||
self.decode_and_validate(handle.fd(), file_size, location, expected_cid)
|
||||
}
|
||||
|
||||
fn decode_and_validate(
|
||||
@@ -126,38 +136,129 @@ impl<S: StorageIO> BlockStoreReader<S> {
|
||||
fd: FileId,
|
||||
file_size: u64,
|
||||
location: BlockLocation,
|
||||
expected_cid: &[u8; CID_SIZE],
|
||||
) -> Result<Bytes, ReadError> {
|
||||
let attempt_once = || -> Result<Bytes, ReadError> {
|
||||
match decode_block_record(self.manager.io(), fd, location.offset, file_size)? {
|
||||
Some(ReadBlockRecord::Valid { data, .. })
|
||||
if data.len() == location.length.raw() as usize =>
|
||||
let at_location = ReadError::Corrupted {
|
||||
file_id: location.file_id,
|
||||
offset: location.offset,
|
||||
};
|
||||
let attempt_once = || -> Result<Bytes, (ReadError, bool)> {
|
||||
match decode_block_record(self.manager.io(), fd, location.offset, file_size) {
|
||||
Err(e) => Err((e.into(), false)),
|
||||
Ok(Some(ReadBlockRecord::Valid {
|
||||
data, cid_bytes, ..
|
||||
})) if cid_bytes == *expected_cid
|
||||
&& data.len() == location.length.raw() as usize =>
|
||||
{
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
Some(ReadBlockRecord::Valid { .. }) => Err(ReadError::Corrupted {
|
||||
file_id: location.file_id,
|
||||
offset: location.offset,
|
||||
}),
|
||||
Some(
|
||||
Ok(Some(ReadBlockRecord::Valid { .. })) => Err((at_location.clone(), false)),
|
||||
Ok(Some(
|
||||
ReadBlockRecord::Corrupted { offset } | ReadBlockRecord::Truncated { offset },
|
||||
) => Err(ReadError::Corrupted {
|
||||
file_id: location.file_id,
|
||||
offset,
|
||||
}),
|
||||
None => Err(ReadError::Corrupted {
|
||||
file_id: location.file_id,
|
||||
offset: location.offset,
|
||||
}),
|
||||
)) => Err((
|
||||
ReadError::Corrupted {
|
||||
file_id: location.file_id,
|
||||
offset,
|
||||
},
|
||||
true,
|
||||
)),
|
||||
Ok(None) => Err((at_location.clone(), true)),
|
||||
}
|
||||
};
|
||||
(0..READ_RETRY_ATTEMPTS.saturating_sub(1))
|
||||
.find_map(|_| match attempt_once() {
|
||||
Ok(bytes) => Some(Ok(bytes)),
|
||||
Err(ReadError::Corrupted { .. }) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
Err((_, true)) => None,
|
||||
Err((e, false)) => Some(Err(e)),
|
||||
})
|
||||
.unwrap_or_else(attempt_once)
|
||||
.unwrap_or_else(|| attempt_once().map_err(|(e, _)| e))
|
||||
}
|
||||
}
|
||||
|
||||
const READ_RETRY_ATTEMPTS: u32 = 4;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BlockStoreReader, ReadError};
|
||||
use crate::blockstore::data_file::{CID_SIZE, DataFileWriter};
|
||||
use crate::blockstore::hash_index::{BlockIndex, HashTable};
|
||||
use crate::blockstore::manager::DataFileManager;
|
||||
use crate::blockstore::test_cid;
|
||||
use crate::blockstore::types::{BlockLocation, DataFileId};
|
||||
use crate::io::StorageIO;
|
||||
use crate::sim::SimulatedIO;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const BLOCK_A: &[u8] = b"block-a-contents";
|
||||
const BLOCK_B: &[u8] = b"block-b-contents";
|
||||
|
||||
fn setup() -> DataFileManager<SimulatedIO> {
|
||||
let sim = SimulatedIO::pristine(7);
|
||||
let dir = Path::new("/data");
|
||||
sim.mkdir(dir).unwrap();
|
||||
sim.sync_dir(dir).unwrap();
|
||||
DataFileManager::new(sim, dir.to_path_buf(), 1 << 20)
|
||||
}
|
||||
|
||||
fn write_two_blocks(
|
||||
mgr: &DataFileManager<SimulatedIO>,
|
||||
) -> ([u8; CID_SIZE], BlockLocation, [u8; CID_SIZE], BlockLocation) {
|
||||
let handle = mgr.open_for_append(DataFileId::new(0)).unwrap();
|
||||
let mut writer = DataFileWriter::new(mgr.io(), handle.fd(), DataFileId::new(0)).unwrap();
|
||||
let cid_a = test_cid(1);
|
||||
let cid_b = test_cid(2);
|
||||
let loc_a = writer.append_block(&cid_a, BLOCK_A).unwrap();
|
||||
let loc_b = writer.append_block(&cid_b, BLOCK_B).unwrap();
|
||||
writer.sync().unwrap();
|
||||
assert_eq!(loc_a.length, loc_b.length, "blocks must be equal length");
|
||||
(cid_a, loc_a, cid_b, loc_b)
|
||||
}
|
||||
|
||||
fn index_mapping(pairs: &[([u8; CID_SIZE], BlockLocation)]) -> Arc<BlockIndex> {
|
||||
let mut table = HashTable::with_capacity(64);
|
||||
pairs.iter().for_each(|(cid, loc)| {
|
||||
table.insert_or_increment(cid, *loc).unwrap();
|
||||
});
|
||||
Arc::new(BlockIndex::new(table, PathBuf::from("/index")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_rejects_index_pointing_at_different_block() {
|
||||
let mgr = Arc::new(setup());
|
||||
let (cid_a, _loc_a, _cid_b, loc_b) = write_two_blocks(&mgr);
|
||||
let index = index_mapping(&[(cid_a, loc_b)]);
|
||||
let reader = BlockStoreReader::new(index, mgr);
|
||||
match reader.get(&cid_a) {
|
||||
Err(ReadError::Corrupted { .. }) => {}
|
||||
other => {
|
||||
panic!("expected Corrupted when CID resolves to a foreign block, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_many_rejects_index_pointing_at_different_block() {
|
||||
let mgr = Arc::new(setup());
|
||||
let (cid_a, _loc_a, _cid_b, loc_b) = write_two_blocks(&mgr);
|
||||
let index = index_mapping(&[(cid_a, loc_b)]);
|
||||
let reader = BlockStoreReader::new(index, mgr);
|
||||
match reader.get_many(&[cid_a]) {
|
||||
Err(ReadError::Corrupted { .. }) => {}
|
||||
other => panic!("expected Corrupted from get_many on foreign block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_mapping_still_resolves() {
|
||||
let mgr = Arc::new(setup());
|
||||
let (cid_a, loc_a, cid_b, loc_b) = write_two_blocks(&mgr);
|
||||
let index = index_mapping(&[(cid_a, loc_a), (cid_b, loc_b)]);
|
||||
let reader = BlockStoreReader::new(index, mgr);
|
||||
assert_eq!(reader.get(&cid_a).unwrap().unwrap().as_ref(), BLOCK_A);
|
||||
assert_eq!(reader.get(&cid_b).unwrap().unwrap().as_ref(), BLOCK_B);
|
||||
let many = reader.get_many(&[cid_a, cid_b]).unwrap();
|
||||
assert_eq!(many[0].as_ref().unwrap().as_ref(), BLOCK_A);
|
||||
assert_eq!(many[1].as_ref().unwrap().as_ref(), BLOCK_B);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard_repo::error::RepoError;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::MemoryBlockStore;
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::io::StorageIO;
|
||||
|
||||
use super::store::{TranquilBlockStore, cid_to_bytes};
|
||||
use super::types::CidBytes;
|
||||
|
||||
const REPAIR_FILE_BYTE_BUDGET: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RepairOutcome {
|
||||
pub nodes_total: usize,
|
||||
pub nodes_repaired: u64,
|
||||
}
|
||||
|
||||
fn rebuild_err(context: &str, e: impl std::fmt::Display) -> RepoError {
|
||||
RepoError::storage(std::io::Error::other(format!("{context}: {e}")))
|
||||
}
|
||||
|
||||
pub async fn rebuild_mst_nodes(
|
||||
entries: &[(String, Cid)],
|
||||
expected_root: Cid,
|
||||
) -> Result<Vec<(Cid, Bytes)>, RepoError> {
|
||||
let scratch = Arc::new(MemoryBlockStore::new());
|
||||
let mut mst = Mst::new(scratch);
|
||||
for (key, cid) in entries {
|
||||
mst.add_mut(key.as_str(), *cid)
|
||||
.await
|
||||
.map_err(|e| rebuild_err("mst rebuild add", e))?;
|
||||
}
|
||||
|
||||
let (root, blocks) = mst
|
||||
.collect_blocks()
|
||||
.await
|
||||
.map_err(|e| rebuild_err("mst collect_blocks", e))?;
|
||||
|
||||
if root != expected_root {
|
||||
return Err(RepoError::storage(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"rebuilt MST root {root} does not match expected root {expected_root}, refusing to repair"
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(blocks.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn rebuild_node_blocks(
|
||||
entries: Vec<(String, Cid)>,
|
||||
expected_root: Cid,
|
||||
) -> Result<Vec<(CidBytes, Vec<u8>)>, RepoError> {
|
||||
rebuild_mst_nodes(&entries, expected_root)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(cid, bytes)| Ok((cid_to_bytes(&cid)?, bytes.to_vec())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn batch_by_bytes(blocks: Vec<(CidBytes, Vec<u8>)>) -> Vec<Vec<(CidBytes, Vec<u8>)>> {
|
||||
blocks
|
||||
.into_iter()
|
||||
.fold(
|
||||
(Vec::<Vec<(CidBytes, Vec<u8>)>>::new(), 0usize),
|
||||
|(mut batches, current_bytes), item| {
|
||||
let item_len = item.1.len();
|
||||
match batches.last_mut() {
|
||||
Some(last) if current_bytes + item_len <= REPAIR_FILE_BYTE_BUDGET => {
|
||||
last.push(item);
|
||||
(batches, current_bytes + item_len)
|
||||
}
|
||||
_ => {
|
||||
batches.push(vec![item]);
|
||||
(batches, item_len)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.0
|
||||
}
|
||||
|
||||
pub async fn rebuild_and_repair_mst<S, C>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
entries: &[(String, Cid)],
|
||||
expected_root: Cid,
|
||||
) -> Result<RepairOutcome, RepoError>
|
||||
where
|
||||
S: StorageIO + Send + Sync + 'static,
|
||||
C: Clock,
|
||||
{
|
||||
let store = store.clone();
|
||||
let entries = entries.to_vec();
|
||||
tokio::task::spawn_blocking(move || -> Result<RepairOutcome, RepoError> {
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let node_blocks = handle.block_on(rebuild_node_blocks(entries, expected_root))?;
|
||||
let nodes_total = node_blocks.len();
|
||||
|
||||
let to_repair: Vec<(CidBytes, Vec<u8>)> = node_blocks
|
||||
.into_iter()
|
||||
.filter(|(cid, _)| !matches!(store.get_block_sync(cid), Ok(Some(_))))
|
||||
.collect();
|
||||
|
||||
let nodes_repaired = batch_by_bytes(to_repair)
|
||||
.into_iter()
|
||||
.try_fold(0u64, |acc, batch| {
|
||||
store.repair_blocks(batch).map(|n| acc + n)
|
||||
})
|
||||
.map_err(|e| rebuild_err("repair_blocks", e))?;
|
||||
|
||||
Ok(RepairOutcome {
|
||||
nodes_total,
|
||||
nodes_repaired,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(RepoError::task_failed)?
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use jacquard_repo::error::RepoError;
|
||||
use jacquard_repo::repo::CommitData;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
use crate::fsync_order::PostBlockstoreHook;
|
||||
use crate::io::{OpenOptions, RealIO, StorageIO};
|
||||
|
||||
@@ -23,10 +24,10 @@ use super::manager::DataFileManager;
|
||||
use super::reader::{BlockStoreReader, ReadError};
|
||||
use super::types::{
|
||||
BlockLocation, BlockOffset, CollectionResult, CompactionResult, DataFileId, EpochCounter,
|
||||
LivenessInfo, WallClockMs,
|
||||
LivenessInfo,
|
||||
};
|
||||
|
||||
fn cid_to_bytes(cid: &Cid) -> Result<[u8; CID_SIZE], RepoError> {
|
||||
pub(crate) fn cid_to_bytes(cid: &Cid) -> Result<[u8; CID_SIZE], RepoError> {
|
||||
let raw = cid.to_bytes();
|
||||
let len = raw.len();
|
||||
raw.try_into().map_err(|_| {
|
||||
@@ -63,7 +64,11 @@ fn read_error_to_repo(e: ReadError) -> RepoError {
|
||||
}
|
||||
ReadError::Corrupted { file_id, offset } => RepoError::storage(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("corrupted block at {file_id}:{}", offset.raw()),
|
||||
format!(
|
||||
"{} {file_id}:{}",
|
||||
super::reader::BLOCK_CORRUPTION_MARKER,
|
||||
offset.raw()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -111,15 +116,17 @@ impl Drop for QuiesceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TranquilBlockStore<S: StorageIO + Send + Sync + 'static = RealIO> {
|
||||
pub struct TranquilBlockStore<S: StorageIO + Send + Sync + 'static, C: Clock> {
|
||||
writer: Arc<WriterHandle>,
|
||||
reader: Arc<BlockStoreReader<S>>,
|
||||
index: Arc<BlockIndex>,
|
||||
epoch: EpochCounter,
|
||||
data_dir: PathBuf,
|
||||
clock: C,
|
||||
synchronous: bool,
|
||||
}
|
||||
|
||||
impl<S: StorageIO + Send + Sync + 'static> Clone for TranquilBlockStore<S> {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Clone for TranquilBlockStore<S, C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
writer: Arc::clone(&self.writer),
|
||||
@@ -127,6 +134,8 @@ impl<S: StorageIO + Send + Sync + 'static> Clone for TranquilBlockStore<S> {
|
||||
index: Arc::clone(&self.index),
|
||||
epoch: self.epoch.clone(),
|
||||
data_dir: self.data_dir.clone(),
|
||||
clock: self.clock.clone(),
|
||||
synchronous: self.synchronous,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +179,7 @@ impl Default for OpenRetryPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
impl TranquilBlockStore<RealIO> {
|
||||
impl TranquilBlockStore<RealIO, SystemClock> {
|
||||
pub fn open(config: BlockStoreConfig) -> Result<Self, RepoError> {
|
||||
Self::open_with_hook(config, None)
|
||||
}
|
||||
@@ -179,7 +188,7 @@ impl TranquilBlockStore<RealIO> {
|
||||
config: BlockStoreConfig,
|
||||
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
|
||||
) -> Result<Self, RepoError> {
|
||||
Self::open_with_io_hook(config, RealIO::new, post_sync_hook)
|
||||
Self::open_with_io_hook(config, RealIO::new, post_sync_hook, SystemClock)
|
||||
}
|
||||
|
||||
pub fn open_with_retry(
|
||||
@@ -227,18 +236,23 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
pub fn open_with_io<F>(config: BlockStoreConfig, make_io: F) -> Result<Self, RepoError>
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> TranquilBlockStore<S, C> {
|
||||
pub fn open_with_io<F>(
|
||||
config: BlockStoreConfig,
|
||||
make_io: F,
|
||||
clock: C,
|
||||
) -> Result<Self, RepoError>
|
||||
where
|
||||
F: Fn() -> S + Send + Sync + Clone + 'static,
|
||||
{
|
||||
Self::open_with_io_hook(config, make_io, None)
|
||||
Self::open_with_io_hook(config, make_io, None, clock)
|
||||
}
|
||||
|
||||
pub fn open_with_io_hook<F>(
|
||||
config: BlockStoreConfig,
|
||||
make_io: F,
|
||||
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
|
||||
clock: C,
|
||||
) -> Result<Self, RepoError>
|
||||
where
|
||||
F: Fn() -> S + Send + Sync + Clone + 'static,
|
||||
@@ -287,6 +301,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
|
||||
let checkpoint_epoch = index.loaded_checkpoint_epoch();
|
||||
let checkpoint_positions = index.loaded_checkpoint_positions();
|
||||
let synchronous = config.group_commit.synchronous;
|
||||
let writer = GroupCommitWriter::spawn_sharded(
|
||||
make_manager,
|
||||
Arc::clone(&index),
|
||||
@@ -295,6 +310,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
checkpoint_epoch,
|
||||
shard_count,
|
||||
checkpoint_positions,
|
||||
clock.clone(),
|
||||
)
|
||||
.map_err(commit_error_to_repo)?;
|
||||
let epoch = writer.epoch().clone();
|
||||
@@ -317,6 +333,8 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
index,
|
||||
epoch,
|
||||
data_dir,
|
||||
clock,
|
||||
synchronous,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -335,10 +353,12 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
}
|
||||
|
||||
let header_start = BlockOffset::new(super::data_file::BLOCK_HEADER_SIZE as u64);
|
||||
let indexed_ends = index.indexed_file_ends();
|
||||
|
||||
all_data_files.iter().try_for_each(|&fid| {
|
||||
let start_offset = file_cursors.get(&fid).copied().unwrap_or(header_start);
|
||||
Self::replay_single_file(io, data_dir, index, fid, start_offset)
|
||||
let indexed_end = indexed_ends.get(&fid).copied().unwrap_or(header_start);
|
||||
Self::replay_single_file(io, data_dir, index, fid, start_offset, indexed_end)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,6 +368,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
index: &BlockIndex,
|
||||
file_id: DataFileId,
|
||||
start_offset: BlockOffset,
|
||||
indexed_end: BlockOffset,
|
||||
) -> Result<(), RepoError> {
|
||||
let file_path = data_dir.join(format!("{file_id}.{}", super::manager::DATA_FILE_EXTENSION));
|
||||
|
||||
@@ -371,7 +392,15 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
})
|
||||
.is_ok();
|
||||
|
||||
let result = Self::scan_and_index(io, index, fd, file_id, start_offset, hint_exists);
|
||||
let result = Self::scan_and_index(
|
||||
io,
|
||||
index,
|
||||
fd,
|
||||
file_id,
|
||||
start_offset,
|
||||
indexed_end,
|
||||
hint_exists,
|
||||
);
|
||||
|
||||
let _ = io.close(fd);
|
||||
|
||||
@@ -384,6 +413,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
fd: crate::io::FileId,
|
||||
file_id: DataFileId,
|
||||
start_offset: BlockOffset,
|
||||
indexed_end: BlockOffset,
|
||||
hint_exists: bool,
|
||||
) -> Result<(), RepoError> {
|
||||
let file_size = io.file_size(fd).map_err(RepoError::storage)?;
|
||||
@@ -443,32 +473,42 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
RepoError::storage(e)
|
||||
})?;
|
||||
|
||||
if file_size > last_valid_end.raw() {
|
||||
let committed_end = BlockOffset::new(start_offset.raw().max(indexed_end.raw()));
|
||||
let keep_end = match hint_exists {
|
||||
true => committed_end,
|
||||
false => last_valid_end.max(indexed_end),
|
||||
};
|
||||
|
||||
if file_size > keep_end.raw() {
|
||||
tracing::info!(
|
||||
file_id = %file_id,
|
||||
truncating_from = last_valid_end.raw(),
|
||||
truncating_from = keep_end.raw(),
|
||||
file_size,
|
||||
scanned_count = scanned_entries.len(),
|
||||
"truncating partial/unacked tail"
|
||||
"truncating uncommitted tail"
|
||||
);
|
||||
io.truncate(fd, last_valid_end.raw())
|
||||
io.truncate(fd, keep_end.raw())
|
||||
.map_err(RepoError::storage)?;
|
||||
io.sync(fd).map_err(RepoError::storage)?;
|
||||
}
|
||||
|
||||
if !scanned_entries.is_empty() {
|
||||
let indexable: Vec<_> = scanned_entries
|
||||
.into_iter()
|
||||
.filter(|(_, loc)| loc.offset.raw() < keep_end.raw())
|
||||
.collect();
|
||||
if !indexable.is_empty() {
|
||||
tracing::info!(
|
||||
file_id = %file_id,
|
||||
scanned = scanned_entries.len(),
|
||||
scanned = indexable.len(),
|
||||
hint_exists,
|
||||
"reindexing blocks past hint coverage"
|
||||
"reindexing blocks within committed extent"
|
||||
);
|
||||
let cursor = super::types::WriteCursor {
|
||||
file_id,
|
||||
offset: last_valid_end,
|
||||
offset: keep_end,
|
||||
};
|
||||
index
|
||||
.batch_put_if_absent(&scanned_entries, cursor)
|
||||
.batch_put_if_absent(&indexable, cursor)
|
||||
.map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?;
|
||||
}
|
||||
|
||||
@@ -497,9 +537,13 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn clock(&self) -> &C {
|
||||
&self.clock
|
||||
}
|
||||
|
||||
pub fn collect_dead_blocks(&self, grace_period_ms: u64) -> Result<CollectionResult, RepoError> {
|
||||
let current_epoch = self.epoch.current();
|
||||
let now = WallClockMs::now();
|
||||
let now = self.clock.wall_millis();
|
||||
Ok(self
|
||||
.index
|
||||
.collect_dead_blocks(current_epoch, now, grace_period_ms))
|
||||
@@ -511,16 +555,15 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
grace_period_ms: u64,
|
||||
) -> Result<CompactionResult, CompactionError> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_round_robin().clone())
|
||||
.map_err(|_| CompactionError::ChannelClosed)?;
|
||||
sender
|
||||
.send(CommitRequest::Compact {
|
||||
file_id,
|
||||
grace_period_ms,
|
||||
response: tx,
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::Compact {
|
||||
file_id,
|
||||
grace_period_ms,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(|_| CompactionError::ChannelClosed)?
|
||||
.map_err(|_| CompactionError::ChannelClosed)?;
|
||||
let result = rx
|
||||
.blocking_recv()
|
||||
@@ -536,7 +579,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
grace_period_ms: u64,
|
||||
) -> Result<HashMap<DataFileId, LivenessInfo>, RepoError> {
|
||||
let current_epoch = self.epoch.current();
|
||||
let now = WallClockMs::now();
|
||||
let now = self.clock.wall_millis();
|
||||
Ok(self
|
||||
.index
|
||||
.liveness_by_file(current_epoch, now, grace_period_ms))
|
||||
@@ -570,21 +613,41 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
leaked_cids: &[(super::types::CidBytes, super::types::RefCount)],
|
||||
) -> Result<u64, RepoError> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_round_robin().clone())
|
||||
.map_err(commit_error_to_repo)?;
|
||||
sender
|
||||
.send(CommitRequest::RepairLeaked {
|
||||
leaked_cids: leaked_cids.to_vec(),
|
||||
response: tx,
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::RepairLeaked {
|
||||
leaked_cids: leaked_cids.to_vec(),
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?;
|
||||
.map_err(commit_error_to_repo)?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
rx.blocking_recv()
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?
|
||||
.map_err(commit_error_to_repo)
|
||||
}
|
||||
|
||||
pub fn repair_blocks(
|
||||
&self,
|
||||
blocks: Vec<(super::types::CidBytes, Vec<u8>)>,
|
||||
) -> Result<u64, CompactionError> {
|
||||
if blocks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::RepairBlocks {
|
||||
blocks,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(|_| CompactionError::ChannelClosed)?
|
||||
.map_err(|_| CompactionError::ChannelClosed)?;
|
||||
rx.blocking_recv()
|
||||
.map_err(|_| CompactionError::ChannelClosed)?
|
||||
}
|
||||
|
||||
pub fn get_block_sync(
|
||||
&self,
|
||||
cid_bytes: &[u8; CID_SIZE],
|
||||
@@ -616,17 +679,16 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
if blocks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_for_blocks(&blocks).clone())
|
||||
.map_err(commit_error_to_repo)?;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
sender
|
||||
.send(CommitRequest::PutBlocks {
|
||||
blocks,
|
||||
response: tx,
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::PutBlocks {
|
||||
blocks,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?;
|
||||
.map_err(commit_error_to_repo)?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
rx.blocking_recv()
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
@@ -638,18 +700,17 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
|
||||
deleted_cids: Vec<[u8; CID_SIZE]>,
|
||||
) -> Result<(), RepoError> {
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_for_apply(&blocks, &deleted_cids).clone())
|
||||
.map_err(commit_error_to_repo)?;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
sender
|
||||
.send(CommitRequest::ApplyCommit {
|
||||
blocks,
|
||||
deleted_cids,
|
||||
response: tx,
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::ApplyCommit {
|
||||
blocks,
|
||||
deleted_cids,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?;
|
||||
.map_err(commit_error_to_repo)?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
rx.blocking_recv()
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?
|
||||
.map_err(commit_error_to_repo)
|
||||
@@ -659,6 +720,22 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
&self,
|
||||
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
|
||||
) -> Result<Vec<BlockLocation>, RepoError> {
|
||||
if self.synchronous {
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::PutBlocks {
|
||||
blocks,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(commit_error_to_repo)?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
return rx
|
||||
.try_recv()
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?
|
||||
.map_err(commit_error_to_repo);
|
||||
}
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_for_blocks(&blocks).clone())
|
||||
@@ -681,6 +758,23 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
|
||||
deleted_cids: Vec<[u8; CID_SIZE]>,
|
||||
) -> Result<(), RepoError> {
|
||||
if self.synchronous {
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
self.writer
|
||||
.with(|w| {
|
||||
w.submit_blocking(CommitRequest::ApplyCommit {
|
||||
blocks,
|
||||
deleted_cids,
|
||||
response: tx,
|
||||
})
|
||||
})
|
||||
.map_err(commit_error_to_repo)?
|
||||
.map_err(commit_error_to_repo)?;
|
||||
return rx
|
||||
.try_recv()
|
||||
.map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?
|
||||
.map_err(commit_error_to_repo);
|
||||
}
|
||||
let sender = self
|
||||
.writer
|
||||
.with(|w| w.sender_for_apply(&blocks, &deleted_cids).clone())
|
||||
@@ -700,9 +794,12 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: StorageIO + Send + Sync + 'static> BlockStore for TranquilBlockStore<S> {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> BlockStore for TranquilBlockStore<S, C> {
|
||||
async fn get(&self, cid: &Cid) -> Result<Option<Bytes>, RepoError> {
|
||||
let cid_bytes = cid_to_bytes(cid)?;
|
||||
if self.synchronous {
|
||||
return self.reader.get(&cid_bytes).map_err(read_error_to_repo);
|
||||
}
|
||||
let reader = Arc::clone(&self.reader);
|
||||
tokio::task::spawn_blocking(move || reader.get(&cid_bytes))
|
||||
.await
|
||||
@@ -720,6 +817,9 @@ impl<S: StorageIO + Send + Sync + 'static> BlockStore for TranquilBlockStore<S>
|
||||
|
||||
async fn has(&self, cid: &Cid) -> Result<bool, RepoError> {
|
||||
let cid_bytes = cid_to_bytes(cid)?;
|
||||
if self.synchronous {
|
||||
return self.reader.has(&cid_bytes).map_err(read_error_to_repo);
|
||||
}
|
||||
let reader = Arc::clone(&self.reader);
|
||||
tokio::task::spawn_blocking(move || reader.has(&cid_bytes))
|
||||
.await
|
||||
@@ -750,6 +850,9 @@ impl<S: StorageIO + Send + Sync + 'static> BlockStore for TranquilBlockStore<S>
|
||||
.iter()
|
||||
.map(cid_to_bytes)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if self.synchronous {
|
||||
return self.reader.get_many(&cid_bytes).map_err(read_error_to_repo);
|
||||
}
|
||||
let reader = Arc::clone(&self.reader);
|
||||
tokio::task::spawn_blocking(move || reader.get_many(&cid_bytes))
|
||||
.await
|
||||
@@ -772,7 +875,7 @@ impl<S: StorageIO + Send + Sync + 'static> BlockStore for TranquilBlockStore<S>
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> TranquilBlockStore<S, C> {
|
||||
pub async fn decrement_refs(&self, cids: &[Cid]) -> Result<(), RepoError> {
|
||||
if cids.is_empty() {
|
||||
return Ok(());
|
||||
@@ -920,12 +1023,13 @@ mod tests {
|
||||
|
||||
let index = BlockIndex::open(&index_dir).unwrap();
|
||||
|
||||
let result = TranquilBlockStore::<EioOnReadAtRange>::replay_single_file(
|
||||
let result = TranquilBlockStore::<EioOnReadAtRange, SystemClock>::replay_single_file(
|
||||
&wrapper,
|
||||
&data_dir,
|
||||
&index,
|
||||
file_id,
|
||||
BlockOffset::new(BLOCK_HEADER_SIZE as u64),
|
||||
BlockOffset::new(BLOCK_HEADER_SIZE as u64),
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -940,6 +1044,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_recovers_from_torn_hint_tail_without_aborting() {
|
||||
use crate::blockstore::HINT_RECORD_SIZE;
|
||||
use std::io::Write;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().join("data");
|
||||
let index_dir = tmp.path().join("index");
|
||||
let cfg = || BlockStoreConfig::new(data_dir.clone(), index_dir.clone());
|
||||
|
||||
let payload_a = b"alpha-block-payload".to_vec();
|
||||
let payload_b = b"bravo-block-payload".to_vec();
|
||||
let cid_a = crate::blockstore::hash_to_cid_bytes(&payload_a);
|
||||
let cid_b = crate::blockstore::hash_to_cid_bytes(&payload_b);
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(cfg()).unwrap();
|
||||
store
|
||||
.apply_commit_blocking(vec![(cid_a, payload_a.clone())], vec![])
|
||||
.unwrap();
|
||||
store.apply_commit_blocking(vec![], vec![]).unwrap();
|
||||
}
|
||||
|
||||
let hint_path = std::fs::read_dir(&data_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.find(|p| p.extension().and_then(|s| s.to_str()) == Some("tqh"))
|
||||
.expect("active hint file must exist after commit");
|
||||
|
||||
let clean = std::fs::metadata(&hint_path).unwrap().len();
|
||||
assert_eq!(
|
||||
clean % HINT_RECORD_SIZE as u64,
|
||||
0,
|
||||
"precondition: synced hint file must be record-aligned, got {clean}"
|
||||
);
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&hint_path)
|
||||
.unwrap();
|
||||
f.write_all(&[0x5Au8; 50]).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::metadata(&hint_path).unwrap().len() % HINT_RECORD_SIZE as u64,
|
||||
50,
|
||||
"torn tail must leave a non-aligned hint file"
|
||||
);
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(cfg()).unwrap();
|
||||
assert!(
|
||||
store.get_block_sync(&cid_a).unwrap().is_some(),
|
||||
"block A lost after torn-tail recovery"
|
||||
);
|
||||
store
|
||||
.apply_commit_blocking(vec![(cid_b, payload_b.clone())], vec![])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let healed = std::fs::metadata(&hint_path).unwrap().len();
|
||||
assert_eq!(
|
||||
healed % HINT_RECORD_SIZE as u64,
|
||||
0,
|
||||
"recovery must realign the hint file, got {healed}"
|
||||
);
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(cfg()).unwrap();
|
||||
assert!(
|
||||
store.get_block_sync(&cid_a).unwrap().is_some(),
|
||||
"block A lost across second reopen"
|
||||
);
|
||||
assert!(
|
||||
store.get_block_sync(&cid_b).unwrap().is_some(),
|
||||
"block B lost across second reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synchronous_store_serves_async_block_store_trait() {
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut cfg = BlockStoreConfig::new(tmp.path().join("data"), tmp.path().join("index"));
|
||||
cfg.group_commit.synchronous = true;
|
||||
let store = TranquilBlockStore::open(cfg).unwrap();
|
||||
|
||||
let payload = b"sync-trait-roundtrip".to_vec();
|
||||
let cid = crate::blockstore::hash_to_cid(&payload);
|
||||
|
||||
store
|
||||
.put_many(vec![(cid, bytes::Bytes::from(payload.clone()))])
|
||||
.await
|
||||
.expect("async put_many must not panic on a synchronous store");
|
||||
|
||||
let got = store.get(&cid).await.expect("async get must succeed");
|
||||
assert_eq!(
|
||||
got.as_deref(),
|
||||
Some(payload.as_slice()),
|
||||
"synchronous store must round-trip blocks through the async trait"
|
||||
);
|
||||
|
||||
store
|
||||
.decrement_refs(&[cid])
|
||||
.await
|
||||
.expect("async decrement_refs must not panic on a synchronous store");
|
||||
}
|
||||
|
||||
fn instant_policy(max_attempts: u8) -> OpenRetryPolicy {
|
||||
OpenRetryPolicy {
|
||||
max_attempts: NonZeroU8::new(max_attempts).expect("max_attempts must be nonzero"),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::blockstore::WallClockMs;
|
||||
use crate::eventlog::TimestampMicros;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(transparent)]
|
||||
pub struct LogicalNanos(u64);
|
||||
|
||||
impl LogicalNanos {
|
||||
pub const fn new(nanos: u64) -> Self {
|
||||
Self(nanos)
|
||||
}
|
||||
|
||||
pub fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_millis(ms: u64) -> Self {
|
||||
Self(ms.saturating_mul(1_000_000))
|
||||
}
|
||||
|
||||
pub fn saturating_sub(self, other: Self) -> Self {
|
||||
Self(self.0.saturating_sub(other.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Clock: Clone + Send + Sync + 'static {
|
||||
fn unix_micros(&self) -> TimestampMicros;
|
||||
fn wall_millis(&self) -> WallClockMs;
|
||||
fn monotonic(&self) -> LogicalNanos;
|
||||
fn advance(&self, by: Duration);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SystemClock;
|
||||
|
||||
static PROCESS_ORIGIN: OnceLock<Instant> = OnceLock::new();
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn unix_micros(&self) -> TimestampMicros {
|
||||
TimestampMicros::now()
|
||||
}
|
||||
|
||||
fn wall_millis(&self) -> WallClockMs {
|
||||
WallClockMs::now()
|
||||
}
|
||||
|
||||
fn monotonic(&self) -> LogicalNanos {
|
||||
let origin = PROCESS_ORIGIN.get_or_init(Instant::now);
|
||||
LogicalNanos::new(u64::try_from(origin.elapsed().as_nanos()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
fn advance(&self, _by: Duration) {}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
pub use sim_clock::SimClock;
|
||||
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
mod sim_clock {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{Clock, LogicalNanos};
|
||||
use crate::blockstore::WallClockMs;
|
||||
use crate::eventlog::TimestampMicros;
|
||||
use crate::sim::splitmix64;
|
||||
|
||||
const EPOCH_BASE_MICROS: u64 = 1_700_000_000_000_000;
|
||||
const ORIGIN_SPREAD_MICROS: u64 = 86_400 * 1_000_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SimClockState {
|
||||
logical_nanos: AtomicU64,
|
||||
unix_origin_micros: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimClock {
|
||||
state: Arc<SimClockState>,
|
||||
}
|
||||
|
||||
impl SimClock {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: Arc::new(SimClockState {
|
||||
logical_nanos: AtomicU64::new(0),
|
||||
unix_origin_micros: EPOCH_BASE_MICROS
|
||||
+ (splitmix64(seed) % ORIGIN_SPREAD_MICROS),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for SimClock {
|
||||
fn unix_micros(&self) -> TimestampMicros {
|
||||
let logical = self.state.logical_nanos.load(Ordering::Acquire);
|
||||
TimestampMicros::new(
|
||||
self.state
|
||||
.unix_origin_micros
|
||||
.saturating_add(logical / 1_000),
|
||||
)
|
||||
}
|
||||
|
||||
fn wall_millis(&self) -> WallClockMs {
|
||||
let logical = self.state.logical_nanos.load(Ordering::Acquire);
|
||||
WallClockMs::new(
|
||||
(self.state.unix_origin_micros / 1_000).saturating_add(logical / 1_000_000),
|
||||
)
|
||||
}
|
||||
|
||||
fn monotonic(&self) -> LogicalNanos {
|
||||
LogicalNanos::new(self.state.logical_nanos.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
fn advance(&self, by: Duration) {
|
||||
let nanos = u64::try_from(by.as_nanos()).unwrap_or(u64::MAX);
|
||||
self.state.logical_nanos.fetch_add(nanos, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ use std::path::Path;
|
||||
use crate::blockstore::CID_SIZE;
|
||||
use crate::blockstore::hash_index::BlockIndex;
|
||||
use crate::blockstore::{DataFileId, TranquilBlockStore};
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
use crate::eventlog::{EventLog, EventSequence, SequenceContiguityResult};
|
||||
use crate::io::StorageIO;
|
||||
use crate::io::{RealIO, StorageIO};
|
||||
use crate::metastore::Metastore;
|
||||
use crate::metastore::encoding::KeyBuilder;
|
||||
use crate::metastore::event_keys::metastore_cursor_key;
|
||||
@@ -244,11 +245,16 @@ impl Default for ConsistencyCheckOptions {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_store_consistency<S: StorageIO + 'static>(
|
||||
blockstore: &TranquilBlockStore,
|
||||
pub fn verify_store_consistency<BS, BC, ES>(
|
||||
blockstore: &TranquilBlockStore<BS, BC>,
|
||||
metastore: &Metastore,
|
||||
eventlog: &EventLog<S>,
|
||||
) -> ConsistencyReport {
|
||||
eventlog: &EventLog<ES>,
|
||||
) -> ConsistencyReport
|
||||
where
|
||||
BS: StorageIO + Send + Sync + 'static,
|
||||
BC: Clock,
|
||||
ES: StorageIO + 'static,
|
||||
{
|
||||
verify_store_consistency_with_options(
|
||||
blockstore,
|
||||
metastore,
|
||||
@@ -257,12 +263,17 @@ pub fn verify_store_consistency<S: StorageIO + 'static>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_store_consistency_with_options<S: StorageIO + 'static>(
|
||||
blockstore: &TranquilBlockStore,
|
||||
pub fn verify_store_consistency_with_options<BS, BC, ES>(
|
||||
blockstore: &TranquilBlockStore<BS, BC>,
|
||||
metastore: &Metastore,
|
||||
eventlog: &EventLog<S>,
|
||||
eventlog: &EventLog<ES>,
|
||||
options: ConsistencyCheckOptions,
|
||||
) -> ConsistencyReport {
|
||||
) -> ConsistencyReport
|
||||
where
|
||||
BS: StorageIO + Send + Sync + 'static,
|
||||
BC: Clock,
|
||||
ES: StorageIO + 'static,
|
||||
{
|
||||
let mut report = ConsistencyReport::default();
|
||||
|
||||
let block_index = blockstore.block_index();
|
||||
@@ -566,8 +577,8 @@ fn check_cursor_vs_eventlog<S: StorageIO + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn check_orphan_data_files(
|
||||
blockstore: &TranquilBlockStore,
|
||||
fn check_orphan_data_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
|
||||
blockstore: &TranquilBlockStore<BS, BC>,
|
||||
block_index: &BlockIndex,
|
||||
report: &mut ConsistencyReport,
|
||||
) {
|
||||
@@ -580,7 +591,7 @@ fn check_orphan_data_files(
|
||||
};
|
||||
|
||||
let epoch = blockstore.epoch().current();
|
||||
let now = crate::wall_clock_ms();
|
||||
let now = blockstore.clock().wall_millis();
|
||||
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
|
||||
|
||||
let indexed_file_ids: HashSet<DataFileId> = indexed_files.keys().copied().collect();
|
||||
@@ -599,8 +610,8 @@ fn check_orphan_data_files(
|
||||
});
|
||||
}
|
||||
|
||||
fn check_missing_indexed_files(
|
||||
blockstore: &TranquilBlockStore,
|
||||
fn check_missing_indexed_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
|
||||
blockstore: &TranquilBlockStore<BS, BC>,
|
||||
block_index: &BlockIndex,
|
||||
report: &mut ConsistencyReport,
|
||||
) {
|
||||
@@ -613,7 +624,7 @@ fn check_missing_indexed_files(
|
||||
};
|
||||
|
||||
let epoch = blockstore.epoch().current();
|
||||
let now = crate::wall_clock_ms();
|
||||
let now = blockstore.clock().wall_millis();
|
||||
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
|
||||
|
||||
indexed_files
|
||||
@@ -622,7 +633,10 @@ fn check_missing_indexed_files(
|
||||
.for_each(|(fid, _)| report.missing_indexed_files.push(*fid));
|
||||
}
|
||||
|
||||
fn check_orphan_hint_files(blockstore: &TranquilBlockStore, report: &mut ConsistencyReport) {
|
||||
fn check_orphan_hint_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
|
||||
blockstore: &TranquilBlockStore<BS, BC>,
|
||||
report: &mut ConsistencyReport,
|
||||
) {
|
||||
let data_files: HashSet<DataFileId> = match blockstore.list_data_files() {
|
||||
Ok(files) => files.into_iter().collect(),
|
||||
Err(e) => {
|
||||
@@ -682,7 +696,7 @@ fn parse_user_hash_from_value(value_bytes: &[u8]) -> Option<UserHash> {
|
||||
}
|
||||
|
||||
pub fn repair_known_issues(
|
||||
blockstore: &TranquilBlockStore,
|
||||
blockstore: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
report: &ConsistencyReport,
|
||||
) -> RepairResult {
|
||||
let mut result = RepairResult::default();
|
||||
|
||||
@@ -21,6 +21,10 @@ const SYNC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REORDER_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const GAP_ABANDON_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub(super) fn writer_terminated() -> io::Error {
|
||||
io::Error::other("eventlog writer thread terminated")
|
||||
}
|
||||
|
||||
pub struct FreezeResponse {
|
||||
pub synced_through: EventSequence,
|
||||
pub segment_id: SegmentId,
|
||||
@@ -42,6 +46,7 @@ pub enum WriterRequest {
|
||||
pub struct WriterNotify {
|
||||
synced_seq: AtomicU64,
|
||||
poisoned: AtomicBool,
|
||||
terminated: AtomicBool,
|
||||
mutex: Mutex<()>,
|
||||
cond: Condvar,
|
||||
}
|
||||
@@ -128,11 +133,16 @@ impl WriterNotify {
|
||||
Self {
|
||||
synced_seq: AtomicU64::new(initial_synced),
|
||||
poisoned: AtomicBool::new(false),
|
||||
terminated: AtomicBool::new(false),
|
||||
mutex: Mutex::new(()),
|
||||
cond: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_terminated(&self) -> bool {
|
||||
self.terminated.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn wait_for_sync(&self, target: EventSequence) -> io::Result<()> {
|
||||
let target_raw = target.raw();
|
||||
|
||||
@@ -143,6 +153,9 @@ impl WriterNotify {
|
||||
if self.poisoned.load(Ordering::Acquire) {
|
||||
return Err(io::Error::other("eventlog writer poisoned"));
|
||||
}
|
||||
if self.terminated.load(Ordering::Acquire) {
|
||||
return Err(io::Error::other("eventlog writer thread terminated"));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + SYNC_TIMEOUT;
|
||||
let mut guard = self.mutex.lock();
|
||||
@@ -154,6 +167,9 @@ impl WriterNotify {
|
||||
if self.poisoned.load(Ordering::Acquire) {
|
||||
return Err(io::Error::other("eventlog writer poisoned"));
|
||||
}
|
||||
if self.terminated.load(Ordering::Acquire) {
|
||||
return Err(io::Error::other("eventlog writer thread terminated"));
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
@@ -178,6 +194,12 @@ impl WriterNotify {
|
||||
let _guard = self.mutex.lock();
|
||||
self.cond.notify_all();
|
||||
}
|
||||
|
||||
fn terminate(&self) {
|
||||
self.terminated.store(true, Ordering::Release);
|
||||
let _guard = self.mutex.lock();
|
||||
self.cond.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
struct ReorderBuffer {
|
||||
@@ -372,12 +394,40 @@ impl<'a> Drop for CloseOnDrop<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_abandoned_request(request: WriterRequest) {
|
||||
match request {
|
||||
WriterRequest::SyncBarrier { response } => {
|
||||
let _ = response.send(Err(writer_terminated()));
|
||||
}
|
||||
WriterRequest::Freeze { response, .. } => {
|
||||
let _ = response.send(Err(writer_terminated()));
|
||||
}
|
||||
WriterRequest::Append(_) | WriterRequest::Shutdown => {}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminateOnDrop<'a> {
|
||||
notify: &'a WriterNotify,
|
||||
receiver: &'a flume::Receiver<WriterRequest>,
|
||||
}
|
||||
|
||||
impl Drop for TerminateOnDrop<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.notify.terminate();
|
||||
self.receiver.drain().for_each(fail_abandoned_request);
|
||||
}
|
||||
}
|
||||
|
||||
fn writer_loop<S: StorageIO>(
|
||||
receiver: &flume::Receiver<WriterRequest>,
|
||||
writer: &mut EventLogWriter<S>,
|
||||
ctx: &WriterCtx<'_, S>,
|
||||
) {
|
||||
let _close = CloseOnDrop(ctx.pending_bytes);
|
||||
let _terminate = TerminateOnDrop {
|
||||
notify: ctx.notify,
|
||||
receiver,
|
||||
};
|
||||
let mut reorder = ReorderBuffer::new(writer.current_seq().next().raw());
|
||||
|
||||
loop {
|
||||
|
||||
@@ -26,7 +26,10 @@ use crate::blockstore::BlocksSynced;
|
||||
use crate::fsync_order::PostBlockstoreHook;
|
||||
use crate::io::StorageIO;
|
||||
|
||||
use commit_loop::{CommitThread, FreezeResponse, PendingBytesBudget, WriterNotify, WriterRequest};
|
||||
use commit_loop::{
|
||||
CommitThread, FreezeResponse, PendingBytesBudget, WriterNotify, WriterRequest,
|
||||
writer_terminated,
|
||||
};
|
||||
|
||||
pub use bridge::{DeferredBroadcast, EventLogBridge};
|
||||
pub use manager::{SEGMENT_FILE_EXTENSION, SegmentManager, parse_segment_id, segment_path};
|
||||
@@ -51,6 +54,7 @@ pub use writer::{EventLogWriter, SyncResult};
|
||||
|
||||
const DEFAULT_BROADCAST_BUFFER: usize = 16384;
|
||||
pub const DEFAULT_PENDING_BYTES_BUDGET: u64 = 1024 * 1024 * 1024;
|
||||
const WRITER_RESPONSE_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
pub struct EventWithMutations {
|
||||
pub event: SequencedEvent,
|
||||
@@ -187,7 +191,7 @@ impl<S: StorageIO + 'static> EventLog<S> {
|
||||
self.commit_thread
|
||||
.sender()
|
||||
.send(WriterRequest::Append(event))
|
||||
.map_err(|_| io::Error::other("eventlog writer thread terminated"))
|
||||
.map_err(|_| writer_terminated())
|
||||
}
|
||||
|
||||
pub fn append_event(
|
||||
@@ -261,10 +265,24 @@ impl<S: StorageIO + 'static> EventLog<S> {
|
||||
self.commit_thread
|
||||
.sender()
|
||||
.send(WriterRequest::SyncBarrier { response: resp_tx })
|
||||
.map_err(|_| io::Error::other("eventlog writer thread terminated"))?;
|
||||
resp_rx
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("eventlog writer thread terminated"))?
|
||||
.map_err(|_| writer_terminated())?;
|
||||
self.await_writer_response(&resp_rx)
|
||||
}
|
||||
|
||||
fn await_writer_response<T>(&self, resp_rx: &flume::Receiver<io::Result<T>>) -> io::Result<T> {
|
||||
loop {
|
||||
match resp_rx.recv_timeout(WRITER_RESPONSE_POLL) {
|
||||
Ok(result) => return result,
|
||||
Err(flume::RecvTimeoutError::Disconnected) => {
|
||||
return Err(writer_terminated());
|
||||
}
|
||||
Err(flume::RecvTimeoutError::Timeout) => {
|
||||
if self.notify.is_terminated() {
|
||||
return Err(writer_terminated());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_events_since(
|
||||
@@ -432,11 +450,9 @@ impl<S: StorageIO + 'static> EventLog<S> {
|
||||
response: resp_tx,
|
||||
resume: resume_rx,
|
||||
})
|
||||
.map_err(|_| io::Error::other("eventlog writer thread terminated"))?;
|
||||
.map_err(|_| writer_terminated())?;
|
||||
|
||||
let freeze_resp: FreezeResponse = resp_rx
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("eventlog writer thread terminated"))??;
|
||||
let freeze_resp: FreezeResponse = self.await_writer_response(&resp_rx)?;
|
||||
|
||||
let all_segments = self.manager.list_segments()?;
|
||||
let sealed_segments: Vec<SegmentId> = all_segments
|
||||
|
||||
@@ -3,18 +3,17 @@ use std::sync::Arc;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
use crate::io::{FileId, StorageIO};
|
||||
|
||||
use super::manager::SegmentManager;
|
||||
use super::segment_file::{
|
||||
SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SegmentWriter, ValidEvent, ValidateEventRecord,
|
||||
validate_event_record,
|
||||
ReadEventRecord, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SegmentReader, SegmentWriter, ValidEvent,
|
||||
ValidateEventRecord, validate_event_record,
|
||||
};
|
||||
use super::segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment};
|
||||
use super::segment_index::{SegmentIndex, rebuild_from_segment};
|
||||
use super::sidecar::build_sidecar_from_segment;
|
||||
use super::types::{
|
||||
DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros,
|
||||
};
|
||||
use super::types::{DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset};
|
||||
|
||||
const VALIDATE_RETRY_ATTEMPTS: u32 = 32;
|
||||
|
||||
@@ -240,6 +239,16 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
did_hash: DidHash,
|
||||
event_type: EventTypeTag,
|
||||
payload: Vec<u8>,
|
||||
) -> io::Result<EventSequence> {
|
||||
self.append_with_clock(did_hash, event_type, payload, &SystemClock)
|
||||
}
|
||||
|
||||
pub fn append_with_clock<C: Clock>(
|
||||
&mut self,
|
||||
did_hash: DidHash,
|
||||
event_type: EventTypeTag,
|
||||
payload: Vec<u8>,
|
||||
clock: &C,
|
||||
) -> io::Result<EventSequence> {
|
||||
let payload_len = u32::try_from(payload.len())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "payload exceeds u32::MAX"))?;
|
||||
@@ -252,7 +261,7 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
}
|
||||
|
||||
let seq = self.next_seq;
|
||||
let timestamp = TimestampMicros::now();
|
||||
let timestamp = clock.unix_micros();
|
||||
|
||||
let event = ValidEvent {
|
||||
seq,
|
||||
@@ -497,24 +506,31 @@ fn find_last_seq_from_segments<S: StorageIO>(
|
||||
if acc.is_some() {
|
||||
return Ok(acc);
|
||||
}
|
||||
|
||||
match SegmentIndex::load(manager.io(), &manager.index_path(seg_id)) {
|
||||
Ok(Some(idx)) => Ok(idx.last_seq()),
|
||||
Err(e) if e.kind() != io::ErrorKind::InvalidData => Err(e),
|
||||
_ => {
|
||||
let handle = manager.open_for_read(seg_id)?;
|
||||
let (_, last_seq) = rebuild_from_segment(
|
||||
manager.io(),
|
||||
handle.fd(),
|
||||
DEFAULT_INDEX_INTERVAL,
|
||||
max_payload,
|
||||
)?;
|
||||
Ok(last_seq)
|
||||
}
|
||||
}
|
||||
last_seq_in_segment_file(manager, seg_id, max_payload)
|
||||
})
|
||||
}
|
||||
|
||||
fn last_seq_in_segment_file<S: StorageIO>(
|
||||
manager: &SegmentManager<S>,
|
||||
seg_id: SegmentId,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Option<EventSequence>> {
|
||||
let handle = manager.open_for_read(seg_id)?;
|
||||
SegmentReader::open(manager.io(), handle.fd(), max_payload)?
|
||||
.map(|record| {
|
||||
record.map(|record| match record {
|
||||
ReadEventRecord::Valid { event, .. } => Some(event.seq),
|
||||
ReadEventRecord::Corrupted { .. } | ReadEventRecord::Truncated { .. } => None,
|
||||
})
|
||||
})
|
||||
.scan((), |(), result| match result {
|
||||
Err(e) => Some(Err(e)),
|
||||
Ok(Some(seq)) => Some(Ok(seq)),
|
||||
Ok(None) => None,
|
||||
})
|
||||
.try_fold(None, |_, result| result.map(Some))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -603,6 +619,49 @@ mod tests {
|
||||
assert_eq!(writer.synced_seq(), EventSequence::new(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_uses_segment_file_tail_not_stale_sidecar_index() {
|
||||
let mgr = setup_manager(64 * 1024);
|
||||
let mut writer =
|
||||
EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD)
|
||||
.unwrap();
|
||||
|
||||
["squid", "anemone", "barnacle", "clam", "conch"]
|
||||
.iter()
|
||||
.for_each(|name| {
|
||||
append_test_event(&mut writer, &format!("did:plc:{name}"));
|
||||
});
|
||||
let synced = writer.sync().unwrap();
|
||||
assert_eq!(synced.synced_through, EventSequence::new(5));
|
||||
|
||||
let index_path = mgr.index_path(SegmentId::new(1));
|
||||
let mut stale = SegmentIndex::new();
|
||||
stale.record(
|
||||
EventSequence::new(1),
|
||||
SegmentOffset::new(SEGMENT_HEADER_SIZE as u64),
|
||||
);
|
||||
stale.record(
|
||||
EventSequence::new(3),
|
||||
SegmentOffset::new(SEGMENT_HEADER_SIZE as u64 + 100),
|
||||
);
|
||||
stale.save(mgr.io(), &index_path).unwrap();
|
||||
assert_eq!(
|
||||
SegmentIndex::load(mgr.io(), &index_path)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_seq(),
|
||||
Some(EventSequence::new(3)),
|
||||
);
|
||||
|
||||
let last =
|
||||
find_last_seq_from_segments(&mgr, &[SegmentId::new(1)], MAX_EVENT_PAYLOAD).unwrap();
|
||||
assert_eq!(
|
||||
last,
|
||||
Some(EventSequence::new(5)),
|
||||
"recovery must read the durable segment file tail, not a lagging sidecar index"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_without_pending_is_noop() {
|
||||
let mgr = setup_manager(64 * 1024);
|
||||
|
||||
@@ -6,6 +6,7 @@ use jacquard_repo::mst::NodeData;
|
||||
use super::oracle::{hex_short, try_cid_to_fixed};
|
||||
use crate::StorageIO;
|
||||
use crate::blockstore::{CidBytes, TranquilBlockStore};
|
||||
use crate::clock::Clock;
|
||||
|
||||
pub enum LookupResult {
|
||||
Found(Cid),
|
||||
@@ -13,8 +14,8 @@ pub enum LookupResult {
|
||||
LostPath,
|
||||
}
|
||||
|
||||
pub fn walk_mst_node_cids_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
pub fn walk_mst_node_cids_tolerant<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
root: Cid,
|
||||
lost: &HashSet<CidBytes>,
|
||||
) -> Result<Vec<CidBytes>, String> {
|
||||
@@ -44,8 +45,8 @@ pub fn walk_mst_node_cids_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn mst_get_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
pub fn mst_get_tolerant<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
root: Cid,
|
||||
target: &str,
|
||||
lost: &HashSet<CidBytes>,
|
||||
@@ -76,8 +77,54 @@ pub fn mst_get_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_node<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
pub fn walk_mst_entries_tolerant<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
root: Cid,
|
||||
lost: &HashSet<CidBytes>,
|
||||
) -> Result<Option<Vec<(String, CidBytes)>>, String> {
|
||||
let mut to_visit: Vec<Cid> = vec![root];
|
||||
let mut visited: HashSet<CidBytes> = HashSet::new();
|
||||
let mut entries: Vec<(String, CidBytes)> = Vec::new();
|
||||
|
||||
while let Some(cid) = to_visit.pop() {
|
||||
let cid_bytes = try_cid_to_fixed(&cid).map_err(|e| format!("cid format: {e}"))?;
|
||||
if !visited.insert(cid_bytes) {
|
||||
continue;
|
||||
}
|
||||
if lost.contains(&cid_bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
let node = match store.get_block_sync(&cid_bytes) {
|
||||
Ok(Some(bytes)) => match serde_ipld_dagcbor::from_slice::<NodeData>(&bytes) {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Ok(None),
|
||||
},
|
||||
Ok(None) => return Ok(None),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let keys = full_keys(&node)?;
|
||||
keys.iter().zip(node.entries.iter()).try_for_each(
|
||||
|(key, entry)| -> Result<(), String> {
|
||||
let value =
|
||||
try_cid_to_fixed(&entry.value).map_err(|e| format!("cid format: {e}"))?;
|
||||
entries.push((key.clone(), value));
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
if let Some(left) = node.left {
|
||||
to_visit.push(left);
|
||||
}
|
||||
node.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.tree)
|
||||
.for_each(|t| to_visit.push(t));
|
||||
}
|
||||
|
||||
Ok(Some(entries))
|
||||
}
|
||||
|
||||
fn read_node<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
cid_bytes: &CidBytes,
|
||||
) -> Result<NodeData, String> {
|
||||
let bytes = match store.get_block_sync(cid_bytes) {
|
||||
|
||||
@@ -10,8 +10,9 @@ use super::oracle::{Oracle, hex_short, try_cid_to_fixed};
|
||||
use crate::blockstore::{
|
||||
BLOCK_HEADER_SIZE, CidBytes, CompactionError, TranquilBlockStore, hash_to_cid_bytes,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::eventlog::{EventSequence, SegmentId};
|
||||
use crate::io::{RealIO, StorageIO};
|
||||
use crate::io::StorageIO;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvariantSet(u32);
|
||||
@@ -34,6 +35,7 @@ impl InvariantSet {
|
||||
pub const INDEX_BACKED_BY_DISK: Self = Self(1 << 13);
|
||||
pub const HINT_BACKED_BY_DATA: Self = Self(1 << 14);
|
||||
pub const INDEX_BLOCKS_READABLE: Self = Self(1 << 15);
|
||||
pub const MST_REPAIRABLE: Self = Self(1 << 16);
|
||||
|
||||
const ALL_KNOWN: u32 = Self::REFCOUNT_CONSERVATION.0
|
||||
| Self::REACHABILITY.0
|
||||
@@ -50,7 +52,8 @@ impl InvariantSet {
|
||||
| Self::TOMBSTONE_BOUND.0
|
||||
| Self::INDEX_BACKED_BY_DISK.0
|
||||
| Self::HINT_BACKED_BY_DATA.0
|
||||
| Self::INDEX_BLOCKS_READABLE.0;
|
||||
| Self::INDEX_BLOCKS_READABLE.0
|
||||
| Self::MST_REPAIRABLE.0;
|
||||
|
||||
pub const fn contains(self, other: Self) -> bool {
|
||||
(self.0 & other.0) == other.0
|
||||
@@ -100,28 +103,28 @@ pub struct EventLogSnapshot {
|
||||
pub segment_last_ts: Vec<(SegmentId, u64)>,
|
||||
}
|
||||
|
||||
pub struct InvariantCtx<'a, S: StorageIO + Send + Sync + 'static = RealIO> {
|
||||
pub store: &'a Arc<TranquilBlockStore<S>>,
|
||||
pub struct InvariantCtx<'a, S: StorageIO + Send + Sync + 'static, C: Clock> {
|
||||
pub store: &'a Arc<TranquilBlockStore<S, C>>,
|
||||
pub oracle: &'a Oracle,
|
||||
pub root: Option<Cid>,
|
||||
pub eventlog: Option<&'a EventLogSnapshot>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Invariant<S: StorageIO + Send + Sync + 'static>: Send + Sync {
|
||||
pub trait Invariant<S: StorageIO + Send + Sync + 'static, C: Clock>: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation>;
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation>;
|
||||
}
|
||||
|
||||
pub struct RefcountConservation;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for RefcountConservation {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for RefcountConservation {
|
||||
fn name(&self) -> &'static str {
|
||||
"RefcountConservation"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let live: Vec<(String, CidBytes)> = ctx.oracle.live_cids_labeled();
|
||||
let live_set: HashSet<CidBytes> = live.iter().map(|(_, c)| *c).collect();
|
||||
let index: HashMap<CidBytes, u32> = ctx
|
||||
@@ -143,7 +146,7 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for RefcountConservation
|
||||
|
||||
let inverse: Vec<String> = index
|
||||
.iter()
|
||||
.filter(|(cid, _)| !live_set.contains(*cid))
|
||||
.filter(|(cid, _)| !live_set.contains(*cid) && !ctx.oracle.lost_blocks().contains(*cid))
|
||||
.map(|(cid, r)| format!("orphan cid {} refcount {}", hex_short(cid), r))
|
||||
.collect();
|
||||
|
||||
@@ -162,12 +165,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for RefcountConservation
|
||||
pub struct Reachability;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for Reachability {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for Reachability {
|
||||
fn name(&self) -> &'static str {
|
||||
"Reachability"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let violations: Vec<String> = ctx
|
||||
.oracle
|
||||
.live_cids_labeled()
|
||||
@@ -193,12 +196,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for Reachability {
|
||||
pub struct AckedWritePersistence;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for AckedWritePersistence {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for AckedWritePersistence {
|
||||
fn name(&self) -> &'static str {
|
||||
"AckedWritePersistence"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(root) = ctx.root else {
|
||||
if ctx.oracle.live_count() == 0 {
|
||||
return Ok(());
|
||||
@@ -241,12 +244,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for AckedWritePersistenc
|
||||
pub struct ReadAfterWrite;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ReadAfterWrite {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for ReadAfterWrite {
|
||||
fn name(&self) -> &'static str {
|
||||
"ReadAfterWrite"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(root) = ctx.root else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -292,15 +295,71 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ReadAfterWrite {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MstRepairable;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for MstRepairable {
|
||||
fn name(&self) -> &'static str {
|
||||
"MstRepairable"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(root) = ctx.root else {
|
||||
if ctx.oracle.live_count() == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(InvariantViolation {
|
||||
invariant: "MstRepairable",
|
||||
detail: format!(
|
||||
"oracle has {} live records but store has no root after repair",
|
||||
ctx.oracle.live_count()
|
||||
),
|
||||
});
|
||||
};
|
||||
let mst = Mst::load(ctx.store.clone(), root, None);
|
||||
let entries: Vec<(String, CidBytes)> = ctx
|
||||
.oracle
|
||||
.live_records()
|
||||
.map(|(c, r, v)| (format!("{}/{}", c.0, r.0), *v))
|
||||
.collect();
|
||||
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
for (key, expected) in &entries {
|
||||
match mst.get(key).await {
|
||||
Ok(Some(cid)) => match try_cid_to_fixed(&cid) {
|
||||
Ok(actual) if actual == *expected => {}
|
||||
Ok(actual) => violations.push(format!(
|
||||
"{key}: MST cid {} != oracle cid {} after repair",
|
||||
hex_short(&actual),
|
||||
hex_short(expected),
|
||||
)),
|
||||
Err(e) => violations.push(format!("{key}: unexpected CID format: {e}")),
|
||||
},
|
||||
Ok(None) => violations.push(format!("{key}: MST returned None after repair")),
|
||||
Err(e) => violations.push(format!("{key}: mst.get error after repair: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
if violations.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvariantViolation {
|
||||
invariant: "MstRepairable",
|
||||
detail: violations.join("; "),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CompactionIdempotent;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for CompactionIdempotent {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for CompactionIdempotent {
|
||||
fn name(&self) -> &'static str {
|
||||
"CompactionIdempotent"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store_a = ctx.store.clone();
|
||||
let first = tokio::task::spawn_blocking(move || compact_by_liveness(&store_a))
|
||||
.await
|
||||
@@ -348,8 +407,8 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for CompactionIdempotent
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &Arc<TranquilBlockStore<S>>,
|
||||
fn snapshot<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &Arc<TranquilBlockStore<S, C>>,
|
||||
) -> Vec<(CidBytes, u32)> {
|
||||
let mut v: Vec<(CidBytes, u32)> = store
|
||||
.block_index()
|
||||
@@ -363,8 +422,8 @@ fn snapshot<S: StorageIO + Send + Sync + 'static>(
|
||||
|
||||
const COMPACT_LIVENESS_CEILING: f64 = 0.99;
|
||||
|
||||
fn compact_by_liveness<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
fn compact_by_liveness<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &TranquilBlockStore<S, C>,
|
||||
) -> Result<(), String> {
|
||||
let liveness = store
|
||||
.compaction_liveness(0)
|
||||
@@ -386,12 +445,12 @@ fn compact_by_liveness<S: StorageIO + Send + Sync + 'static>(
|
||||
pub struct HintBackedByData;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for HintBackedByData {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for HintBackedByData {
|
||||
fn name(&self) -> &'static str {
|
||||
"HintBackedByData"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let data: std::collections::HashSet<_> = store_c
|
||||
@@ -435,17 +494,19 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for HintBackedByData {
|
||||
pub struct IndexBlocksReadable;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBlocksReadable {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for IndexBlocksReadable {
|
||||
fn name(&self) -> &'static str {
|
||||
"IndexBlocksReadable"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let lost = ctx.oracle.lost_blocks().clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let entries = store_c.block_index().live_entries_snapshot();
|
||||
let unreadable: Vec<String> = entries
|
||||
.iter()
|
||||
.filter(|(cid, _)| !lost.contains(cid))
|
||||
.take(INDEX_READABLE_SAMPLE_CAP)
|
||||
.filter_map(|(cid, _)| match store_c.get_block_sync(cid) {
|
||||
Ok(Some(_)) => None,
|
||||
@@ -490,12 +551,12 @@ const INDEX_READABLE_REPORT_CAP: usize = 20;
|
||||
pub struct IndexBackedByDisk;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBackedByDisk {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for IndexBackedByDisk {
|
||||
fn name(&self) -> &'static str {
|
||||
"IndexBackedByDisk"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let disk: std::collections::HashSet<_> = store_c
|
||||
@@ -544,12 +605,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBackedByDisk {
|
||||
pub struct NoOrphanFiles;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for NoOrphanFiles {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for NoOrphanFiles {
|
||||
fn name(&self) -> &'static str {
|
||||
"NoOrphanFiles"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let disk = store_c.list_data_files().map_err(|e| e.to_string())?;
|
||||
@@ -606,12 +667,12 @@ impl Default for ByteBudget {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ByteBudget {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for ByteBudget {
|
||||
fn name(&self) -> &'static str {
|
||||
"ByteBudget"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store = ctx.store.clone();
|
||||
let factor = self.overhead_factor;
|
||||
let floor = self.floor_bytes;
|
||||
@@ -643,12 +704,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ByteBudget {
|
||||
pub struct ManifestEqualsReality;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ManifestEqualsReality {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for ManifestEqualsReality {
|
||||
fn name(&self) -> &'static str {
|
||||
"ManifestEqualsReality"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let store = ctx.store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let listed = store.list_data_files().map_err(|e| e.to_string())?;
|
||||
@@ -715,12 +776,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ManifestEqualsRealit
|
||||
pub struct ChecksumCoverage;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ChecksumCoverage {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for ChecksumCoverage {
|
||||
fn name(&self) -> &'static str {
|
||||
"ChecksumCoverage"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let violations: Vec<String> = ctx
|
||||
.oracle
|
||||
.live_cids_labeled()
|
||||
@@ -761,12 +822,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ChecksumCoverage {
|
||||
pub struct MonotonicSeq;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for MonotonicSeq {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for MonotonicSeq {
|
||||
fn name(&self) -> &'static str {
|
||||
"MonotonicSeq"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(el) = ctx.eventlog else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -819,12 +880,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for MonotonicSeq {
|
||||
pub struct FsyncOrdering;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for FsyncOrdering {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for FsyncOrdering {
|
||||
fn name(&self) -> &'static str {
|
||||
"FsyncOrdering"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(el) = ctx.eventlog else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -841,21 +902,37 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for FsyncOrdering {
|
||||
if !missing.is_empty() {
|
||||
let mut sorted = missing;
|
||||
sorted.sort_unstable();
|
||||
let ts_by_seq: HashMap<u64, u64> = ctx
|
||||
.oracle
|
||||
.synced_events()
|
||||
.iter()
|
||||
.map(|e| (e.seq.raw(), e.timestamp_us))
|
||||
.collect();
|
||||
let cutoff = ctx.oracle.last_retention_cutoff_us();
|
||||
let detail: Vec<String> = sorted
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|seq| {
|
||||
let ts = ts_by_seq.get(seq).copied().unwrap_or(0);
|
||||
let below = cutoff.is_some_and(|c| ts < c);
|
||||
format!("seq {seq} ts {ts} below_cutoff {below}")
|
||||
})
|
||||
.collect();
|
||||
violations.push(format!(
|
||||
"{} acked events lost on disk, lowest missing seq {}",
|
||||
"{} acked events lost on disk, cutoff {cutoff:?}: {}",
|
||||
sorted.len(),
|
||||
sorted[0]
|
||||
detail.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(last_synced) = ctx.oracle.last_synced_seq()
|
||||
let retained_synced_max = ctx.oracle.synced_events().iter().map(|e| e.seq.raw()).max();
|
||||
if let Some(expected) = retained_synced_max
|
||||
&& el.synced_seq.raw() != 0
|
||||
&& el.synced_seq.raw() < last_synced.raw()
|
||||
&& el.synced_seq.raw() < expected
|
||||
{
|
||||
violations.push(format!(
|
||||
"writer synced_seq {} below oracle last_synced_seq {}",
|
||||
el.synced_seq.raw(),
|
||||
last_synced.raw()
|
||||
"writer synced_seq {} below retained acked seq {expected}",
|
||||
el.synced_seq.raw()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -873,12 +950,12 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for FsyncOrdering {
|
||||
pub struct TombstoneBound;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for TombstoneBound {
|
||||
impl<S: StorageIO + Send + Sync + 'static, C: Clock> Invariant<S, C> for TombstoneBound {
|
||||
fn name(&self) -> &'static str {
|
||||
"TombstoneBound"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S, C>) -> Result<(), InvariantViolation> {
|
||||
let Some(el) = ctx.eventlog else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -887,11 +964,14 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for TombstoneBound {
|
||||
};
|
||||
|
||||
let active = el.segments.last().copied();
|
||||
let retention_active = ctx.oracle.last_retention_active_segment();
|
||||
|
||||
let stale: Vec<String> = el
|
||||
.segment_last_ts
|
||||
.iter()
|
||||
.filter(|(id, last_ts)| Some(*id) != active && *last_ts < cutoff_us)
|
||||
.filter(|(id, last_ts)| {
|
||||
Some(*id) != active && Some(*id) != retention_active && *last_ts < cutoff_us
|
||||
})
|
||||
.map(|(id, last_ts)| format!("segment {id} last_ts {last_ts} < cutoff {cutoff_us}"))
|
||||
.collect();
|
||||
|
||||
@@ -906,15 +986,15 @@ impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for TombstoneBound {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invariants_for<S: StorageIO + Send + Sync + 'static>(
|
||||
pub fn invariants_for<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
set: InvariantSet,
|
||||
) -> Vec<Box<dyn Invariant<S>>> {
|
||||
) -> Vec<Box<dyn Invariant<S, C>>> {
|
||||
let unknown = set.unknown_bits();
|
||||
assert!(
|
||||
unknown == 0,
|
||||
"invariants_for: unknown InvariantSet bits 0x{unknown:x}; all bits must map to an impl"
|
||||
);
|
||||
let candidates: Vec<(InvariantSet, Box<dyn Invariant<S>>)> = vec![
|
||||
let candidates: Vec<(InvariantSet, Box<dyn Invariant<S, C>>)> = vec![
|
||||
(
|
||||
InvariantSet::REFCOUNT_CONSERVATION,
|
||||
Box::new(RefcountConservation),
|
||||
@@ -925,6 +1005,7 @@ pub fn invariants_for<S: StorageIO + Send + Sync + 'static>(
|
||||
Box::new(AckedWritePersistence),
|
||||
),
|
||||
(InvariantSet::READ_AFTER_WRITE, Box::new(ReadAfterWrite)),
|
||||
(InvariantSet::MST_REPAIRABLE, Box::new(MstRepairable)),
|
||||
(
|
||||
InvariantSet::COMPACTION_IDEMPOTENT,
|
||||
Box::new(CompactionIdempotent),
|
||||
|
||||
@@ -7,6 +7,7 @@ use tracing::warn;
|
||||
|
||||
use super::runner::{EventLogState, Harness};
|
||||
use crate::blockstore::TranquilBlockStore;
|
||||
use crate::clock::Clock;
|
||||
use crate::io::StorageIO;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
@@ -94,8 +95,8 @@ impl MetricName {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sample_harness<S: StorageIO + Send + Sync + 'static>(
|
||||
harness: &Harness<S>,
|
||||
pub fn sample_harness<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
harness: &Harness<S, C>,
|
||||
elapsed: Duration,
|
||||
) -> MetricsSample {
|
||||
MetricsSample {
|
||||
@@ -116,8 +117,8 @@ pub fn sample_harness<S: StorageIO + Send + Sync + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn data_file_count<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &Arc<TranquilBlockStore<S>>,
|
||||
fn data_file_count<S: StorageIO + Send + Sync + 'static, C: Clock>(
|
||||
store: &Arc<TranquilBlockStore<S, C>>,
|
||||
) -> Option<u64> {
|
||||
match store.list_data_files() {
|
||||
Ok(v) => Some(v.len() as u64),
|
||||
|
||||
@@ -24,8 +24,8 @@ pub use invariants::{
|
||||
pub use leak::{LeakGateBuildError, LeakGateConfig, LeakViolation, evaluate as evaluate_leak_gate};
|
||||
pub use metrics::{MetricName, MetricsSample, sample_harness};
|
||||
pub use op::{
|
||||
CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
|
||||
RetentionSecs, Seed, ValueSeed,
|
||||
AdvanceNanos, CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed,
|
||||
RecordKey, RetentionSecs, Seed, ValueSeed,
|
||||
};
|
||||
pub use oracle::{EventExpectation, Oracle};
|
||||
pub use overrides::{ConfigOverrides, GroupCommitOverrides, StoreOverrides};
|
||||
@@ -42,6 +42,6 @@ pub use soak::{
|
||||
SoakEvent, SoakReport, run_soak,
|
||||
};
|
||||
pub use workload::{
|
||||
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
|
||||
ValueBytes, WorkloadModel,
|
||||
AdvanceMaxSecs, ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs,
|
||||
SizeDistribution, ValueBytes, WorkloadModel,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct PayloadSeed(pub u32);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RetentionSecs(pub u32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct AdvanceNanos(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum EventKind {
|
||||
Commit,
|
||||
@@ -54,6 +57,9 @@ pub enum Op {
|
||||
RunRetention {
|
||||
max_age_secs: RetentionSecs,
|
||||
},
|
||||
AdvanceTime {
|
||||
by: AdvanceNanos,
|
||||
},
|
||||
ReadRecord {
|
||||
collection: CollectionName,
|
||||
rkey: RecordKey,
|
||||
@@ -61,6 +67,7 @@ pub enum Op {
|
||||
ReadBlock {
|
||||
value_seed: ValueSeed,
|
||||
},
|
||||
MstList,
|
||||
ExternalDeleteDataFile {
|
||||
choice: FileChoice,
|
||||
},
|
||||
@@ -68,7 +75,10 @@ pub enum Op {
|
||||
|
||||
impl Op {
|
||||
pub const fn is_read_only(&self) -> bool {
|
||||
matches!(self, Op::ReadRecord { .. } | Op::ReadBlock { .. })
|
||||
matches!(
|
||||
self,
|
||||
Op::ReadRecord { .. } | Op::ReadBlock { .. } | Op::MstList
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use cid::Cid;
|
||||
|
||||
use super::op::{CollectionName, EventKind, RecordKey};
|
||||
use crate::blockstore::CidBytes;
|
||||
use crate::eventlog::EventSequence;
|
||||
use crate::eventlog::{EventSequence, SegmentId};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
#[error("unexpected CID encoding: got {actual} bytes, expected 36 for sha256 CIDv1")]
|
||||
@@ -18,6 +18,7 @@ pub struct EventExpectation {
|
||||
pub timestamp_us: u64,
|
||||
pub kind: EventKind,
|
||||
pub did_hash: u32,
|
||||
pub segment: SegmentId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -29,6 +30,7 @@ pub struct Oracle {
|
||||
unsynced_events: Vec<EventExpectation>,
|
||||
last_synced_seq: Option<EventSequence>,
|
||||
last_retention_cutoff_us: Option<u64>,
|
||||
last_retention_active_segment: Option<SegmentId>,
|
||||
lost_blocks: HashSet<CidBytes>,
|
||||
}
|
||||
|
||||
@@ -54,6 +56,10 @@ impl Oracle {
|
||||
self.live.contains_key(&(coll.clone(), rkey.clone()))
|
||||
}
|
||||
|
||||
pub fn expected_record_cid(&self, coll: &CollectionName, rkey: &RecordKey) -> Option<CidBytes> {
|
||||
self.live.get(&(coll.clone(), rkey.clone())).copied()
|
||||
}
|
||||
|
||||
pub fn set_root(&mut self, root: Cid) {
|
||||
self.current_root = Some(root);
|
||||
}
|
||||
@@ -129,9 +135,18 @@ impl Oracle {
|
||||
self.unsynced_events.clear();
|
||||
}
|
||||
|
||||
pub fn record_retention(&mut self, cutoff_us: u64) {
|
||||
pub fn forget_events_in_segments(&mut self, lost: &HashSet<SegmentId>) {
|
||||
if lost.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.synced_events.retain(|e| !lost.contains(&e.segment));
|
||||
self.unsynced_events.retain(|e| !lost.contains(&e.segment));
|
||||
}
|
||||
|
||||
pub fn record_retention(&mut self, cutoff_us: u64, active_segment: Option<SegmentId>) {
|
||||
self.synced_events.retain(|e| e.timestamp_us >= cutoff_us);
|
||||
self.last_retention_cutoff_us = Some(cutoff_us);
|
||||
self.last_retention_active_segment = active_segment;
|
||||
}
|
||||
|
||||
pub fn synced_events(&self) -> &[EventExpectation] {
|
||||
@@ -149,6 +164,10 @@ impl Oracle {
|
||||
pub fn last_retention_cutoff_us(&self) -> Option<u64> {
|
||||
self.last_retention_cutoff_us
|
||||
}
|
||||
|
||||
pub fn last_retention_active_segment(&self) -> Option<SegmentId> {
|
||||
self.last_retention_active_segment
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_cid_to_fixed(cid: &Cid) -> Result<CidBytes, CidFormatError> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::runner::{
|
||||
GauntletConfig, IoBackend, MaxFileSize, OpInterval, RestartPolicy, RunLimits, ShardCount,
|
||||
WallMs, WriterConcurrency,
|
||||
};
|
||||
use super::workload::{KeySpaceSize, OpCount, SizeDistribution, ValueBytes};
|
||||
use super::workload::{AdvanceMaxSecs, KeySpaceSize, OpCount, SizeDistribution, ValueBytes};
|
||||
use crate::sim::FaultConfig;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
@@ -26,6 +26,10 @@ pub struct ConfigOverrides {
|
||||
pub fault_density_uniform: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restart_every_n_ops: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub advance_time: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub advance_max_secs: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "StoreOverrides::is_empty")]
|
||||
pub store: StoreOverrides,
|
||||
}
|
||||
@@ -110,6 +114,12 @@ impl ConfigOverrides {
|
||||
RestartPolicy::EveryNOps(OpInterval(n))
|
||||
};
|
||||
}
|
||||
if let Some(n) = self.advance_time {
|
||||
cfg.workload.weights.advance_time = n;
|
||||
}
|
||||
if let Some(n) = self.advance_max_secs {
|
||||
cfg.workload.advance_max_secs = AdvanceMaxSecs(n.max(1));
|
||||
}
|
||||
if let Some(n) = self.store.max_file_size {
|
||||
cfg.store.max_file_size = MaxFileSize(n);
|
||||
}
|
||||
@@ -225,6 +235,22 @@ mod tests {
|
||||
assert!(matches!(cfg.io, IoBackend::Real));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_time_overrides_inject_time_travel() {
|
||||
use crate::gauntlet::op::Seed;
|
||||
use crate::gauntlet::scenarios::{Scenario, config_for};
|
||||
let mut cfg = config_for(Scenario::FirehoseFanout, Seed(1));
|
||||
assert_eq!(cfg.workload.weights.advance_time, 0);
|
||||
let o = ConfigOverrides {
|
||||
advance_time: Some(40),
|
||||
advance_max_secs: Some(1_209_600),
|
||||
..ConfigOverrides::default()
|
||||
};
|
||||
o.apply_to(&mut cfg);
|
||||
assert_eq!(cfg.workload.weights.advance_time, 40);
|
||||
assert_eq!(cfg.workload.advance_max_secs.0, 1_209_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_density_uniform_forces_simulated_backend() {
|
||||
use crate::gauntlet::op::Seed;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ use super::runner::{
|
||||
RestartPolicy, RunLimits, ShardCount, StoreConfig, WallMs, WriterConcurrency,
|
||||
};
|
||||
use super::workload::{
|
||||
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
|
||||
ValueBytes, WorkloadModel,
|
||||
AdvanceMaxSecs, ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs,
|
||||
SizeDistribution, ValueBytes, WorkloadModel,
|
||||
};
|
||||
use crate::blockstore::{GroupCommitConfig, MAX_BLOCK_SIZE};
|
||||
use crate::sim::FaultConfig;
|
||||
@@ -26,12 +26,18 @@ pub enum Scenario {
|
||||
ModerateFaults,
|
||||
AggressiveFaults,
|
||||
TornPages,
|
||||
MisdirectedWrites,
|
||||
Fsyncgate,
|
||||
FirehoseFanout,
|
||||
ContendedReaders,
|
||||
ContendedWriters,
|
||||
FlakyDevice,
|
||||
ExternalCorruption,
|
||||
RetentionTimeTravel,
|
||||
EventlogTimeTravelChaos,
|
||||
BlockChurnRecoverable,
|
||||
ReadCorruption,
|
||||
InlineCommit,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
@@ -49,12 +55,18 @@ impl Scenario {
|
||||
Self::ModerateFaults => "ModerateFaults",
|
||||
Self::AggressiveFaults => "AggressiveFaults",
|
||||
Self::TornPages => "TornPages",
|
||||
Self::MisdirectedWrites => "MisdirectedWrites",
|
||||
Self::Fsyncgate => "Fsyncgate",
|
||||
Self::FirehoseFanout => "FirehoseFanout",
|
||||
Self::ContendedReaders => "ContendedReaders",
|
||||
Self::ContendedWriters => "ContendedWriters",
|
||||
Self::FlakyDevice => "FlakyDevice",
|
||||
Self::ExternalCorruption => "ExternalCorruption",
|
||||
Self::RetentionTimeTravel => "RetentionTimeTravel",
|
||||
Self::EventlogTimeTravelChaos => "EventlogTimeTravelChaos",
|
||||
Self::BlockChurnRecoverable => "BlockChurnRecoverable",
|
||||
Self::ReadCorruption => "ReadCorruption",
|
||||
Self::InlineCommit => "InlineCommit",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +84,18 @@ impl Scenario {
|
||||
Self::ModerateFaults => "moderate-faults",
|
||||
Self::AggressiveFaults => "aggressive-faults",
|
||||
Self::TornPages => "torn-pages",
|
||||
Self::MisdirectedWrites => "misdirected-writes",
|
||||
Self::Fsyncgate => "fsyncgate",
|
||||
Self::FirehoseFanout => "firehose-fanout",
|
||||
Self::ContendedReaders => "contended-readers",
|
||||
Self::ContendedWriters => "contended-writers",
|
||||
Self::FlakyDevice => "flaky-device",
|
||||
Self::ExternalCorruption => "external-corruption",
|
||||
Self::RetentionTimeTravel => "retention-time-travel",
|
||||
Self::EventlogTimeTravelChaos => "eventlog-time-travel-chaos",
|
||||
Self::BlockChurnRecoverable => "block-churn-recoverable",
|
||||
Self::ReadCorruption => "read-corruption",
|
||||
Self::InlineCommit => "inline-commit",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +119,7 @@ impl Scenario {
|
||||
"Simulated IO with aggressive fault config. CrashAtSyscall restarts."
|
||||
}
|
||||
Self::TornPages => "Torn-page faults only, 20k ops.",
|
||||
Self::MisdirectedWrites => "Misdirected-write faults only, 20k ops.",
|
||||
Self::Fsyncgate => "Fsync-drop faults only, 10k ops.",
|
||||
Self::FirehoseFanout => {
|
||||
"Eventlog-heavy workload with FSYNC_ORDERING / MONOTONIC_SEQ / TOMBSTONE_BOUND invariants."
|
||||
@@ -115,6 +134,21 @@ impl Scenario {
|
||||
Self::ExternalCorruption => {
|
||||
"Rare external data-file deletion mid-workload. Validates phantom-purge self-heal under chaos."
|
||||
}
|
||||
Self::RetentionTimeTravel => {
|
||||
"Eventlog retention under logical time travel: random multi-day AdvanceTime jumps interleaved with append/sync/retention. TOMBSTONE_BOUND across fake weeks."
|
||||
}
|
||||
Self::EventlogTimeTravelChaos => {
|
||||
"Eventlog crash-recovery under logical time travel with recoverable faults. Expect 100% clean: any violation is a recovery bug, not the single-copy detection limit."
|
||||
}
|
||||
Self::BlockChurnRecoverable => {
|
||||
"Block-only churn under recoverable faults with crashes. Deterministic vehicle for refcount/reachability recovery bugs without the single-copy corruption-detection noise."
|
||||
}
|
||||
Self::ReadCorruption => {
|
||||
"Read-heavy workload under misdirected-read and bit-flip faults. Validates ReadRecord/ReadBlock results against the oracle at op time: the store must never serve content that does not match the requested address."
|
||||
}
|
||||
Self::InlineCommit => {
|
||||
"Synchronous inline group-commit on the real backend with persisted-block verification and frequent restarts. Drives the GroupCommitConfig synchronous + verify_persisted_blocks path that production single-writer commits use."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +173,18 @@ impl Scenario {
|
||||
Self::ModerateFaults,
|
||||
Self::AggressiveFaults,
|
||||
Self::TornPages,
|
||||
Self::MisdirectedWrites,
|
||||
Self::Fsyncgate,
|
||||
Self::FirehoseFanout,
|
||||
Self::ContendedReaders,
|
||||
Self::ContendedWriters,
|
||||
Self::FlakyDevice,
|
||||
Self::ExternalCorruption,
|
||||
Self::RetentionTimeTravel,
|
||||
Self::EventlogTimeTravelChaos,
|
||||
Self::BlockChurnRecoverable,
|
||||
Self::ReadCorruption,
|
||||
Self::InlineCommit,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -213,12 +253,18 @@ pub fn config_for(scenario: Scenario, seed: Seed) -> GauntletConfig {
|
||||
Scenario::ModerateFaults => moderate_faults(seed),
|
||||
Scenario::AggressiveFaults => aggressive_faults(seed),
|
||||
Scenario::TornPages => torn_pages(seed),
|
||||
Scenario::MisdirectedWrites => misdirected_writes(seed),
|
||||
Scenario::Fsyncgate => fsyncgate(seed),
|
||||
Scenario::FirehoseFanout => firehose_fanout(seed),
|
||||
Scenario::ContendedReaders => contended_readers(seed),
|
||||
Scenario::ContendedWriters => contended_writers(seed),
|
||||
Scenario::FlakyDevice => flaky_device(seed),
|
||||
Scenario::ExternalCorruption => external_corruption(seed),
|
||||
Scenario::RetentionTimeTravel => retention_time_travel(seed),
|
||||
Scenario::EventlogTimeTravelChaos => eventlog_time_travel_chaos(seed),
|
||||
Scenario::BlockChurnRecoverable => block_churn_recoverable(seed),
|
||||
Scenario::ReadCorruption => read_corruption(seed),
|
||||
Scenario::InlineCommit => inline_commit(seed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +297,7 @@ fn block_workload(
|
||||
key_space,
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +672,26 @@ fn torn_pages(seed: Seed) -> GauntletConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn misdirected_writes(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Simulated {
|
||||
fault: FaultConfig::misdirected_only(),
|
||||
},
|
||||
workload: sim_microbench_workload(),
|
||||
op_count: OpCount(20_000),
|
||||
invariants: InvariantSet::MST_REPAIRABLE,
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(5 * 60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::Never,
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fsyncgate(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
@@ -666,6 +733,7 @@ fn firehose_fanout(seed: Seed) -> GauntletConfig {
|
||||
key_space: KeySpaceSize(500),
|
||||
did_space: DidSpaceSize(64),
|
||||
retention_max_secs: RetentionMaxSecs(60),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants()
|
||||
@@ -706,6 +774,7 @@ fn contended_readers(seed: Seed) -> GauntletConfig {
|
||||
key_space: KeySpaceSize(400),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants(),
|
||||
@@ -773,6 +842,7 @@ fn contended_writers(seed: Seed) -> GauntletConfig {
|
||||
key_space: KeySpaceSize(1_000),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants(),
|
||||
@@ -815,3 +885,187 @@ fn external_corruption(seed: Seed) -> GauntletConfig {
|
||||
tolerate_op_errors: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn retention_time_travel(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Simulated {
|
||||
fault: FaultConfig::none(),
|
||||
},
|
||||
workload: WorkloadModel {
|
||||
weights: OpWeights {
|
||||
add: 20,
|
||||
compact: 3,
|
||||
checkpoint: 2,
|
||||
append_event: 30,
|
||||
sync_event_log: 15,
|
||||
run_retention: 10,
|
||||
advance_time: 20,
|
||||
..OpWeights::default()
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(96)),
|
||||
collections: default_collections(),
|
||||
key_space: KeySpaceSize(400),
|
||||
did_space: DidSpaceSize(64),
|
||||
retention_max_secs: RetentionMaxSecs(1_209_600),
|
||||
advance_max_secs: AdvanceMaxSecs(1_209_600),
|
||||
},
|
||||
op_count: OpCount(1_200),
|
||||
invariants: sim_invariants() | InvariantSet::MONOTONIC_SEQ | InvariantSet::TOMBSTONE_BOUND,
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(5 * 60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::EveryNOps(OpInterval(400)),
|
||||
store: sim_store(),
|
||||
eventlog: Some(EventLogConfig {
|
||||
max_segment_size: MaxSegmentSize(8 * 1024),
|
||||
}),
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn eventlog_time_travel_chaos(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Simulated {
|
||||
fault: FaultConfig::recoverable(),
|
||||
},
|
||||
workload: WorkloadModel {
|
||||
weights: OpWeights {
|
||||
add: 15,
|
||||
compact: 2,
|
||||
checkpoint: 3,
|
||||
append_event: 35,
|
||||
sync_event_log: 15,
|
||||
run_retention: 10,
|
||||
advance_time: 20,
|
||||
..OpWeights::default()
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(96)),
|
||||
collections: default_collections(),
|
||||
key_space: KeySpaceSize(400),
|
||||
did_space: DidSpaceSize(64),
|
||||
retention_max_secs: RetentionMaxSecs(604_800),
|
||||
advance_max_secs: AdvanceMaxSecs(259_200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants()
|
||||
| InvariantSet::MONOTONIC_SEQ
|
||||
| InvariantSet::FSYNC_ORDERING
|
||||
| InvariantSet::TOMBSTONE_BOUND,
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(10 * 60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::CrashAtSyscall(OpInterval(2_000)),
|
||||
store: sim_store(),
|
||||
eventlog: Some(EventLogConfig {
|
||||
max_segment_size: MaxSegmentSize(16 * 1024),
|
||||
}),
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn block_churn_recoverable(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Simulated {
|
||||
fault: FaultConfig::recoverable(),
|
||||
},
|
||||
workload: WorkloadModel {
|
||||
weights: OpWeights {
|
||||
add: 70,
|
||||
delete: 10,
|
||||
compact: 5,
|
||||
checkpoint: 5,
|
||||
mst_list: 10,
|
||||
..OpWeights::default()
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
|
||||
collections: default_collections(),
|
||||
key_space: KeySpaceSize(500),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants(),
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(10 * 60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::CrashAtSyscall(OpInterval(2_000)),
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_corruption(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Simulated {
|
||||
fault: FaultConfig::read_faults(),
|
||||
},
|
||||
workload: WorkloadModel {
|
||||
weights: OpWeights {
|
||||
add: 35,
|
||||
delete: 3,
|
||||
compact: 2,
|
||||
read_record: 40,
|
||||
read_block: 12,
|
||||
mst_list: 8,
|
||||
..OpWeights::default()
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
|
||||
collections: default_collections(),
|
||||
key_space: KeySpaceSize(300),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(20_000),
|
||||
invariants: sim_invariants(),
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(10 * 60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::Never,
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_commit(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Real,
|
||||
workload: block_workload(
|
||||
block_weights(80, 10, 5, 5),
|
||||
SizeDistribution::Fixed(ValueBytes(96)),
|
||||
KeySpaceSize(300),
|
||||
),
|
||||
op_count: OpCount(10_000),
|
||||
invariants: phase2_invariants(),
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(120_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::EveryNOps(OpInterval(1_000)),
|
||||
store: StoreConfig {
|
||||
max_file_size: MaxFileSize(8 * 1024),
|
||||
group_commit: GroupCommitConfig {
|
||||
synchronous: true,
|
||||
verify_persisted_blocks: true,
|
||||
checkpoint_interval_ms: 100,
|
||||
checkpoint_write_threshold: 16,
|
||||
..GroupCommitConfig::default()
|
||||
},
|
||||
shard_count: ShardCount(1),
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ mod tests {
|
||||
RestartPolicy, RunLimits, ShardCount, StoreConfig, WriterConcurrency,
|
||||
};
|
||||
use crate::gauntlet::workload::{
|
||||
DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
|
||||
ValueBytes, WorkloadModel,
|
||||
AdvanceMaxSecs, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs,
|
||||
SizeDistribution, ValueBytes, WorkloadModel,
|
||||
};
|
||||
use crate::sim::FaultConfig;
|
||||
|
||||
@@ -130,6 +130,7 @@ mod tests {
|
||||
key_space: KeySpaceSize(4),
|
||||
did_space: DidSpaceSize(1),
|
||||
retention_max_secs: RetentionMaxSecs(60),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(4),
|
||||
invariants: InvariantSet::EMPTY,
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::runner::{
|
||||
};
|
||||
use super::workload::OpCount;
|
||||
use crate::blockstore::TranquilBlockStore;
|
||||
use crate::clock::Clock;
|
||||
use crate::io::{RealIO, StorageIO};
|
||||
|
||||
const OP_ERROR_LOG_THROTTLE: u64 = 1024;
|
||||
@@ -137,7 +138,7 @@ pub async fn run_soak<W: Write + Send>(
|
||||
outcome
|
||||
}
|
||||
|
||||
fn shutdown_harness<S: StorageIO + Send + Sync + 'static>(harness: &mut Harness<S>) {
|
||||
fn shutdown_harness<S: StorageIO + Send + Sync + 'static, C: Clock>(harness: &mut Harness<S, C>) {
|
||||
if let Some(el) = harness.eventlog.as_mut() {
|
||||
if let Err(e) = el.writer.shutdown() {
|
||||
warn!(error = %e, "soak: event log writer shutdown failed");
|
||||
@@ -146,15 +147,17 @@ fn shutdown_harness<S: StorageIO + Send + Sync + 'static>(harness: &mut Harness<
|
||||
}
|
||||
}
|
||||
|
||||
async fn drive_soak<S, W>(
|
||||
harness: &mut Harness<S>,
|
||||
async fn drive_soak<S, C, W>(
|
||||
harness: &mut Harness<S, C>,
|
||||
cfg: &SoakConfig,
|
||||
emitter: &mut W,
|
||||
) -> Result<SoakReport, SoakError>
|
||||
where
|
||||
S: StorageIO + Send + Sync + 'static,
|
||||
C: Clock,
|
||||
W: Write + Send,
|
||||
{
|
||||
let clock = harness.store.clock().clone();
|
||||
let mut oracle = Oracle::new();
|
||||
let mut root: Option<Cid> = None;
|
||||
|
||||
@@ -194,7 +197,16 @@ where
|
||||
if start.elapsed() >= cfg.total_duration {
|
||||
break;
|
||||
}
|
||||
match apply_op(harness, &mut root, &mut oracle, op, &cfg.gauntlet.workload).await {
|
||||
match apply_op(
|
||||
harness,
|
||||
&mut root,
|
||||
&mut oracle,
|
||||
op,
|
||||
&cfg.gauntlet.workload,
|
||||
&clock,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
ops_executed = ops_executed.saturating_add(1);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use super::op::{
|
||||
CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
|
||||
RetentionSecs, Seed, ValueSeed,
|
||||
AdvanceNanos, CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed,
|
||||
RecordKey, RetentionSecs, Seed, ValueSeed,
|
||||
};
|
||||
|
||||
const NANOS_PER_SEC: u64 = 1_000_000_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ValueBytes(pub u32);
|
||||
|
||||
@@ -23,7 +25,9 @@ pub struct OpWeights {
|
||||
pub run_retention: u32,
|
||||
pub read_record: u32,
|
||||
pub read_block: u32,
|
||||
pub mst_list: u32,
|
||||
pub external_delete_data_file: u32,
|
||||
pub advance_time: u32,
|
||||
}
|
||||
|
||||
impl OpWeights {
|
||||
@@ -37,7 +41,9 @@ impl OpWeights {
|
||||
+ self.run_retention
|
||||
+ self.read_record
|
||||
+ self.read_block
|
||||
+ self.mst_list
|
||||
+ self.external_delete_data_file
|
||||
+ self.advance_time
|
||||
}
|
||||
|
||||
pub const fn touches_eventlog(&self) -> bool {
|
||||
@@ -82,6 +88,9 @@ pub struct DidSpaceSize(pub u32);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RetentionMaxSecs(pub u32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct AdvanceMaxSecs(pub u32);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkloadModel {
|
||||
pub weights: OpWeights,
|
||||
@@ -90,6 +99,7 @@ pub struct WorkloadModel {
|
||||
pub key_space: KeySpaceSize,
|
||||
pub did_space: DidSpaceSize,
|
||||
pub retention_max_secs: RetentionMaxSecs,
|
||||
pub advance_max_secs: AdvanceMaxSecs,
|
||||
}
|
||||
|
||||
impl Default for WorkloadModel {
|
||||
@@ -105,13 +115,16 @@ impl Default for WorkloadModel {
|
||||
run_retention: 0,
|
||||
read_record: 0,
|
||||
read_block: 0,
|
||||
mst_list: 0,
|
||||
external_delete_data_file: 0,
|
||||
advance_time: 0,
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
|
||||
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
|
||||
key_space: KeySpaceSize(200),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +155,8 @@ impl WorkloadModel {
|
||||
let t7 = t6 + w.run_retention;
|
||||
let t8 = t7 + w.read_record;
|
||||
let t9 = t8 + w.read_block;
|
||||
let t10 = t9 + w.mst_list;
|
||||
let t11 = t10 + w.external_delete_data_file;
|
||||
|
||||
match bucket {
|
||||
b if b < t1 => Op::AddRecord {
|
||||
@@ -173,9 +188,16 @@ impl WorkloadModel {
|
||||
b if b < t9 => Op::ReadBlock {
|
||||
value_seed: ValueSeed(rng.next_u32()),
|
||||
},
|
||||
_ => Op::ExternalDeleteDataFile {
|
||||
b if b < t10 => Op::MstList,
|
||||
b if b < t11 => Op::ExternalDeleteDataFile {
|
||||
choice: FileChoice(rng.next_u32()),
|
||||
},
|
||||
_ => Op::AdvanceTime {
|
||||
by: AdvanceNanos(
|
||||
u64::from(rng.next_u32() % self.advance_max_secs.0.max(1))
|
||||
* NANOS_PER_SEC,
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod archival;
|
||||
pub mod backup;
|
||||
pub mod blockstore;
|
||||
pub mod bloom;
|
||||
pub mod clock;
|
||||
pub mod consistency;
|
||||
pub mod eventlog;
|
||||
pub mod fsync_order;
|
||||
@@ -16,6 +17,9 @@ mod record;
|
||||
mod sim;
|
||||
|
||||
pub use blockstore::BlocksSynced;
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
pub use clock::SimClock;
|
||||
pub use clock::{Clock, LogicalNanos, SystemClock};
|
||||
pub use fsync_order::PostBlockstoreHook;
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
pub use harness::{
|
||||
@@ -31,7 +35,3 @@ pub use sim::{
|
||||
FaultConfig, LatencyNs, OpRecord, PristineGuard, Probability, SimulatedIO, SyncReorderWindow,
|
||||
sim_proptest_cases, sim_seed_count, sim_seed_range, sim_single_seed,
|
||||
};
|
||||
|
||||
pub(crate) fn wall_clock_ms() -> blockstore::WallClockMs {
|
||||
blockstore::WallClockMs::now()
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ use super::user_block_ops::UserBlockOps;
|
||||
use super::user_blocks::user_block_user_prefix;
|
||||
use super::user_hash::UserHashMap;
|
||||
use crate::blockstore::TranquilBlockStore;
|
||||
use crate::clock::SystemClock;
|
||||
use crate::eventlog::EventLogBridge;
|
||||
use crate::io::StorageIO;
|
||||
use crate::io::{RealIO, StorageIO};
|
||||
|
||||
use tranquil_db_traits::{
|
||||
ApplyCommitError, ApplyCommitInput, ApplyCommitResult, ImportBlock, ImportRecord,
|
||||
@@ -81,7 +82,7 @@ pub struct CommitOps<S: StorageIO> {
|
||||
user_block_ops: UserBlockOps,
|
||||
backlink_ops: BacklinkOps,
|
||||
event_ops: EventOps<S>,
|
||||
blockstore: Option<TranquilBlockStore>,
|
||||
blockstore: Option<TranquilBlockStore<RealIO, SystemClock>>,
|
||||
}
|
||||
|
||||
impl<S: StorageIO + 'static> CommitOps<S> {
|
||||
@@ -110,7 +111,7 @@ impl<S: StorageIO + 'static> CommitOps<S> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_blockstore(mut self, blockstore: TranquilBlockStore) -> Self {
|
||||
pub fn with_blockstore(mut self, blockstore: TranquilBlockStore<RealIO, SystemClock>) -> Self {
|
||||
self.blockstore = Some(blockstore);
|
||||
self
|
||||
}
|
||||
@@ -474,7 +475,6 @@ fn parse_user_hash_from_key(key_bytes: &[u8]) -> Option<UserHash> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::eventlog::{EventLog, EventLogConfig};
|
||||
use crate::io::RealIO;
|
||||
use crate::metastore::{Metastore, MetastoreConfig};
|
||||
use tranquil_db_traits::{CommitEventData, RepoEventType};
|
||||
use tranquil_types::{Handle, Nsid, Rkey};
|
||||
|
||||
@@ -42,8 +42,9 @@ use super::keys::UserHash;
|
||||
use super::record_ops::ListRecordsQuery;
|
||||
use super::user_hash::UserHashMap;
|
||||
use crate::blockstore::TranquilBlockStore;
|
||||
use crate::clock::SystemClock;
|
||||
use crate::eventlog::EventLogBridge;
|
||||
use crate::io::StorageIO;
|
||||
use crate::io::{RealIO, StorageIO};
|
||||
use crate::metastore::Metastore;
|
||||
|
||||
type Tx<T> = oneshot::Sender<Result<T, DbError>>;
|
||||
@@ -5956,7 +5957,7 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
|
||||
fn handler_loop<S: StorageIO + 'static>(
|
||||
metastore: Metastore,
|
||||
bridge: Arc<EventLogBridge<S>>,
|
||||
blockstore: Option<TranquilBlockStore>,
|
||||
blockstore: Option<TranquilBlockStore<RealIO, SystemClock>>,
|
||||
rx: flume::Receiver<MetastoreRequest>,
|
||||
thread_index: usize,
|
||||
) {
|
||||
@@ -6004,7 +6005,7 @@ impl HandlerPool {
|
||||
pub fn spawn<S: StorageIO + 'static>(
|
||||
metastore: Metastore,
|
||||
bridge: Arc<EventLogBridge<S>>,
|
||||
blockstore: Option<TranquilBlockStore>,
|
||||
blockstore: Option<TranquilBlockStore<RealIO, SystemClock>>,
|
||||
thread_count: Option<usize>,
|
||||
) -> Self {
|
||||
let count = thread_count
|
||||
@@ -6106,7 +6107,6 @@ impl Drop for HandlerPool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::eventlog::{EventLog, EventLogConfig};
|
||||
use crate::io::RealIO;
|
||||
use crate::metastore::MetastoreConfig;
|
||||
use tranquil_types::{Did, Handle};
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::clock::{Clock, SimClock};
|
||||
use crate::io::{FileId, OpenOptions, StorageIO};
|
||||
|
||||
pub const TORN_PAGE_BYTES: usize = 4096;
|
||||
pub const SECTOR_BYTES: usize = 512;
|
||||
|
||||
const BASE_IO_SERVICE_NS: u64 = 1_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Probability(f64);
|
||||
|
||||
@@ -111,6 +114,13 @@ impl FaultConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn misdirected_only() -> Self {
|
||||
Self {
|
||||
misdirected_write_probability: Probability::new(0.25),
|
||||
..Self::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fsyncgate_only() -> Self {
|
||||
Self {
|
||||
delayed_io_error_probability: Probability::new(0.05),
|
||||
@@ -118,6 +128,32 @@ impl FaultConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recoverable() -> Self {
|
||||
Self {
|
||||
misdirected_write_probability: Probability::ZERO,
|
||||
misdirected_read_probability: Probability::ZERO,
|
||||
bit_flip_on_read_probability: Probability::ZERO,
|
||||
..Self::moderate()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_faults() -> Self {
|
||||
Self {
|
||||
misdirected_read_probability: Probability::new(0.05),
|
||||
bit_flip_on_read_probability: Probability::new(0.05),
|
||||
io_error_probability: Probability::new(0.01),
|
||||
..Self::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_corruption() -> Self {
|
||||
Self {
|
||||
misdirected_read_probability: Probability::new(0.05),
|
||||
bit_flip_on_read_probability: Probability::new(0.05),
|
||||
..Self::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn injects_errors(&self) -> bool {
|
||||
self.partial_write_probability.is_nonzero()
|
||||
|| self.bit_flip_on_read_probability.is_nonzero()
|
||||
@@ -247,33 +283,66 @@ struct SimState {
|
||||
fds: HashMap<FileId, SimFd>,
|
||||
dirs_durable: HashSet<PathBuf>,
|
||||
op_log: Vec<OpRecord>,
|
||||
rng_counter: u64,
|
||||
next_fd_id: u64,
|
||||
next_storage_id: u64,
|
||||
pending_syncs: VecDeque<PendingSync>,
|
||||
pending_deletes: Vec<PendingDelete>,
|
||||
}
|
||||
|
||||
const TAG_OPEN_IO: u64 = 100;
|
||||
const TAG_READ_IO: u64 = 101;
|
||||
const TAG_READ_MISDIR: u64 = 102;
|
||||
const TAG_READ_DRIFT_SECTORS: u64 = 103;
|
||||
const TAG_READ_DRIFT_DIR: u64 = 104;
|
||||
const TAG_READ_BITFLIP: u64 = 105;
|
||||
const TAG_READ_FLIP_POS: u64 = 106;
|
||||
const TAG_READ_FLIP_BIT: u64 = 107;
|
||||
const TAG_WRITE_IO: u64 = 110;
|
||||
const TAG_WRITE_TORN: u64 = 111;
|
||||
const TAG_WRITE_TORN_SECTORS: u64 = 112;
|
||||
const TAG_WRITE_PARTIAL: u64 = 113;
|
||||
const TAG_WRITE_PARTIAL_LEN: u64 = 114;
|
||||
const TAG_WRITE_MISDIR: u64 = 115;
|
||||
const TAG_WRITE_DRIFT_SECTORS: u64 = 116;
|
||||
const TAG_WRITE_DRIFT_DIR: u64 = 117;
|
||||
const TAG_SYNC_IO: u64 = 120;
|
||||
const TAG_SYNC_FAILURE: u64 = 121;
|
||||
const TAG_SYNC_DELAYED: u64 = 122;
|
||||
const TAG_SYNCDIR_IO: u64 = 130;
|
||||
const TAG_SYNCDIR_FAILURE: u64 = 131;
|
||||
const TAG_LATENCY: u64 = 140;
|
||||
|
||||
fn path_stream(path: &Path) -> u64 {
|
||||
let key = path.file_name().unwrap_or(path.as_os_str());
|
||||
key.to_string_lossy()
|
||||
.bytes()
|
||||
.fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
impl SimState {
|
||||
fn next_random(&mut self, seed: u64) -> f64 {
|
||||
let counter = self.rng_counter;
|
||||
self.rng_counter += 1;
|
||||
let mixed = splitmix64(seed.wrapping_add(counter));
|
||||
(mixed >> 11) as f64 / (1u64 << 53) as f64
|
||||
fn fault_hash(seed: u64, stream: u64, key: u64, tag: u64) -> u64 {
|
||||
let h = splitmix64(seed ^ 0x9E37_79B9_7F4A_7C15);
|
||||
let h = splitmix64(h ^ stream.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let h = splitmix64(h ^ key);
|
||||
splitmix64(h ^ tag)
|
||||
}
|
||||
|
||||
fn next_random_usize(&mut self, seed: u64, max: usize) -> usize {
|
||||
fn fault_unit(seed: u64, stream: u64, key: u64, tag: u64) -> f64 {
|
||||
(Self::fault_hash(seed, stream, key, tag) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn fault_below(seed: u64, stream: u64, key: u64, tag: u64, probability: Probability) -> bool {
|
||||
probability.is_nonzero() && Self::fault_unit(seed, stream, key, tag) < probability.raw()
|
||||
}
|
||||
|
||||
fn fault_usize(seed: u64, stream: u64, key: u64, tag: u64, max: usize) -> usize {
|
||||
if max == 0 {
|
||||
return 0;
|
||||
0
|
||||
} else {
|
||||
(Self::fault_hash(seed, stream, key, tag) as usize) % max
|
||||
}
|
||||
let counter = self.rng_counter;
|
||||
self.rng_counter += 1;
|
||||
let mixed = splitmix64(seed.wrapping_add(counter));
|
||||
(mixed as usize) % max
|
||||
}
|
||||
|
||||
fn should_fault(&mut self, seed: u64, probability: Probability) -> bool {
|
||||
probability.is_nonzero() && self.next_random(seed) < probability.raw()
|
||||
}
|
||||
|
||||
fn alloc_fd_id(&mut self) -> FileId {
|
||||
@@ -329,8 +398,11 @@ pub struct SimulatedIO {
|
||||
state: Mutex<SimState>,
|
||||
fault_config: FaultConfig,
|
||||
pristine_mode: AtomicBool,
|
||||
write_crash_armed: AtomicBool,
|
||||
write_crash_countdown: AtomicI64,
|
||||
write_crashed: AtomicBool,
|
||||
rng_seed: u64,
|
||||
latency_counter: AtomicU64,
|
||||
clock: SimClock,
|
||||
}
|
||||
|
||||
impl SimulatedIO {
|
||||
@@ -342,7 +414,6 @@ impl SimulatedIO {
|
||||
fds: HashMap::new(),
|
||||
dirs_durable: HashSet::new(),
|
||||
op_log: Vec::new(),
|
||||
rng_counter: 0,
|
||||
next_fd_id: 1,
|
||||
next_storage_id: 1,
|
||||
pending_syncs: VecDeque::new(),
|
||||
@@ -350,11 +421,39 @@ impl SimulatedIO {
|
||||
}),
|
||||
fault_config,
|
||||
pristine_mode: AtomicBool::new(false),
|
||||
write_crash_armed: AtomicBool::new(false),
|
||||
write_crash_countdown: AtomicI64::new(-1),
|
||||
write_crashed: AtomicBool::new(false),
|
||||
rng_seed: seed,
|
||||
latency_counter: AtomicU64::new(0),
|
||||
clock: SimClock::new(seed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arm_write_crash(&self, after_writes: i64) {
|
||||
self.write_crashed.store(false, Ordering::Relaxed);
|
||||
self.write_crash_countdown
|
||||
.store(after_writes, Ordering::Relaxed);
|
||||
self.write_crash_armed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn write_crash_fired(&self) -> bool {
|
||||
if !self.write_crash_armed.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
if self.write_crashed.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
if self.write_crash_countdown.fetch_sub(1, Ordering::Relaxed) <= 0 {
|
||||
self.write_crashed.store(true, Ordering::Relaxed);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn clock(&self) -> SimClock {
|
||||
self.clock.clone()
|
||||
}
|
||||
|
||||
fn effective_fault_config(&self) -> FaultConfig {
|
||||
if self.pristine_mode.load(Ordering::Relaxed) {
|
||||
FaultConfig::none()
|
||||
@@ -367,24 +466,31 @@ impl SimulatedIO {
|
||||
self.pristine_mode.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn jitter(&self) {
|
||||
pub fn pristine_mode(&self) -> bool {
|
||||
self.pristine_mode.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn jitter(&self, stream: u64, key: u64) {
|
||||
let max_ns = self.effective_fault_config().latency_distribution_ns.0;
|
||||
if max_ns == 0 {
|
||||
return;
|
||||
}
|
||||
let c = self.latency_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let r = splitmix64(self.rng_seed.wrapping_add(c));
|
||||
let ns = r % max_ns;
|
||||
std::thread::sleep(Duration::from_nanos(ns));
|
||||
let extra_ns = match max_ns {
|
||||
0 => 0,
|
||||
max => SimState::fault_hash(self.rng_seed, stream, key, TAG_LATENCY) % max,
|
||||
};
|
||||
self.clock
|
||||
.advance(Duration::from_nanos(BASE_IO_SERVICE_NS + extra_ns));
|
||||
}
|
||||
|
||||
pub fn pristine(seed: u64) -> Self {
|
||||
Self::new(seed, FaultConfig::none())
|
||||
}
|
||||
|
||||
pub fn crash(&self) {
|
||||
pub fn crash(&self) -> Vec<PathBuf> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
self.write_crash_armed.store(false, Ordering::Relaxed);
|
||||
self.write_crashed.store(false, Ordering::Relaxed);
|
||||
self.write_crash_countdown.store(-1, Ordering::Relaxed);
|
||||
|
||||
state.fds.clear();
|
||||
state.pending_syncs.clear();
|
||||
|
||||
@@ -395,13 +501,20 @@ impl SimulatedIO {
|
||||
}
|
||||
});
|
||||
|
||||
let orphaned: Vec<StorageId> = state
|
||||
let orphaned: HashSet<StorageId> = state
|
||||
.storage
|
||||
.iter()
|
||||
.filter(|(_, s)| !s.dir_entry_durable)
|
||||
.map(|(sid, _)| *sid)
|
||||
.collect();
|
||||
|
||||
let removed_paths: Vec<PathBuf> = state
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|(_, sid)| orphaned.contains(sid))
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
|
||||
orphaned.iter().for_each(|sid| {
|
||||
state.storage.remove(sid);
|
||||
});
|
||||
@@ -413,6 +526,8 @@ impl SimulatedIO {
|
||||
s.buffered = s.durable.clone();
|
||||
s.io_poisoned = false;
|
||||
});
|
||||
|
||||
removed_paths
|
||||
}
|
||||
|
||||
pub fn op_log(&self) -> Vec<OpRecord> {
|
||||
@@ -447,18 +562,20 @@ impl SimulatedIO {
|
||||
|
||||
pub struct PristineGuard {
|
||||
sim: Arc<SimulatedIO>,
|
||||
prev: bool,
|
||||
}
|
||||
|
||||
impl PristineGuard {
|
||||
pub fn new(sim: Arc<SimulatedIO>, on: bool) -> Self {
|
||||
sim.set_pristine_mode(on);
|
||||
Self { sim }
|
||||
let prev = sim.pristine_mode();
|
||||
sim.set_pristine_mode(on || prev);
|
||||
Self { sim, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PristineGuard {
|
||||
fn drop(&mut self) {
|
||||
self.sim.set_pristine_mode(false);
|
||||
self.sim.set_pristine_mode(self.prev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +585,20 @@ impl StorageIO for SimulatedIO {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let seed = self.rng_seed;
|
||||
|
||||
if state.should_fault(seed, fault.io_error_probability) {
|
||||
let open_stream = path_stream(path);
|
||||
let existing_size = state
|
||||
.paths
|
||||
.get(path)
|
||||
.and_then(|sid| state.storage.get(sid))
|
||||
.map(|s| s.buffered.len() as u64)
|
||||
.unwrap_or(0);
|
||||
if SimState::fault_below(
|
||||
seed,
|
||||
open_stream,
|
||||
existing_size,
|
||||
TAG_OPEN_IO,
|
||||
fault.io_error_probability,
|
||||
) {
|
||||
return Err(io::Error::other("simulated EIO on open"));
|
||||
}
|
||||
|
||||
@@ -547,24 +677,38 @@ impl StorageIO for SimulatedIO {
|
||||
}
|
||||
|
||||
fn read_at(&self, id: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.jitter();
|
||||
let fault = self.effective_fault_config();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let sid = state.require_readable(id)?;
|
||||
let seed = self.rng_seed;
|
||||
let stream = sid.0;
|
||||
self.jitter(stream, offset);
|
||||
|
||||
if state.storage.get(&sid).is_some_and(|s| s.io_poisoned) {
|
||||
return Err(io::Error::other("simulated EIO after delayed sync fault"));
|
||||
}
|
||||
|
||||
if state.should_fault(seed, fault.io_error_probability) {
|
||||
if SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_READ_IO,
|
||||
fault.io_error_probability,
|
||||
) {
|
||||
return Err(io::Error::other("simulated EIO on read"));
|
||||
}
|
||||
|
||||
let read_offset = if state.should_fault(seed, fault.misdirected_read_probability) {
|
||||
let drift_sectors = state.next_random_usize(seed, 8) + 1;
|
||||
let read_offset = if SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_READ_MISDIR,
|
||||
fault.misdirected_read_probability,
|
||||
) {
|
||||
let drift_sectors =
|
||||
SimState::fault_usize(seed, stream, offset, TAG_READ_DRIFT_SECTORS, 8) + 1;
|
||||
let drift = (drift_sectors * SECTOR_BYTES) as u64;
|
||||
if state.next_random(seed) < 0.5 {
|
||||
if SimState::fault_unit(seed, stream, offset, TAG_READ_DRIFT_DIR) < 0.5 {
|
||||
offset.saturating_sub(drift)
|
||||
} else {
|
||||
offset.saturating_add(drift)
|
||||
@@ -590,9 +734,17 @@ impl StorageIO for SimulatedIO {
|
||||
let to_read = buf.len().min(available);
|
||||
buf[..to_read].copy_from_slice(&storage.buffered[off..off + to_read]);
|
||||
|
||||
if state.should_fault(seed, fault.bit_flip_on_read_probability) && to_read > 0 {
|
||||
let flip_pos = state.next_random_usize(seed, to_read);
|
||||
let flip_bit = state.next_random_usize(seed, 8);
|
||||
if to_read > 0
|
||||
&& SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_READ_BITFLIP,
|
||||
fault.bit_flip_on_read_probability,
|
||||
)
|
||||
{
|
||||
let flip_pos = SimState::fault_usize(seed, stream, offset, TAG_READ_FLIP_POS, to_read);
|
||||
let flip_bit = SimState::fault_usize(seed, stream, offset, TAG_READ_FLIP_BIT, 8);
|
||||
buf[flip_pos] ^= 1 << flip_bit;
|
||||
}
|
||||
|
||||
@@ -605,27 +757,51 @@ impl StorageIO for SimulatedIO {
|
||||
}
|
||||
|
||||
fn write_at(&self, id: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> {
|
||||
self.jitter();
|
||||
let fault = self.effective_fault_config();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let sid = state.require_writable(id)?;
|
||||
let seed = self.rng_seed;
|
||||
let stream = sid.0;
|
||||
self.jitter(stream, offset);
|
||||
|
||||
if self.write_crash_fired() {
|
||||
return Err(io::Error::other("simulated crash mid-commit"));
|
||||
}
|
||||
|
||||
if state.storage.get(&sid).is_some_and(|s| s.io_poisoned) {
|
||||
return Err(io::Error::other("simulated EIO after delayed sync fault"));
|
||||
}
|
||||
|
||||
if state.should_fault(seed, fault.io_error_probability) {
|
||||
if SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_WRITE_IO,
|
||||
fault.io_error_probability,
|
||||
) {
|
||||
return Err(io::Error::other("simulated EIO on write"));
|
||||
}
|
||||
|
||||
let torn_len = if buf.len() > 1 && state.should_fault(seed, fault.torn_page_probability) {
|
||||
let torn_len = if buf.len() > 1
|
||||
&& SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_WRITE_TORN,
|
||||
fault.torn_page_probability,
|
||||
) {
|
||||
let page_base = (offset as usize) - ((offset as usize) % TORN_PAGE_BYTES);
|
||||
let page_end = page_base + TORN_PAGE_BYTES;
|
||||
let cap = page_end.saturating_sub(offset as usize).min(buf.len());
|
||||
let max_sectors = cap / SECTOR_BYTES;
|
||||
(max_sectors >= 2).then(|| {
|
||||
let n = state.next_random_usize(seed, max_sectors - 1) + 1;
|
||||
let n = SimState::fault_usize(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_WRITE_TORN_SECTORS,
|
||||
max_sectors - 1,
|
||||
) + 1;
|
||||
n * SECTOR_BYTES
|
||||
})
|
||||
} else {
|
||||
@@ -634,18 +810,34 @@ impl StorageIO for SimulatedIO {
|
||||
|
||||
let actual_len = match torn_len {
|
||||
Some(n) => n,
|
||||
None if buf.len() > 1 && state.should_fault(seed, fault.partial_write_probability) => {
|
||||
let partial = state.next_random_usize(seed, buf.len());
|
||||
None if buf.len() > 1
|
||||
&& SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_WRITE_PARTIAL,
|
||||
fault.partial_write_probability,
|
||||
) =>
|
||||
{
|
||||
let partial =
|
||||
SimState::fault_usize(seed, stream, offset, TAG_WRITE_PARTIAL_LEN, buf.len());
|
||||
partial.max(1)
|
||||
}
|
||||
None => buf.len(),
|
||||
};
|
||||
|
||||
let misdirected = state.should_fault(seed, fault.misdirected_write_probability);
|
||||
let misdirected = SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
offset,
|
||||
TAG_WRITE_MISDIR,
|
||||
fault.misdirected_write_probability,
|
||||
);
|
||||
let write_offset = if misdirected {
|
||||
let drift_sectors = state.next_random_usize(seed, 8) + 1;
|
||||
let drift_sectors =
|
||||
SimState::fault_usize(seed, stream, offset, TAG_WRITE_DRIFT_SECTORS, 8) + 1;
|
||||
let drift = (drift_sectors * SECTOR_BYTES) as u64;
|
||||
if state.next_random(seed) < 0.5 {
|
||||
if SimState::fault_unit(seed, stream, offset, TAG_WRITE_DRIFT_DIR) < 0.5 {
|
||||
offset.saturating_sub(drift)
|
||||
} else {
|
||||
offset.saturating_add(drift)
|
||||
@@ -674,21 +866,37 @@ impl StorageIO for SimulatedIO {
|
||||
}
|
||||
|
||||
fn sync(&self, id: FileId) -> io::Result<()> {
|
||||
self.jitter();
|
||||
let fault = self.effective_fault_config();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let sid = state.require_open(id)?;
|
||||
let seed = self.rng_seed;
|
||||
let stream = sid.0;
|
||||
let fsize = state
|
||||
.storage
|
||||
.get(&sid)
|
||||
.map(|s| s.buffered.len() as u64)
|
||||
.unwrap_or(0);
|
||||
self.jitter(stream, fsize);
|
||||
|
||||
if self.write_crashed.load(Ordering::Relaxed) {
|
||||
return Err(io::Error::other("simulated crash mid-commit"));
|
||||
}
|
||||
|
||||
if state.storage.get(&sid).is_some_and(|s| s.io_poisoned) {
|
||||
return Err(io::Error::other("simulated EIO after delayed sync fault"));
|
||||
}
|
||||
|
||||
if state.should_fault(seed, fault.io_error_probability) {
|
||||
if SimState::fault_below(seed, stream, fsize, TAG_SYNC_IO, fault.io_error_probability) {
|
||||
return Err(io::Error::other("simulated EIO on sync"));
|
||||
}
|
||||
|
||||
if state.should_fault(seed, fault.sync_failure_probability) {
|
||||
if SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
fsize,
|
||||
TAG_SYNC_FAILURE,
|
||||
fault.sync_failure_probability,
|
||||
) {
|
||||
state.op_log.push(OpRecord::Sync {
|
||||
fd: id,
|
||||
succeeded: false,
|
||||
@@ -696,7 +904,13 @@ impl StorageIO for SimulatedIO {
|
||||
return Err(io::Error::other("simulated dropped fsync"));
|
||||
}
|
||||
|
||||
let poison_after = state.should_fault(seed, fault.delayed_io_error_probability);
|
||||
let poison_after = SimState::fault_below(
|
||||
seed,
|
||||
stream,
|
||||
fsize,
|
||||
TAG_SYNC_DELAYED,
|
||||
fault.delayed_io_error_probability,
|
||||
);
|
||||
let reorder_window = fault.sync_reorder_window.0 as usize;
|
||||
|
||||
let evicted = if reorder_window > 0 {
|
||||
@@ -814,7 +1028,7 @@ impl StorageIO for SimulatedIO {
|
||||
}
|
||||
|
||||
fn barrier(&self) -> io::Result<()> {
|
||||
self.jitter();
|
||||
self.jitter(0, 0);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let drained: Vec<PendingSync> = state.pending_syncs.drain(..).collect();
|
||||
drained.into_iter().for_each(|p| {
|
||||
@@ -831,12 +1045,31 @@ impl StorageIO for SimulatedIO {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let seed = self.rng_seed;
|
||||
|
||||
if state.should_fault(seed, fault.io_error_probability) {
|
||||
let dir_stream = path_stream(path);
|
||||
let dir_entries = state
|
||||
.paths
|
||||
.keys()
|
||||
.filter(|p| p.parent() == Some(path))
|
||||
.count() as u64;
|
||||
|
||||
if SimState::fault_below(
|
||||
seed,
|
||||
dir_stream,
|
||||
dir_entries,
|
||||
TAG_SYNCDIR_IO,
|
||||
fault.io_error_probability,
|
||||
) {
|
||||
return Err(io::Error::other("simulated EIO on sync_dir"));
|
||||
}
|
||||
|
||||
let dir_path = path.to_path_buf();
|
||||
let actually_persisted = !state.should_fault(seed, fault.dir_sync_failure_probability);
|
||||
let actually_persisted = !SimState::fault_below(
|
||||
seed,
|
||||
dir_stream,
|
||||
dir_entries,
|
||||
TAG_SYNCDIR_FAILURE,
|
||||
fault.dir_sync_failure_probability,
|
||||
);
|
||||
|
||||
if actually_persisted {
|
||||
state.dirs_durable.insert(dir_path.clone());
|
||||
@@ -907,7 +1140,7 @@ pub fn sim_proptest_cases() -> u32 {
|
||||
u32::try_from(sim_seed_count()).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
fn splitmix64(mut x: u64) -> u64 {
|
||||
pub(crate) fn splitmix64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e3779b97f4a7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use rayon::prelude::*;
|
||||
use tranquil_store::RealIO;
|
||||
use tranquil_store::archival::{
|
||||
ArchivalDestination, ArchivalSidecar, ContinuousArchiver, LocalArchivalDestination,
|
||||
};
|
||||
use tranquil_store::eventlog::{EventLog, EventLogConfig, SegmentId};
|
||||
use tranquil_store::sim_seed_range;
|
||||
|
||||
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
|
||||
use tranquil_types::Did;
|
||||
|
||||
struct FlakyDestination {
|
||||
inner: LocalArchivalDestination,
|
||||
calls: AtomicU32,
|
||||
fail_at: u32,
|
||||
}
|
||||
|
||||
impl ArchivalDestination for FlakyDestination {
|
||||
fn store_segment(&self, segment_id: SegmentId, data: &[u8]) -> std::io::Result<()> {
|
||||
let n = self.calls.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n == self.fail_at {
|
||||
return Err(std::io::Error::other("simulated archival crash"));
|
||||
}
|
||||
self.inner.store_segment(segment_id, data)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_segments(segments_dir: &std::path::Path, n_events: u32) {
|
||||
std::fs::create_dir_all(segments_dir).unwrap();
|
||||
let el = EventLog::open(
|
||||
EventLogConfig {
|
||||
segments_dir: segments_dir.to_path_buf(),
|
||||
max_segment_size: 256,
|
||||
..EventLogConfig::default()
|
||||
},
|
||||
RealIO::new(),
|
||||
)
|
||||
.unwrap();
|
||||
(0..n_events).for_each(|i| {
|
||||
let did = Did::from(format!("did:plc:archive{}", i % 8));
|
||||
let event = SequencedEvent {
|
||||
seq: SequenceNumber::from_raw(0),
|
||||
did: did.clone(),
|
||||
created_at: chrono::Utc::now(),
|
||||
event_type: RepoEventType::Commit,
|
||||
commit_cid: None,
|
||||
prev_cid: None,
|
||||
prev_data_cid: None,
|
||||
ops: None,
|
||||
blobs: None,
|
||||
blocks: None,
|
||||
handle: None,
|
||||
active: None,
|
||||
status: None,
|
||||
rev: Some(format!("rev{i}")),
|
||||
};
|
||||
el.append_event(&did, RepoEventType::Commit, &event)
|
||||
.unwrap();
|
||||
if i % 64 == 63 {
|
||||
el.sync().unwrap();
|
||||
}
|
||||
});
|
||||
el.sync().unwrap();
|
||||
el.shutdown().unwrap();
|
||||
}
|
||||
|
||||
fn tqe_files(dir: &std::path::Path) -> BTreeMap<String, Vec<u8>> {
|
||||
std::fs::read_dir(dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "tqe"))
|
||||
.map(|p| {
|
||||
(
|
||||
p.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
std::fs::read(&p).unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sim_archival_sidecar_recovers_after_midpass_crash() {
|
||||
sim_seed_range().into_par_iter().for_each(|seed| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let segments_dir = dir.path().join("segments");
|
||||
let archive_dir = dir.path().join("archive");
|
||||
let sidecar_path = dir.path().join("archival_sidecar.json");
|
||||
|
||||
build_segments(&segments_dir, 800);
|
||||
|
||||
let source = tqe_files(&segments_dir);
|
||||
assert!(
|
||||
source.len() >= 4,
|
||||
"seed={seed} need several segments to archive, got {}",
|
||||
source.len()
|
||||
);
|
||||
let mut sealed: Vec<(String, Vec<u8>)> = source.into_iter().collect();
|
||||
sealed.pop();
|
||||
let sealed: BTreeMap<String, Vec<u8>> = sealed.into_iter().collect();
|
||||
|
||||
let fail_at = ((seed % 3) + 1) as u32;
|
||||
{
|
||||
let dest = FlakyDestination {
|
||||
inner: LocalArchivalDestination::new(archive_dir.clone()).unwrap(),
|
||||
calls: AtomicU32::new(0),
|
||||
fail_at,
|
||||
};
|
||||
let archiver =
|
||||
ContinuousArchiver::new(segments_dir.clone(), sidecar_path.clone(), Box::new(dest));
|
||||
let _ = archiver.run_pass();
|
||||
}
|
||||
|
||||
let recovered = {
|
||||
let dest = LocalArchivalDestination::new(archive_dir.clone()).unwrap();
|
||||
let archiver =
|
||||
ContinuousArchiver::new(segments_dir.clone(), sidecar_path.clone(), Box::new(dest));
|
||||
archiver.run_pass().unwrap()
|
||||
};
|
||||
assert!(
|
||||
recovered.segments_archived as usize <= sealed.len(),
|
||||
"seed={seed} recovery pass cannot archive more than the sealed set"
|
||||
);
|
||||
|
||||
let archived = tqe_files(&archive_dir);
|
||||
assert_eq!(
|
||||
archived, sealed,
|
||||
"seed={seed} after a mid-pass crash and resume, every sealed segment must be archived exactly once with matching content"
|
||||
);
|
||||
|
||||
let final_state = ArchivalSidecar::new(sidecar_path.clone()).load().unwrap();
|
||||
let highest_sealed: SegmentId = {
|
||||
let mut ids: Vec<SegmentId> = sealed
|
||||
.keys()
|
||||
.map(|name| {
|
||||
let stem = name.trim_end_matches(".tqe");
|
||||
SegmentId::new(stem.parse::<u32>().unwrap())
|
||||
})
|
||||
.collect();
|
||||
ids.sort();
|
||||
*ids.last().unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
final_state.last_archived_segment,
|
||||
Some(highest_sealed),
|
||||
"seed={seed} sidecar last_archived_segment must equal the highest sealed segment after recovery"
|
||||
);
|
||||
|
||||
let idempotent = {
|
||||
let dest = LocalArchivalDestination::new(archive_dir.clone()).unwrap();
|
||||
let archiver =
|
||||
ContinuousArchiver::new(segments_dir.clone(), sidecar_path.clone(), Box::new(dest));
|
||||
archiver.run_pass().unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
idempotent.segments_archived, 0,
|
||||
"seed={seed} a pass after full archival must archive nothing"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tranquil_store::RealIO;
|
||||
use tranquil_store::backup::{
|
||||
BackupCoordinator, BackupKind, read_manifest, recover_to_sequence, restore_from_backup,
|
||||
restore_from_incremental, verify_backup,
|
||||
@@ -11,6 +10,7 @@ use tranquil_store::blockstore::{
|
||||
};
|
||||
use tranquil_store::eventlog::{EventLog, EventLogConfig};
|
||||
use tranquil_store::metastore::{Metastore, MetastoreConfig};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
fn test_cid(seed: u16) -> CidBytes {
|
||||
let mut cid = [0u8; 36];
|
||||
@@ -32,7 +32,7 @@ fn block_data(seed: u16) -> Vec<u8> {
|
||||
|
||||
struct TestStore {
|
||||
_dir: tempfile::TempDir,
|
||||
blockstore: TranquilBlockStore,
|
||||
blockstore: TranquilBlockStore<RealIO, SystemClock>,
|
||||
eventlog: Arc<EventLog<RealIO>>,
|
||||
metastore: Metastore,
|
||||
}
|
||||
@@ -163,7 +163,10 @@ fn with_runtime<F: FnOnce()>(f: F) {
|
||||
f();
|
||||
}
|
||||
|
||||
fn verify_blocks_readable(store: &TranquilBlockStore, range: std::ops::Range<u16>) {
|
||||
fn verify_blocks_readable(
|
||||
store: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
range: std::ops::Range<u16>,
|
||||
) {
|
||||
range.for_each(|i| {
|
||||
let cid = test_cid(i);
|
||||
let data = store.get_block_sync(&cid).unwrap();
|
||||
|
||||
@@ -4,10 +4,10 @@ use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use tranquil_store::PostBlockstoreHook;
|
||||
use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, BlocksSynced, CidBytes, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
use tranquil_store::{PostBlockstoreHook, RealIO, SystemClock};
|
||||
|
||||
struct SlowHook;
|
||||
|
||||
@@ -18,7 +18,7 @@ impl PostBlockstoreHook for SlowHook {
|
||||
}
|
||||
}
|
||||
|
||||
fn refcount(store: &TranquilBlockStore, cid: &CidBytes) -> Option<u32> {
|
||||
fn refcount(store: &TranquilBlockStore<RealIO, SystemClock>, cid: &CidBytes) -> Option<u32> {
|
||||
store.block_index().get(cid).map(|e| e.refcount.raw())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tranquil_store::RealIO;
|
||||
use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
use tranquil_store::eventlog::{EventLog, EventLogConfig};
|
||||
use tranquil_store::metastore::{Metastore, MetastoreConfig};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
use tranquil_types::{CidLink, Did, Handle};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -78,11 +78,11 @@ pub fn with_runtime<F: FnOnce()>(f: F) {
|
||||
f();
|
||||
}
|
||||
|
||||
pub fn advance_epoch(store: &TranquilBlockStore) {
|
||||
pub fn advance_epoch(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
store.apply_commit_blocking(vec![], vec![]).unwrap();
|
||||
}
|
||||
|
||||
pub fn collect_all_dead(store: &TranquilBlockStore) -> HashSet<CidBytes> {
|
||||
pub fn collect_all_dead(store: &TranquilBlockStore<RealIO, SystemClock>) -> HashSet<CidBytes> {
|
||||
let result = store.collect_dead_blocks(0).unwrap();
|
||||
result
|
||||
.candidates
|
||||
@@ -91,7 +91,7 @@ pub fn collect_all_dead(store: &TranquilBlockStore) -> HashSet<CidBytes> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn compact_all_sealed(store: &TranquilBlockStore) {
|
||||
pub fn compact_all_sealed(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
let Ok(files) = store.list_data_files() else {
|
||||
return;
|
||||
};
|
||||
@@ -118,7 +118,7 @@ pub fn tiny_blockstore_config(dir: &std::path::Path) -> BlockStoreConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compact_by_liveness(store: &TranquilBlockStore) {
|
||||
pub fn compact_by_liveness(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
let liveness = store.compaction_liveness(0).unwrap();
|
||||
liveness
|
||||
.iter()
|
||||
@@ -133,7 +133,7 @@ pub fn compact_by_liveness(store: &TranquilBlockStore) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn compact_lowest_liveness(store: &TranquilBlockStore) {
|
||||
pub fn compact_lowest_liveness(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
let liveness = store.compaction_liveness(0).unwrap();
|
||||
let candidate = liveness
|
||||
.iter()
|
||||
@@ -154,7 +154,10 @@ pub fn compact_lowest_liveness(store: &TranquilBlockStore) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect_refcounts(store: &TranquilBlockStore, cids: &[CidBytes]) -> Vec<(u32, u32)> {
|
||||
pub fn collect_refcounts(
|
||||
store: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
cids: &[CidBytes],
|
||||
) -> Vec<(u32, u32)> {
|
||||
cids.iter()
|
||||
.map(|cid| {
|
||||
let seed = u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]]);
|
||||
@@ -169,7 +172,7 @@ pub fn collect_refcounts(store: &TranquilBlockStore, cids: &[CidBytes]) -> Vec<(
|
||||
}
|
||||
|
||||
pub struct TestStores {
|
||||
pub blockstore: TranquilBlockStore,
|
||||
pub blockstore: TranquilBlockStore<RealIO, SystemClock>,
|
||||
pub eventlog: Arc<EventLog<RealIO>>,
|
||||
pub metastore: Metastore,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashSet;
|
||||
use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, CidBytes, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
fn tiny_store_config(dir: &std::path::Path) -> BlockStoreConfig {
|
||||
BlockStoreConfig {
|
||||
@@ -31,7 +32,11 @@ fn make_block(seed: u32, size: usize) -> (CidBytes, Vec<u8>) {
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_live_blocks(store: &TranquilBlockStore, live: &HashSet<u32>, context: &str) {
|
||||
fn verify_live_blocks(
|
||||
store: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
live: &HashSet<u32>,
|
||||
context: &str,
|
||||
) {
|
||||
let missing: Vec<u32> = live
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -50,7 +55,7 @@ fn verify_live_blocks(store: &TranquilBlockStore, live: &HashSet<u32>, context:
|
||||
);
|
||||
}
|
||||
|
||||
fn compact_sealed(store: &TranquilBlockStore) {
|
||||
fn compact_sealed(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
let files = store.list_data_files().unwrap();
|
||||
files
|
||||
.iter()
|
||||
|
||||
@@ -7,15 +7,15 @@ use common::{
|
||||
collect_refcounts, compact_by_liveness, compact_lowest_liveness, test_cid,
|
||||
tiny_blockstore_config, with_runtime,
|
||||
};
|
||||
use tranquil_store::RealIO;
|
||||
use tranquil_store::blockstore::{CidBytes, TranquilBlockStore};
|
||||
use tranquil_store::eventlog::{EventLog, EventLogBridge, EventLogConfig};
|
||||
use tranquil_store::metastore::handler::HandlerPool;
|
||||
use tranquil_store::metastore::partitions::Partition;
|
||||
use tranquil_store::metastore::{Metastore, MetastoreConfig};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
struct FullStack {
|
||||
blockstore: TranquilBlockStore,
|
||||
blockstore: TranquilBlockStore<RealIO, SystemClock>,
|
||||
_pool: Arc<HandlerPool>,
|
||||
_event_log: Arc<EventLog<RealIO>>,
|
||||
}
|
||||
@@ -109,7 +109,7 @@ fn close_full_stack(stack: FullStack, base_dir: &Path) {
|
||||
}
|
||||
|
||||
fn verify_blocks_and_refcounts(
|
||||
store: &TranquilBlockStore,
|
||||
store: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
live_cids: &[CidBytes],
|
||||
expected_refcounts: Option<&[(u32, u32)]>,
|
||||
label: &str,
|
||||
|
||||
@@ -6,12 +6,16 @@ use common::{block_data, test_cid, tiny_blockstore_config, with_runtime};
|
||||
use tranquil_store::blockstore::{
|
||||
CompactionResult, DataFileId, TranquilBlockStore, hint_file_path,
|
||||
};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
fn data_file_path(dir: &std::path::Path, file_id: DataFileId) -> std::path::PathBuf {
|
||||
dir.join(format!("{file_id}.tqb"))
|
||||
}
|
||||
|
||||
fn populate_with_compaction_history(store: &TranquilBlockStore, live_cids: &[u32]) {
|
||||
fn populate_with_compaction_history(
|
||||
store: &TranquilBlockStore<RealIO, SystemClock>,
|
||||
live_cids: &[u32],
|
||||
) {
|
||||
live_cids.iter().for_each(|&seed| {
|
||||
store
|
||||
.put_blocks_blocking(vec![(test_cid(seed), block_data(seed))])
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::sync::Arc;
|
||||
use common::with_runtime;
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
use tranquil_store::gauntlet::{
|
||||
Gauntlet, IndexBackedByDisk, Invariant, InvariantCtx, InvariantSet, Oracle, Scenario, Seed,
|
||||
config_for,
|
||||
Gauntlet, IndexBackedByDisk, Invariant, InvariantCtx, InvariantSet, Op, OpStream, Oracle,
|
||||
RetentionSecs, Scenario, Seed, config_for,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -101,6 +101,43 @@ async fn external_corruption_scenario_survives_many_seeds() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retention_time_travel_survives_many_seeds() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let failures: Vec<String> = futures::stream::iter(tranquil_store::sim_seed_range().map(Seed))
|
||||
.map(|seed| async move {
|
||||
let g = Gauntlet::new(config_for(Scenario::RetentionTimeTravel, seed))
|
||||
.expect("build gauntlet");
|
||||
let mut ops = g.generate_ops().into_vec();
|
||||
ops.push(Op::SyncEventLog);
|
||||
ops.push(Op::RunRetention {
|
||||
max_age_secs: RetentionSecs(604_800),
|
||||
});
|
||||
let report = g.run_with_ops(OpStream::from_vec(ops)).await;
|
||||
(seed, report)
|
||||
})
|
||||
.buffer_unordered(16)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|(_, r)| !r.is_clean())
|
||||
.map(|(seed, r)| {
|
||||
format!(
|
||||
"seed {}: {} violations\n {}",
|
||||
seed.0,
|
||||
r.violations.len(),
|
||||
r.violations
|
||||
.iter()
|
||||
.map(|v| format!("{}: {}", v.invariant, v.detail))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "long running, validates iris-class regression over many seeds"]
|
||||
async fn iris_class_regression_30_seeds() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use tranquil_store::FaultConfig;
|
||||
use tranquil_store::blockstore::GroupCommitConfig;
|
||||
use tranquil_store::gauntlet::{
|
||||
CollectionName, ConfigOverrides, DidSpaceSize, Gauntlet, GauntletConfig, GauntletReport,
|
||||
InvariantSet, IoBackend, KeySpaceSize, MaxFileSize, OpCount, OpInterval, OpWeights,
|
||||
RegressionRecord, RestartPolicy, RetentionMaxSecs, RunLimits, Scenario, Seed, ShardCount,
|
||||
SizeDistribution, StoreConfig, StoreOverrides, ValueBytes, WallMs, WorkloadModel,
|
||||
AdvanceMaxSecs, CollectionName, ConfigOverrides, DidSpaceSize, Gauntlet, GauntletConfig,
|
||||
GauntletReport, InvariantSet, IoBackend, KeySpaceSize, MaxFileSize, OpCount, OpInterval,
|
||||
OpWeights, RegressionRecord, RestartPolicy, RetentionMaxSecs, RunLimits, Scenario, Seed,
|
||||
ShardCount, SizeDistribution, StoreConfig, StoreOverrides, ValueBytes, WallMs, WorkloadModel,
|
||||
WriterConcurrency, config_for, farm,
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ fn fast_sanity_config(seed: Seed) -> GauntletConfig {
|
||||
key_space: KeySpaceSize(100),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(200),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -108,6 +109,86 @@ async fn full_stack_restart_port() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_corruption_detects_no_silent_mismatch() {
|
||||
let reports = farm::run_many(
|
||||
|seed| {
|
||||
let mut cfg = config_for(Scenario::ReadCorruption, seed);
|
||||
ConfigOverrides {
|
||||
op_count: Some(4_000),
|
||||
..ConfigOverrides::default()
|
||||
}
|
||||
.apply_to(&mut cfg);
|
||||
cfg
|
||||
},
|
||||
(0..4).map(Seed),
|
||||
);
|
||||
let failures: Vec<String> = reports
|
||||
.iter()
|
||||
.filter(|r| !r.is_clean())
|
||||
.map(|r| {
|
||||
format!(
|
||||
"seed {}: {}",
|
||||
r.seed.0,
|
||||
r.violations
|
||||
.iter()
|
||||
.map(|v| format!("{}: {}", v.invariant, v.detail))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mst_list_walk_validates_under_recoverable_faults() {
|
||||
let reports = farm::run_many(
|
||||
|seed| {
|
||||
let mut cfg = config_for(Scenario::BlockChurnRecoverable, seed);
|
||||
ConfigOverrides {
|
||||
op_count: Some(4_000),
|
||||
..ConfigOverrides::default()
|
||||
}
|
||||
.apply_to(&mut cfg);
|
||||
cfg
|
||||
},
|
||||
(0..6).map(Seed),
|
||||
);
|
||||
let failures: Vec<String> = reports
|
||||
.iter()
|
||||
.filter(|r| !r.is_clean())
|
||||
.map(|r| {
|
||||
format!(
|
||||
"seed {}: {}",
|
||||
r.seed.0,
|
||||
r.violations
|
||||
.iter()
|
||||
.map(|v| format!("{}: {}", v.invariant, v.detail))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_commit_synchronous_path_sanity() {
|
||||
let mut cfg = config_for(Scenario::InlineCommit, Seed(5));
|
||||
ConfigOverrides {
|
||||
op_count: Some(2_000),
|
||||
..ConfigOverrides::default()
|
||||
}
|
||||
.apply_to(&mut cfg);
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
assert!(
|
||||
report.restarts.0 >= 1,
|
||||
"InlineCommit must exercise at least one restart of the synchronous commit path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_idempotent_sanity() {
|
||||
let cfg = GauntletConfig {
|
||||
@@ -126,6 +207,7 @@ async fn compaction_idempotent_sanity() {
|
||||
key_space: KeySpaceSize(50),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(300),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -165,6 +247,7 @@ async fn no_orphan_files_sanity() {
|
||||
key_space: KeySpaceSize(80),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(200),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -209,6 +292,7 @@ async fn simulated_pristine_roundtrip() {
|
||||
key_space: KeySpaceSize(80),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(300),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -260,6 +344,7 @@ async fn firehose_fanout_pristine_smoke() {
|
||||
key_space: KeySpaceSize(100),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(60),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(2_000),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -312,6 +397,7 @@ async fn contended_readers_pristine_smoke() {
|
||||
key_space: KeySpaceSize(200),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(1_000),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -360,6 +446,7 @@ async fn contended_writers_pristine_smoke() {
|
||||
key_space: KeySpaceSize(500),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(1_000),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
@@ -491,6 +578,7 @@ async fn torn_pages_only_completes_within_budget() {
|
||||
key_space: KeySpaceSize(500),
|
||||
did_space: DidSpaceSize(32),
|
||||
retention_max_secs: RetentionMaxSecs(3600),
|
||||
advance_max_secs: AdvanceMaxSecs(7200),
|
||||
},
|
||||
op_count: OpCount(2_000),
|
||||
invariants: InvariantSet::REFCOUNT_CONSERVATION
|
||||
|
||||
@@ -5,6 +5,7 @@ use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, DataFileId, GroupCommitConfig,
|
||||
TranquilBlockStore,
|
||||
};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
fn test_cid_u32(seed: u32) -> [u8; 36] {
|
||||
let mut cid = [0u8; 36];
|
||||
@@ -50,7 +51,7 @@ fn with_runtime<F: FnOnce()>(f: F) {
|
||||
f();
|
||||
}
|
||||
|
||||
fn collect_all_dead(store: &TranquilBlockStore) -> HashSet<CidBytes> {
|
||||
fn collect_all_dead(store: &TranquilBlockStore<RealIO, SystemClock>) -> HashSet<CidBytes> {
|
||||
let result = store.collect_dead_blocks(0).unwrap();
|
||||
result
|
||||
.candidates
|
||||
@@ -59,7 +60,7 @@ fn collect_all_dead(store: &TranquilBlockStore) -> HashSet<CidBytes> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compact_all_sealed(store: &TranquilBlockStore) {
|
||||
fn compact_all_sealed(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
let files = store.list_data_files().unwrap();
|
||||
let sealed: Vec<DataFileId> = files
|
||||
.iter()
|
||||
@@ -72,7 +73,7 @@ fn compact_all_sealed(store: &TranquilBlockStore) {
|
||||
});
|
||||
}
|
||||
|
||||
fn verify_live_readable(store: &TranquilBlockStore, oracle: &GcOracle) {
|
||||
fn verify_live_readable(store: &TranquilBlockStore<RealIO, SystemClock>, oracle: &GcOracle) {
|
||||
oracle.live_seeds().iter().for_each(|&seed| {
|
||||
let cid = test_cid_u32(seed);
|
||||
let data = store
|
||||
@@ -92,7 +93,7 @@ fn verify_live_readable(store: &TranquilBlockStore, oracle: &GcOracle) {
|
||||
});
|
||||
}
|
||||
|
||||
fn verify_no_live_in_dead(store: &TranquilBlockStore, oracle: &GcOracle) {
|
||||
fn verify_no_live_in_dead(store: &TranquilBlockStore<RealIO, SystemClock>, oracle: &GcOracle) {
|
||||
let dead = collect_all_dead(store);
|
||||
oracle.live_seeds().iter().for_each(|&seed| {
|
||||
let cid = test_cid_u32(seed);
|
||||
@@ -150,7 +151,7 @@ impl GcOracle {
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_epoch(store: &TranquilBlockStore) {
|
||||
fn advance_epoch(store: &TranquilBlockStore<RealIO, SystemClock>) {
|
||||
store.apply_commit_blocking(vec![], vec![]).unwrap();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user