Compare commits

...
23 Commits
Author SHA1 Message Date
LewisandTangled 180de29984 fix(tranquil-pds): firehose car carries inductive proof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
max.andTangled 0455dc20bd fix(validation): discord usernames may contain periods at start or end 2026-04-25 09:59:12 +03:00
Lewis 2770b9b14a feat(tranquil-store): cargo-fuzz targets with asan+ubsan
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis d436597184 feat(tranquil-store): flaky-device scenario, jemalloc heap-prof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis 4cfca6d956 feat(tranquil-store): soak harness driving leak gate, signal tweaks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-23 08:39:16 +03:00
Lewis 98b94fb170 feat(tranquil-store): leak gate and metrics sampling for gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 21:00:17 +03:00
Lewis 4fe01cff72 feat(tranquil-store): sweep subcommand with axis override fan-out
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 17:23:15 +03:00
Lewis 00c9eb732f fix(tranquil-store): arc-counted cache handles, reader-eviction race
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 22:04:24 +03:00
Lewis 6d2d3b4be4 fix(tranquil-store): commit-marker batch replay, batch-boundary rotation
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 14:55:15 +03:00
Lewis 9d81e58803 fix(tranquil-store): no more orphan data files, recover torn-hint tails, header-safe resume
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 09:59:37 +03:00
Lewis 2afd075496 fix(tranquil-store): atomic record commits, hint-as-truth recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 21:00:54 +03:00
Hayden YoungandTangled c74bf967cf fix(auth): don't require 2fa for app passwords 2026-04-20 18:20:20 +03:00
Lewis 1285d5c675 fix(tranquil-store): bound writer fd usage across rotations
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 16:50:19 +03:00
Lewis c30d73cd4d test(tranquil-store): migrate some tests to gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 0fab8f2eb9 feat(tranquil-store): tranquil-gauntlet CLI, config overrides, profiles
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 57336fa124 feat(tranquil-store/gauntlet): new invariants & scenarios
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 10:25:07 +03:00
Lewis ace105899f feat(tranquil-store/gauntlet): concurrent executor, eventlog, fault recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 00:19:08 +03:00
Lewis c80a525e0d feat(tranquil-store/gauntlet): op surface, oracle, workload for eventlog & reads
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
Lewis 7edb76507b feat(tranquil-store/gauntlet): simulated io fault modes, shrinker, regression dump
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
LewisandTangled a20e4e05e6 fix(oauth): indigo client send empty assertions
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-17 15:18:26 +03:00
teqandTangled 3e7a199736 docs: nixos and binary cache 2026-04-17 01:32:30 +03:00
teqandTangled 7ed9c8ce59 feat(ci): supply cache by arg 2026-04-17 01:32:30 +03:00
teqandTangled b0c3402a82 feat(ci): add cachix publishing workflows 2026-04-17 01:32:30 +03:00
99 changed files with 15215 additions and 1065 deletions
+22
View File
@@ -25,6 +25,28 @@ fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-pr]
retries = 0
fail-fast = true
test-threads = "num-cpus"
slow-timeout = { period = "60s", terminate-after = 5 }
[[profile.gauntlet-pr.overrides]]
filter = "binary(gauntlet_smoke)"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-nightly]
retries = 0
fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "600s", terminate-after = 1 }
[profile.gauntlet-soak]
retries = 0
fail-fast = false
test-threads = 1
slow-timeout = { period = "5m", terminate-after = 1000 }
[test-groups]
serial-env-tests = { max-threads = 1 }
heavy-load-tests = { max-threads = 4 }
+1 -1
View File
@@ -1,4 +1,4 @@
/target
target/
.env
.direnv
result
@@ -0,0 +1,21 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push aarch64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
@@ -0,0 +1,28 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push x86_64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push "$CACHIX_CACHE_NAME"
- name: Build and push devShell
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix develop $SUBS --profile dev-profile -c true
cachix push "$CACHIX_CACHE_NAME" dev-profile
Generated
+80 -25
View File
@@ -1558,7 +1558,7 @@ checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
dependencies = [
"confique-macro",
"serde",
"toml",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
@@ -5050,7 +5050,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@@ -6201,6 +6201,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@@ -7144,6 +7153,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -7152,13 +7173,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -7177,6 +7207,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.5+spec-1.1.0"
@@ -7198,6 +7242,12 @@ dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.7+spec-1.1.0"
@@ -7405,7 +7455,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"anyhow",
"axum",
@@ -7456,7 +7506,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"anyhow",
"base32",
@@ -7479,7 +7529,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7493,7 +7543,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7511,7 +7561,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"confique",
"serde",
@@ -7519,7 +7569,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7535,7 +7585,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"chrono",
@@ -7552,7 +7602,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7568,7 +7618,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"bytes",
@@ -7579,7 +7629,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"chrono",
"futures",
@@ -7598,7 +7648,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"anyhow",
"axum",
@@ -7621,7 +7671,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7654,7 +7704,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7746,7 +7796,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"bytes",
"cid",
@@ -7758,7 +7808,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"backon",
@@ -7783,7 +7833,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"axum",
"futures",
@@ -7799,7 +7849,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"axum",
"clap",
@@ -7820,7 +7870,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"chrono",
@@ -7843,7 +7893,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"aws-config",
@@ -7860,12 +7910,13 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"async-trait",
"bytes",
"chrono",
"cid",
"clap",
"dashmap",
"fjall",
"flume 0.11.1",
@@ -7893,6 +7944,7 @@ dependencies = [
"thiserror 2.0.18",
"tikv-jemallocator",
"tokio",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"tranquil-db",
@@ -7907,7 +7959,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"anyhow",
"axum",
@@ -7929,7 +7981,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.5.5"
version = "0.5.6"
dependencies = [
"chrono",
"cid",
@@ -8854,6 +8906,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
+1 -1
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.5.5"
version = "0.5.6"
edition = "2024"
license = "AGPL-3.0-or-later"
+3 -1
View File
@@ -46,6 +46,8 @@ just test
just lint
```
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts (including the devshell) are available from our [binary cache](docs/install-nix.md#binary-cache).
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
@@ -59,6 +61,7 @@ podman-compose -f docker-compose.prod.yaml up -d
### Installation Guides
- [Nix](docs/install-nix.md)
- [Debian](docs/install-debian.md)
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.md)
@@ -99,4 +102,3 @@ This project is very grateful to [@nonbinary.computer](https://tangled.org/did:p
## License
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
+3 -3
View File
@@ -76,7 +76,7 @@ pub async fn link_signal_device(
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("Signal linking aborted due to server shutdown");
tracing::info!("server shutting down, aborting signal linking");
return;
}
r = link_result.completion => r,
@@ -84,10 +84,10 @@ pub async fn link_signal_device(
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("Signal device linked successfully");
tracing::info!("signal device linked");
} else {
tracing::warn!(
"Signal link completed but generation mismatch or already linked; discarding"
"discarding completed signal link, generation mismatch or already linked"
);
}
}
@@ -282,7 +282,7 @@ async fn process_messaging_channel_update(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
),
CommsChannel::Signal => ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
),
CommsChannel::Email => ApiError::InvalidEmail,
});
@@ -163,6 +163,7 @@ pub async fn create_session(
let email_2fa_enabled = row.email_2fa_enabled;
let is_legacy_login = has_totp || email_2fa_enabled;
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,
+1 -1
View File
@@ -299,7 +299,7 @@ impl DiscordSender {
"type": 1,
"options": [{
"name": "handle",
"description": "Your PDS handle (e.g. alice.example.com)",
"description": "Your PDS handle",
"type": 3,
"required": false
}]
+5 -3
View File
@@ -65,6 +65,9 @@ pub fn ensure_test_defaults() {
if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() {
env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
}
if env::var("TRANQUIL_LEXICON_OFFLINE").is_err() {
env::set_var("TRANQUIL_LEXICON_OFFLINE", "1");
}
}
TranquilConfig::builder()
.env()
@@ -394,7 +397,7 @@ impl TranquilConfig {
#[derive(Debug, Config)]
pub struct ServerConfig {
/// Public hostname of the PDS (e.g. `pds.example.com`).
/// Public hostname of the PDS, such as `pds.example.com`.
#[config(env = "PDS_HOSTNAME")]
pub hostname: String,
@@ -463,8 +466,7 @@ impl ServerConfig {
format!("https://{}", self.hostname)
}
/// Hostname without port suffix (e.g. `pds.example.com` from
/// `pds.example.com:443`).
/// Hostname without port suffix. Returns `pds.example.com` from `pds.example.com:443`.
pub fn hostname_without_port(&self) -> &str {
self.hostname.split(':').next().unwrap_or(&self.hostname)
}
+17 -27
View File
@@ -61,8 +61,6 @@ impl Drop for InFlightGuard<'_> {
impl DynamicRegistry {
pub fn new() -> Self {
let network_disabled =
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
Self {
store: RwLock::new(SchemaStore {
schemas: HashMap::new(),
@@ -70,11 +68,18 @@ impl DynamicRegistry {
}),
negative_cache: RwLock::new(HashMap::new()),
in_flight: RwLock::new(HashMap::new()),
network_disabled: AtomicBool::new(network_disabled),
network_disabled: AtomicBool::new(false),
}
}
#[allow(dead_code)]
pub fn from_env() -> Self {
let registry = Self::new();
let disabled =
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
registry.set_network_disabled(disabled);
registry
}
pub fn set_network_disabled(&self, disabled: bool) {
self.network_disabled.store(disabled, Ordering::Relaxed);
}
@@ -247,12 +252,10 @@ impl DynamicRegistry {
self.wait_for_leader(nsid).await;
match self.get_cached(nsid) {
Some(doc) => Ok(doc),
None if self.is_negative_cached(nsid) => {
Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
})
}
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
}),
None => Err(ResolveError::LeaderAborted {
nsid: nsid.to_string(),
}),
@@ -429,10 +432,7 @@ mod tests {
let served = result.expect("stale entry must be served when refresh fails");
assert_eq!(served.id, "pet.nel.flaky");
assert!(
registry
.get_entry("pet.nel.flaky")
.unwrap()
.is_fresh(),
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
"failed refresh must bump expiry so subsequent lookups skip the resolver"
);
assert!(
@@ -492,12 +492,7 @@ mod tests {
registry.insert_schema(doc);
registry.expire_now("pet.nel.refresh");
assert!(
!registry
.get_entry("pet.nel.refresh")
.unwrap()
.is_fresh()
);
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
let refreshed = registry
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
@@ -512,10 +507,7 @@ mod tests {
assert_eq!(refreshed.id, "pet.nel.refresh");
assert!(
registry
.get_entry("pet.nel.refresh")
.unwrap()
.is_fresh(),
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
"refresh must restore freshness"
);
}
@@ -601,9 +593,7 @@ mod tests {
assert!(registry.is_negative_cached("pet.nel.failHerd"));
}
async fn futures_collect<T>(
handles: Vec<tokio::task::JoinHandle<T>>,
) -> Vec<T> {
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
futures::future::join_all(handles)
.await
.into_iter()
+1 -1
View File
@@ -25,7 +25,7 @@ impl LexiconRegistry {
Self {
schemas: HashMap::new(),
#[cfg(feature = "resolve")]
dynamic: crate::dynamic::DynamicRegistry::new(),
dynamic: crate::dynamic::DynamicRegistry::from_env(),
}
}
@@ -138,21 +138,29 @@ pub async fn pushed_authorization_request(
}
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
if let (Some(assertion), Some(assertion_type)) =
(&request.client_assertion, &request.client_assertion_type)
{
let assertion = request
.client_assertion
.as_deref()
.filter(|s| !s.is_empty());
let assertion_type = request
.client_assertion_type
.as_deref()
.filter(|s| !s.is_empty());
let secret = request.client_secret.as_deref().filter(|s| !s.is_empty());
if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) {
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidRequest(
"Unsupported client_assertion_type".to_string(),
));
}
return Ok(ClientAuth::PrivateKeyJwt {
client_assertion: assertion.clone(),
client_assertion: assertion.to_string(),
});
}
if let Some(secret) = &request.client_secret {
if let Some(secret) = secret {
return Ok(ClientAuth::SecretPost {
client_secret: secret.clone(),
client_secret: secret.to_string(),
});
}
Ok(ClientAuth::None)
@@ -138,13 +138,17 @@ impl TokenRequest {
}
};
let client_auth = match (self.client_assertion, self.client_assertion_type) {
let assertion = self.client_assertion.filter(|s| !s.is_empty());
let assertion_type = self.client_assertion_type.filter(|s| !s.is_empty());
let client_secret = self.client_secret.filter(|s| !s.is_empty());
let client_auth = match (assertion, assertion_type) {
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
client_id: self.client_id,
assertion,
assertion_type,
},
_ => match self.client_secret {
_ => match client_secret {
Some(secret) => RequestClientAuth::SecretPost {
client_id: self.client_id,
client_secret: secret,
@@ -287,8 +287,6 @@ pub fn is_valid_discord_username(username: &str) -> bool {
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
&& !username.starts_with('.')
&& !username.ends_with('.')
&& !username.contains("..")
}
@@ -493,8 +491,6 @@ mod tests {
assert!(!is_valid_discord_username("Alice"));
assert!(!is_valid_discord_username("ALICE"));
assert!(!is_valid_discord_username("user-name"));
assert!(!is_valid_discord_username(".username"));
assert!(!is_valid_discord_username("username."));
assert!(!is_valid_discord_username("user..name"));
assert!(!is_valid_discord_username("user name"));
assert!(!is_valid_discord_username(
+22 -1
View File
@@ -156,6 +156,7 @@ pub enum Legacy2faOutcome {
}
pub struct Legacy2faContext {
pub is_app_password: bool,
pub email_2fa_enabled: bool,
pub has_totp: bool,
pub allow_legacy_login: bool,
@@ -163,7 +164,7 @@ pub struct Legacy2faContext {
impl Legacy2faContext {
pub fn requires_2fa(&self) -> bool {
self.email_2fa_enabled || self.has_totp
!self.is_app_password && (self.email_2fa_enabled || self.has_totp)
}
pub fn is_blocked(&self) -> bool {
@@ -418,6 +419,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: false,
allow_legacy_login: true,
@@ -427,11 +429,27 @@ mod tests {
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
#[tokio::test]
async fn test_process_flow_not_required_because_app_password() {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: true,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
#[tokio::test]
async fn test_process_flow_blocked() {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: false,
@@ -446,6 +464,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
@@ -460,6 +479,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
@@ -474,6 +494,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
+88 -7
View File
@@ -6,17 +6,19 @@ use crate::types::{Did, Handle, Nsid, Rkey};
use backon::{ExponentialBuilder, Retryable};
use bytes::Bytes;
use cid::Cid;
use jacquard_common::smol_str::SmolStr;
use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::Mst;
use jacquard_repo::mst::util::compute_cid;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::OwnedMutexGuard;
use tracing::error;
use tracing::{error, warn};
use tranquil_db_traits::SequenceNumber;
use uuid::Uuid;
@@ -236,13 +238,89 @@ pub async fn finalize_repo_write(
ApiError::InternalError(None)
})?;
let block_bytes = ctx.tracking_store.take_written_blocks();
let written_bytes = ctx.tracking_store.take_written_blocks();
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
let storage_for_diff = Arc::new(ctx.tracking_store.clone());
let original_settled = Mst::load(storage_for_diff.clone(), ctx.prev_data_cid, None);
let new_settled = Mst::load(storage_for_diff, new_mst_root, None);
let storage_for_proof = Arc::new(ctx.tracking_store.clone());
let original_settled = Mst::load(storage_for_proof.clone(), ctx.prev_data_cid, None);
let new_settled = Mst::load(storage_for_proof.clone(), new_mst_root, None);
let new_tree_cids: Vec<Cid> = block_bytes.keys().copied().collect();
let mut inverse_trace = new_settled.clone();
let mut non_invertible: Vec<String> = Vec::new();
let mut invert_errors: Vec<String> = Vec::new();
for op in params.ops.iter() {
let (collection, rkey) = match op {
RecordOp::Create {
collection, rkey, ..
}
| RecordOp::Update {
collection, rkey, ..
}
| RecordOp::Delete {
collection, rkey, ..
} => (collection, rkey),
};
let key = SmolStr::new(format!("{}/{}", collection, rkey));
let verified = match op {
RecordOp::Create { cid, .. } => VerifiedWriteOp::Create {
key,
cid: *cid.as_cid(),
},
RecordOp::Update { cid, prev, .. } => VerifiedWriteOp::Update {
key,
cid: *cid.as_cid(),
prev: *prev.as_cid(),
},
RecordOp::Delete { prev, .. } => VerifiedWriteOp::Delete {
key,
prev: *prev.as_cid(),
},
};
match inverse_trace.invert_op(verified.clone()).await {
Ok(true) => {}
Ok(false) => non_invertible.push(format!("{:?}", verified)),
Err(e) => invert_errors.push(format!("{:?} -> {:?}", verified, e)),
}
}
if !non_invertible.is_empty() {
warn!(
user_id = %params.user_id,
count = non_invertible.len(),
ops = ?non_invertible,
"firehose proof walk: ops not invertible on new MST, consumer will reject frame"
);
}
if !invert_errors.is_empty() {
warn!(
user_id = %params.user_id,
count = invert_errors.len(),
failures = ?invert_errors,
"firehose proof walk: invert_op errored, cover blocks may be incomplete"
);
}
let read_cid_set: HashSet<Cid> = ctx.tracking_store.get_read_cids().into_iter().collect();
let missing_read_cids: Vec<Cid> = read_cid_set
.iter()
.copied()
.filter(|cid| !written_bytes.contains_key(cid))
.collect();
let mut relevant: BTreeMap<Cid, Bytes> = BTreeMap::new();
if !missing_read_cids.is_empty() {
let fetched = ctx
.tracking_store
.get_many(&missing_read_cids)
.await
.map_err(|e| {
error!("fetch cover read bytes: {e}");
ApiError::InternalError(None)
})?;
for (cid, maybe) in missing_read_cids.into_iter().zip(fetched) {
if let Some(bytes) = maybe {
relevant.insert(cid, bytes);
}
}
}
let obsolete_cids = match original_settled.diff(&new_settled).await {
Ok(diff) => {
@@ -263,6 +341,9 @@ pub async fn finalize_repo_write(
}
};
let mut block_bytes = written_bytes;
block_bytes.extend(relevant);
let result = commit_and_log(
state,
CommitParams {
@@ -111,7 +111,7 @@ async fn mst_blocks_survive_full_store_reopen() {
let max_file_size = store
.list_data_files()
.ok()
.and_then(|_| Some(4 * 1024 * 1024u64))
.map(|_| 4 * 1024 * 1024u64)
.unwrap_or(4 * 1024 * 1024);
let reopened_missing = tokio::task::spawn_blocking(move || {
@@ -0,0 +1,640 @@
mod common;
mod mst_verify;
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use cid::Cid;
use common::*;
use jacquard_common::smol_str::SmolStr;
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
use mst_verify::{extract_event_blocks, inline_to_store};
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
use tranquil_types::Did;
async fn new_commit_data_cid(
storage: &Arc<MemoryBlockStore>,
commit_cid: &Cid,
) -> Result<Cid, String> {
let commit_bytes = storage
.get(commit_cid)
.await
.map_err(|e| format!("get commit: {e:?}"))?
.ok_or_else(|| format!("CAR missing commit block {commit_cid}"))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
Ok(*commit.data())
}
fn ops_json(event: &SequencedEvent) -> Result<&Vec<Value>, String> {
event
.ops
.as_ref()
.and_then(|v| v.as_array())
.ok_or_else(|| "event.ops not an array".into())
}
fn parse_op_to_verified(op: &Value) -> Result<VerifiedWriteOp, String> {
let action = op["action"].as_str().ok_or("op.action missing")?;
let path = op["path"].as_str().ok_or("op.path missing")?;
let key = SmolStr::new(path);
match action {
"create" => {
let cid_str = op["cid"].as_str().ok_or("create missing cid")?;
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
Ok(VerifiedWriteOp::Create { key, cid })
}
"update" => {
let cid_str = op["cid"].as_str().ok_or("update missing cid")?;
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
let prev_str = op["prev"].as_str().ok_or("update missing prev")?;
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
Ok(VerifiedWriteOp::Update { key, cid, prev })
}
"delete" => {
let prev_str = op["prev"].as_str().ok_or("delete missing prev")?;
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
Ok(VerifiedWriteOp::Delete { key, prev })
}
other => Err(format!("unknown op action: {other}")),
}
}
async fn verify_inductive_forward(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
let prev_data_cid = event
.prev_data_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
let commit_cid = event
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing commit_cid".to_string())?;
let storage = inline_to_store(extract_event_blocks(event)?);
let expected_new_data = new_commit_data_cid(&storage, &commit_cid).await?;
let mut mst = Mst::load(storage.clone(), prev_data_cid, None);
for op_value in ops_json(event)? {
let action = op_value["action"].as_str().ok_or("op.action missing")?;
let path = op_value["path"].as_str().ok_or("op.path missing")?;
match action {
"create" | "update" => {
let cid = Cid::from_str(op_value["cid"].as_str().ok_or("op.cid missing")?)
.map_err(|e| format!("parse op.cid: {e:?}"))?;
mst = mst
.add(path, cid)
.await
.map_err(|e| format!("mst.add({path}): {e:?}"))?;
}
"delete" => {
mst = mst
.delete(path)
.await
.map_err(|e| format!("mst.delete({path}): {e:?}"))?;
}
other => return Err(format!("unknown op action: {other}")),
}
}
let computed = mst
.persist()
.await
.map_err(|e| format!("mst.persist: {e:?}"))?;
Ok((expected_new_data, computed))
}
async fn verify_inductive_inverse(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
let prev_data_cid = event
.prev_data_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
let commit_cid = event
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing commit_cid".to_string())?;
let storage = inline_to_store(extract_event_blocks(event)?);
let new_data_cid = new_commit_data_cid(&storage, &commit_cid).await?;
let mut mst = Mst::load(storage.clone(), new_data_cid, None);
for op_value in ops_json(event)? {
let verified = parse_op_to_verified(op_value)?;
let inverted = mst
.invert_op(verified.clone())
.await
.map_err(|e| format!("invert_op({verified:?}): {e:?}"))?;
if !inverted {
return Err(format!("op not invertible: {verified:?}"));
}
}
let computed_prev = mst
.get_pointer()
.await
.map_err(|e| format!("get_pointer: {e:?}"))?;
Ok((prev_data_cid, computed_prev))
}
fn report_failures(total: usize, failures: &[String], mode: &str) {
assert!(
failures.is_empty(),
"{} of {total} {mode} commit events failed inductive verification:\n - {}",
failures.len(),
failures.join("\n - "),
);
}
async fn apply_writes_batch(client: &reqwest::Client, token: &str, did: &str, writes: Vec<Value>) {
let payload = json!({ "repo": did, "writes": writes });
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(token)
.json(&payload)
.send()
.await
.expect("applyWrites request failed");
assert_eq!(
res.status(),
StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
}
async fn create_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": {
"$type": col,
"text": format!("post {rkey}"),
"createdAt": now,
}
}))
.send()
.await
.expect("createRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "createRecord failed");
}
async fn put_record(
client: &reqwest::Client,
token: &str,
did: &str,
col: &str,
rkey: &str,
text: &str,
) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": {
"$type": col,
"text": text,
"createdAt": now,
}
}))
.send()
.await
.expect("putRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "putRecord failed");
}
async fn delete_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({ "repo": did, "collection": col, "rkey": rkey }))
.send()
.await
.expect("deleteRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "deleteRecord failed");
}
const COLLECTION: &str = "app.bsky.feed.post";
fn rkey_for(prefix: &str, i: usize) -> String {
format!("3k{prefix}{:08}", i)
}
async fn our_commit_events(did: &str) -> Vec<SequencedEvent> {
let repos = get_test_repos().await;
let typed_did = Did::new(did.to_string()).unwrap();
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
.await
.expect("get_events_since_seq");
events
.into_iter()
.filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit)
.collect()
}
#[tokio::test]
async fn inductive_forward_verifies_delete_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
const N_CREATE: usize = 200;
let all_writes: Vec<Value> = (0..N_CREATE)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("del", i),
"value": {
"$type": COLLECTION,
"text": format!("record {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in all_writes.chunks(50) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
let delete_indices: Vec<usize> = (10..N_CREATE).step_by(7).collect();
for i in &delete_indices {
delete_record(&client, &token, &did, COLLECTION, &rkey_for("del", *i)).await;
}
let our = our_commit_events(&did).await;
let delete_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("delete")))
.unwrap_or(false)
})
.collect();
assert_eq!(delete_events.len(), delete_indices.len());
let mut failures = Vec::new();
for e in &delete_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(delete_events.len(), &failures, "delete forward");
}
#[tokio::test]
async fn inductive_forward_verifies_create_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
for i in 0..60usize {
create_record(&client, &token, &did, COLLECTION, &rkey_for("cre", i)).await;
}
let our = our_commit_events(&did).await;
let create_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().all(|op| op["action"].as_str() == Some("create")))
.unwrap_or(false)
&& e.prev_data_cid.is_some()
})
.collect();
assert!(!create_events.is_empty());
let mut failures = Vec::new();
for e in &create_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(create_events.len(), &failures, "create forward");
}
#[tokio::test]
async fn inductive_forward_verifies_update_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let creates: Vec<Value> = (0..80)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("upd", i),
"value": {
"$type": COLLECTION,
"text": format!("original {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in creates.chunks(40) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
for i in (0..80).step_by(3) {
put_record(
&client,
&token,
&did,
COLLECTION,
&rkey_for("upd", i),
&format!("updated {i}"),
)
.await;
}
let our = our_commit_events(&did).await;
let update_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("update")))
.unwrap_or(false)
})
.collect();
assert!(!update_events.is_empty());
let mut failures = Vec::new();
for e in &update_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(update_events.len(), &failures, "update forward");
}
#[tokio::test]
async fn inductive_forward_verifies_mixed_applywrites() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let seed: Vec<Value> = (0..120)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("mix", i),
"value": {
"$type": COLLECTION,
"text": format!("seed {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in seed.chunks(40) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
let mixed: Vec<Value> = (0..40)
.flat_map(|i| {
vec![
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("mxc", i),
"value": {
"$type": COLLECTION,
"text": format!("new {i}"),
"createdAt": now,
}
}),
json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": COLLECTION,
"rkey": rkey_for("mix", i),
"value": {
"$type": COLLECTION,
"text": format!("updated-mix {i}"),
"createdAt": now,
}
}),
json!({
"$type": "com.atproto.repo.applyWrites#delete",
"collection": COLLECTION,
"rkey": rkey_for("mix", i + 60),
}),
]
})
.collect();
apply_writes_batch(&client, &token, &did, mixed).await;
let our = our_commit_events(&did).await;
let last = our
.iter()
.rfind(|e| e.prev_data_cid.is_some())
.expect("at least one non-genesis commit");
let actions: Vec<&str> = ops_json(last)
.unwrap()
.iter()
.filter_map(|op| op["action"].as_str())
.collect();
assert!(actions.contains(&"create"));
assert!(actions.contains(&"update"));
assert!(actions.contains(&"delete"));
let (exp, got) = verify_inductive_forward(last)
.await
.expect("mixed applyWrites forward verify");
assert_eq!(exp, got, "mixed applyWrites commit forward-verify mismatch");
}
#[tokio::test]
async fn inductive_inverse_verifies_every_commit() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let seed: Vec<Value> = (0..100)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("inv", i),
"value": {
"$type": COLLECTION,
"text": format!("seed {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in seed.chunks(50) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
for i in (0..100).step_by(5) {
put_record(
&client,
&token,
&did,
COLLECTION,
&rkey_for("inv", i),
&format!("upd {i}"),
)
.await;
}
for i in (2..100).step_by(11) {
delete_record(&client, &token, &did, COLLECTION, &rkey_for("inv", i)).await;
}
let our = our_commit_events(&did).await;
let non_genesis: Vec<&SequencedEvent> = our
.iter()
.filter(|e| e.prev_data_cid.is_some() && ops_json(e).is_ok())
.collect();
assert!(!non_genesis.is_empty());
let mut failures = Vec::new();
for e in &non_genesis {
match verify_inductive_inverse(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: inverse root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(non_genesis.len(), &failures, "any inverse");
}
#[tokio::test]
async fn prev_cid_chain_walks_to_genesis() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
for i in 0..8 {
create_record(&client, &token, &did, COLLECTION, &rkey_for("cha", i)).await;
}
let our = our_commit_events(&did).await;
assert!(our.len() >= 2);
let last = our.last().unwrap();
let mut current_prev: Option<Cid> = last.prev_cid.as_ref().and_then(|c| c.to_cid());
let head_commit_cid = last
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.expect("head commit_cid");
let by_commit: BTreeMap<Cid, &SequencedEvent> = our
.iter()
.filter_map(|e| {
e.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.map(|c| (c, e))
})
.collect();
let mut visited = 1;
while let Some(prev) = current_prev {
let e = by_commit
.get(&prev)
.unwrap_or_else(|| panic!("prev commit {prev} missing from event list"));
visited += 1;
current_prev = e.prev_cid.as_ref().and_then(|c| c.to_cid());
}
assert!(
visited >= 2,
"chain too short: visited={visited}, head_commit={head_commit_cid}"
);
assert_eq!(
visited,
our.len(),
"chain did not reach genesis: walked {visited}, have {}",
our.len()
);
}
#[tokio::test]
async fn record_bytes_present_in_car_for_creates() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let writes: Vec<Value> = (0..5)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("rec", i),
"value": {
"$type": COLLECTION,
"text": format!("rec {i}"),
"createdAt": now,
}
})
})
.collect();
apply_writes_batch(&client, &token, &did, writes).await;
let our = our_commit_events(&did).await;
let latest = our.iter().rfind(|e| e.prev_data_cid.is_some()).unwrap();
let inline = extract_event_blocks(latest).unwrap();
let have_cids: std::collections::HashSet<Cid> = inline
.iter()
.map(|b| Cid::read_bytes(b.cid_bytes.as_slice()).unwrap())
.collect();
for op in ops_json(latest).unwrap() {
if op["action"].as_str() == Some("create")
&& let Some(cid_str) = op["cid"].as_str()
{
let cid = Cid::from_str(cid_str).unwrap();
assert!(
have_cids.contains(&cid),
"create op record CID {cid} not present in CAR inline blocks"
);
}
}
}
@@ -0,0 +1,26 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use bytes::Bytes;
use cid::Cid;
use jacquard_repo::storage::MemoryBlockStore;
use tranquil_db_traits::{EventBlockInline, EventBlocks, SequencedEvent};
pub fn extract_event_blocks(event: &SequencedEvent) -> Result<&[EventBlockInline], String> {
match event.blocks.as_ref() {
Some(EventBlocks::Inline(v)) => Ok(v.as_slice()),
Some(EventBlocks::LegacyCids(_)) => Err("legacy cids, not inline".into()),
None => Err("event missing blocks".into()),
}
}
pub fn inline_to_store(inline: &[EventBlockInline]) -> Arc<MemoryBlockStore> {
let map: BTreeMap<Cid, Bytes> = inline
.iter()
.map(|b| {
let cid = Cid::read_bytes(b.cid_bytes.as_slice()).expect("valid cid bytes");
(cid, Bytes::from(b.data.clone()))
})
.collect();
Arc::new(MemoryBlockStore::new_from_blocks(map))
}
+31
View File
@@ -187,6 +187,37 @@ async fn test_par_and_authorize() {
);
}
#[tokio::test]
async fn test_par_public_client_empty_assertion_fields() {
let url = base_url().await;
let client = client();
let redirect_uri = "https://nels.evil.oauth.pet/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", "test-state"),
("client_assertion", ""),
("client_assertion_type", ""),
])
.send()
.await
.unwrap();
assert_eq!(
par_res.status(),
StatusCode::CREATED,
"PAR with empty assertion fields from a public client should succeed"
);
}
#[tokio::test]
async fn test_full_oauth_flow() {
let url = base_url().await;
+6 -1
View File
@@ -88,12 +88,17 @@ fn test_signal_username_validation() {
assert!(is_valid_signal_username("bob_smith.99"));
assert!(is_valid_signal_username("user123.42"));
assert!(is_valid_signal_username("lu1.01"));
assert!(is_valid_signal_username("abc.00"));
assert!(is_valid_signal_username("a_very_long_username_here.55"));
assert!(is_valid_signal_username("alice.123"));
assert!(is_valid_signal_username("alice.999999999"));
assert!(is_valid_signal_username("alice.18446744073709551615"));
assert!(!is_valid_signal_username("alice"));
assert!(!is_valid_signal_username("alice.1"));
assert!(!is_valid_signal_username("alice.001"));
assert!(!is_valid_signal_username("abc.00"));
assert!(!is_valid_signal_username("alice.0"));
assert!(!is_valid_signal_username("alice.999999999999999999999"));
assert!(!is_valid_signal_username(".01"));
assert!(!is_valid_signal_username("ab.01"));
assert!(!is_valid_signal_username(""));
+18 -13
View File
@@ -33,15 +33,11 @@ impl SignalUsername {
pub fn parse(username: &str) -> Result<Self, InvalidSignalUsername> {
let reject = || Err(InvalidSignalUsername(username.to_string()));
if username.len() < 6 || username.len() > 35 {
return reject();
}
let Some((base, discriminator)) = username.rsplit_once('.') else {
return reject();
};
if base.len() < 3 || base.len() > 32 {
if !matches!(base.len(), 3..=32) {
return reject();
}
@@ -53,7 +49,7 @@ impl SignalUsername {
return reject();
}
if discriminator.len() != 2 || !discriminator.chars().all(|c| c.is_ascii_digit()) {
if !is_valid_discriminator(discriminator) {
return reject();
}
@@ -65,6 +61,19 @@ impl SignalUsername {
}
}
fn is_valid_discriminator(s: &str) -> bool {
if !s.chars().all(|c| c.is_ascii_digit()) {
return false;
}
if !matches!(s.len(), 2..=20) {
return false;
}
if s.len() > 2 && s.starts_with('0') {
return false;
}
s.parse::<u64>().is_ok_and(|n| n != 0)
}
impl fmt::Display for SignalUsername {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
@@ -113,11 +122,7 @@ pub struct MessageTooLong {
impl fmt::Display for MessageTooLong {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"message body too long: {} bytes (max {})",
self.len, self.max
)
write!(f, "message body is {} bytes, max {}", self.len, self.max)
}
}
@@ -368,13 +373,13 @@ impl SignalClient {
let req = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("signal worker shutting down (cancellation)");
tracing::info!("signal worker cancelled, shutting down");
break;
}
msg = rx.recv() => match msg {
Some(r) => r,
None => {
tracing::info!("signal worker shutting down (channel closed)");
tracing::info!("signal worker channel closed, shutting down");
break;
}
},
+1 -1
View File
@@ -1050,7 +1050,7 @@ impl ContentsStore for FjallSignalStore {
.and_then(|v| match <[u8; 32]>::try_from(v.as_ref()) {
Ok(arr) => Some(ProfileKey { bytes: arr }),
Err(_) => {
warn!(%uuid, len = v.len(), "corrupted profile key (expected 32 bytes)");
warn!(%uuid, len = v.len(), "corrupted profile key, expected 32 bytes");
None
}
}))
+1 -1
View File
@@ -1117,7 +1117,7 @@ impl ContentsStore for PgSignalStore {
Some(r) => match <[u8; 32]>::try_from(r.key.as_slice()) {
Ok(arr) => Some(ProfileKey { bytes: arr }),
Err(_) => {
warn!(%uuid, len = r.key.len(), "corrupted profile key (expected 32 bytes)");
warn!(%uuid, len = r.key.len(), "corrupted profile key, expected 32 bytes");
None
}
},
+12 -2
View File
@@ -35,9 +35,20 @@ rayon = "1"
smallvec = "1"
uuid = { workspace = true }
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 }
libc = "0.2"
[features]
test-harness = ["dep:tempfile"]
gauntlet-cli = ["test-harness", "dep:clap", "dep:toml", "dep:tracing-subscriber"]
gauntlet-jemalloc-prof = []
[[bin]]
name = "tranquil-gauntlet"
path = "src/bin/tranquil_gauntlet.rs"
required-features = ["gauntlet-cli"]
[dev-dependencies]
tranquil-store = { path = ".", features = ["test-harness"] }
@@ -51,9 +62,8 @@ tranquil-db = { workspace = true }
sqlx = { workspace = true }
k256 = { workspace = true }
rand = { workspace = true }
tikv-jemallocator = "0.6"
tikv-jemallocator = { version = "0.6", features = ["profiling", "unprefixed_malloc_on_supported_platforms"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
libc = "0.2"
[[bench]]
name = "blockstore"
+4
View File
@@ -0,0 +1,4 @@
target/
artifacts/
coverage/
Cargo.lock
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "tranquil-store-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
tranquil-store = { path = "..", features = ["test-harness"] }
tempfile = "3"
tokio = { version = "1", features = ["rt", "time", "macros", "sync"] }
[[bin]]
name = "decode_block_record"
path = "fuzz_targets/decode_block_record.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_hint_record"
path = "fuzz_targets/decode_hint_record.rs"
test = false
doc = false
bench = false
[[bin]]
name = "segment_scan"
path = "fuzz_targets/segment_scan.rs"
test = false
doc = false
bench = false
[[bin]]
name = "metastore_key_codec"
path = "fuzz_targets/metastore_key_codec.rs"
test = false
doc = false
bench = false
[[bin]]
name = "gauntlet_micro"
path = "fuzz_targets/gauntlet_micro.rs"
test = false
doc = false
bench = false
[workspace]
@@ -0,0 +1 @@

@@ -0,0 +1 @@
BADX
@@ -0,0 +1 @@
TQEV
@@ -0,0 +1,28 @@
#![no_main]
use std::path::Path;
use libfuzzer_sys::fuzz_target;
use tranquil_store::blockstore::{BlockOffset, decode_block_record};
use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO};
fuzz_target!(|data: &[u8]| {
let sim = SimulatedIO::new(0, FaultConfig::none());
let opts = OpenOptions {
read: true,
write: true,
create: true,
truncate: false,
};
let fd = match sim.open(Path::new("/fuzz/block.tqb"), opts) {
Ok(fd) => fd,
Err(_) => return,
};
if !data.is_empty() {
let _ = sim.write_all_at(fd, 0, data);
let _ = sim.sync(fd);
}
let file_size = data.len() as u64;
let _ = decode_block_record(&sim, fd, BlockOffset::new(0), file_size);
let _ = sim.close(fd);
});
@@ -0,0 +1,41 @@
#![no_main]
use std::path::Path;
use libfuzzer_sys::fuzz_target;
use tranquil_store::blockstore::{HintOffset, decode_hint_record};
use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO};
fuzz_target!(|data: &[u8]| {
let sim = SimulatedIO::new(0, FaultConfig::none());
let opts = OpenOptions {
read: true,
write: true,
create: true,
truncate: false,
};
let fd = match sim.open(Path::new("/fuzz/hint.tqh"), opts) {
Ok(fd) => fd,
Err(_) => return,
};
if !data.is_empty() {
let _ = sim.write_all_at(fd, 0, data);
let _ = sim.sync(fd);
}
let file_size = data.len() as u64;
let cursor = std::cell::Cell::new(0u64);
std::iter::from_fn(|| {
if cursor.get() >= file_size {
return None;
}
match decode_hint_record(&sim, fd, HintOffset::new(cursor.get()), file_size) {
Ok(Some(_)) => {
cursor.set(cursor.get() + 64);
Some(())
}
_ => None,
}
})
.for_each(|()| {});
let _ = sim.close(fd);
});
@@ -0,0 +1,109 @@
#![no_main]
use std::sync::OnceLock;
use arbitrary::{Arbitrary, Unstructured};
use libfuzzer_sys::fuzz_target;
use tokio::runtime::Runtime;
use tranquil_store::blockstore::GroupCommitConfig;
use tranquil_store::gauntlet::{
CollectionName, DidSpaceSize, Gauntlet, GauntletConfig, InvariantSet, IoBackend, KeySpaceSize,
MaxFileSize, Op, OpCount, OpInterval, OpStream, OpWeights, RecordKey, RestartPolicy,
RetentionMaxSecs, RunLimits, Seed, ShardCount, SizeDistribution, StoreConfig, ValueBytes,
ValueSeed, WallMs, WorkloadModel, WriterConcurrency,
};
#[derive(Arbitrary, Debug)]
enum FuzzOp {
Add { rkey: u8, value: u16 },
Delete { rkey: u8 },
Compact,
Checkpoint,
Read { rkey: u8 },
ReadBlock { value: u16 },
}
const COLLECTION: &str = "app.bsky.feed.post";
const MAX_OPS: usize = 128;
fn to_op(fuzz_op: FuzzOp) -> Op {
match fuzz_op {
FuzzOp::Add { rkey, value } => Op::AddRecord {
collection: CollectionName(COLLECTION.to_string()),
rkey: RecordKey(format!("k{rkey:03}")),
value_seed: ValueSeed(u32::from(value)),
},
FuzzOp::Delete { rkey } => Op::DeleteRecord {
collection: CollectionName(COLLECTION.to_string()),
rkey: RecordKey(format!("k{rkey:03}")),
},
FuzzOp::Compact => Op::Compact,
FuzzOp::Checkpoint => Op::Checkpoint,
FuzzOp::Read { rkey } => Op::ReadRecord {
collection: CollectionName(COLLECTION.to_string()),
rkey: RecordKey(format!("k{rkey:03}")),
},
FuzzOp::ReadBlock { value } => Op::ReadBlock {
value_seed: ValueSeed(u32::from(value)),
},
}
}
fn tiny_config() -> GauntletConfig {
GauntletConfig {
seed: Seed(0),
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights::default(),
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: vec![CollectionName(COLLECTION.to_string())],
key_space: KeySpaceSize(256),
did_space: DidSpaceSize(8),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(0),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::READ_AFTER_WRITE,
limits: RunLimits {
max_wall_ms: Some(WallMs(2_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(32)),
store: StoreConfig {
max_file_size: MaxFileSize(4096),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn shared_runtime() -> &'static Runtime {
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("build tokio runtime")
})
}
fuzz_target!(|data: &[u8]| {
if data.is_empty() {
return;
}
let mut u = Unstructured::new(data);
let ops: Vec<FuzzOp> = match Vec::<FuzzOp>::arbitrary(&mut u) {
Ok(ops) => ops.into_iter().take(MAX_OPS).collect(),
Err(_) => return,
};
if ops.is_empty() {
return;
}
let stream = OpStream::from_vec(ops.into_iter().map(to_op).collect());
let cfg = tiny_config();
let gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
let _ = shared_runtime().block_on(gauntlet.run_with_ops(stream));
});
@@ -0,0 +1,77 @@
#![no_main]
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use tranquil_store::metastore::encoding::{KeyBuilder, KeyReader};
#[derive(Arbitrary, Debug, PartialEq, Eq)]
enum Field {
U64(u64),
I64(i64),
U32(u32),
U16(u16),
Bool(bool),
Bytes(Vec<u8>),
String(String),
}
fn append(builder: KeyBuilder, field: &Field) -> KeyBuilder {
match field {
Field::U64(v) => builder.u64(*v),
Field::I64(v) => builder.i64(*v),
Field::U32(v) => builder.u32(*v),
Field::U16(v) => builder.u16(*v),
Field::Bool(v) => builder.bool(*v),
Field::Bytes(v) => builder.bytes(v),
Field::String(v) => builder.string(v),
}
}
fn consume(reader: &mut KeyReader<'_>, field: &Field) -> bool {
match field {
Field::U64(v) => reader.u64() == Some(*v),
Field::I64(v) => reader.i64() == Some(*v),
Field::U32(v) => reader.u32() == Some(*v),
Field::U16(v) => reader.u16() == Some(*v),
Field::Bool(v) => reader.bool() == Some(*v),
Field::Bytes(v) => reader.bytes().as_deref() == Some(v.as_slice()),
Field::String(v) => reader.string().as_deref() == Some(v.as_str()),
}
}
#[derive(Arbitrary, Debug)]
enum Mode {
Roundtrip(Vec<Field>),
Raw(Vec<u8>),
}
fuzz_target!(|mode: Mode| {
match mode {
Mode::Roundtrip(fields) => {
let encoded1 = fields.iter().fold(KeyBuilder::new(), append).build();
let mut reader = KeyReader::new(encoded1.as_slice());
let all_match = fields.iter().all(|f| consume(&mut reader, f));
assert!(all_match, "roundtrip decode failed");
assert!(reader.is_empty(), "trailing bytes after decode");
let encoded2 = fields.iter().fold(KeyBuilder::new(), append).build();
assert_eq!(
encoded1.as_slice(),
encoded2.as_slice(),
"encoding not deterministic",
);
}
Mode::Raw(data) => {
let mut reader = KeyReader::new(&data);
let _ = reader.u64();
let _ = reader.i64();
let _ = reader.u32();
let _ = reader.u16();
let _ = reader.bool();
let _ = reader.bytes();
let _ = reader.string();
let _ = reader.tag();
}
}
});
@@ -0,0 +1,31 @@
#![no_main]
use std::path::Path;
use libfuzzer_sys::fuzz_target;
use tranquil_store::eventlog::SegmentReader;
const FUZZ_MAX_PAYLOAD: u32 = 1 << 20;
use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO};
fuzz_target!(|data: &[u8]| {
let sim = SimulatedIO::new(0, FaultConfig::none());
let opts = OpenOptions {
read: true,
write: true,
create: true,
truncate: false,
};
let fd = match sim.open(Path::new("/fuzz/segment.tqe"), opts) {
Ok(fd) => fd,
Err(_) => return,
};
if !data.is_empty() {
let _ = sim.write_all_at(fd, 0, data);
let _ = sim.sync(fd);
}
if let Ok(reader) = SegmentReader::open(&sim, fd, FUZZ_MAX_PAYLOAD) {
reader.for_each(|_result| {});
}
let _ = sim.close(fd);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
use cid::Cid;
use multihash::Multihash;
use sha2::{Digest, Sha256};
use super::data_file::CID_SIZE;
pub const DAG_CBOR_CODEC: u64 = 0x71;
pub const SHA2_256_CODE: u64 = 0x12;
pub fn hash_to_cid(data: &[u8]) -> Cid {
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
let mh = Multihash::wrap(SHA2_256_CODE, &digest)
.expect("SHA-256 digest is 32 bytes, well within multihash capacity");
Cid::new_v1(DAG_CBOR_CODEC, mh)
}
pub fn hash_to_cid_bytes(data: &[u8]) -> [u8; CID_SIZE] {
let raw = hash_to_cid(data).to_bytes();
raw.try_into()
.expect("CIDv1 + DAG-CBOR + SHA-256 always encodes to CID_SIZE bytes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_to_cid_bytes_is_deterministic() {
let a = hash_to_cid_bytes(b"hello");
let b = hash_to_cid_bytes(b"hello");
assert_eq!(a, b);
}
#[test]
fn hash_to_cid_bytes_diverges_on_single_byte_change() {
assert_ne!(hash_to_cid_bytes(b"abc"), hash_to_cid_bytes(b"abd"));
}
#[test]
fn hash_to_cid_and_bytes_agree() {
let cid = hash_to_cid(b"payload");
let raw: [u8; CID_SIZE] = cid.to_bytes().try_into().expect("36 bytes");
assert_eq!(raw, hash_to_cid_bytes(b"payload"));
}
}
@@ -68,8 +68,8 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
return Err(CompactionError::ActiveFileCannotBeCompacted);
}
let source_fd = manager.open_for_read(source_file_id)?;
let source_size = manager.io().file_size(source_fd)?;
let source_handle = manager.open_for_read(source_file_id)?;
let source_size = manager.io().file_size(source_handle.fd())?;
let new_file_id = file_ids.allocate();
@@ -77,7 +77,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
manager,
index,
source_file_id,
source_fd,
source_handle.fd(),
new_file_id,
current_epoch,
grace_period_ms,
@@ -102,6 +102,13 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
.io()
.delete(&hint_file_path(manager.data_dir(), source_file_id))
.ok();
if live_count == 0 {
manager.delete_data_file(new_file_id).ok();
manager
.io()
.delete(&hint_file_path(manager.data_dir(), new_file_id))
.ok();
}
manager.io().sync_dir(manager.data_dir())?;
let reclaimed_bytes = source_size.saturating_sub(new_size);
@@ -141,8 +148,8 @@ fn stream_compact<S: StorageIO>(
let mut reader = DataFileReader::open(manager.io(), source_fd)?;
let now = crate::wall_clock_ms();
let new_fd = manager.open_for_append(new_file_id)?;
let mut writer = DataFileWriter::new(manager.io(), new_fd, new_file_id)?;
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())?;
@@ -153,7 +160,7 @@ fn stream_compact<S: StorageIO>(
let mut live_count: u64 = 0;
let mut dead_count: u64 = 0;
reader.try_for_each(|r| {
let scan_result = reader.try_for_each(|r| {
let record = r?;
match record {
ReadBlockRecord::Valid {
@@ -192,11 +199,34 @@ fn stream_compact<S: StorageIO>(
ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. } => {}
}
Ok::<_, CompactionError>(())
})?;
});
writer.sync()?;
hint_writer.sync()?;
manager.io().sync_dir(manager.data_dir())?;
let record_count =
u32::try_from((live_count as u128).saturating_add(dead_count as u128)).unwrap_or(u32::MAX);
let writer_position = writer.position();
let finalize_result = scan_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)
});
let _ = manager.io().close(hint_fd);
finalize_result?;
let new_size = writer.position().raw();
@@ -11,7 +11,7 @@ use crate::fsync_order::PostBlockstoreHook;
use super::BlocksSynced;
use crate::io::{FileId, OpenOptions, StorageIO};
use super::data_file::{CID_SIZE, DataFileWriter};
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::manager::DataFileManager;
@@ -106,6 +106,10 @@ pub enum CommitError {
Io(Arc<io::Error>),
Index(String),
ChannelClosed,
VerifyFailed {
file_id: DataFileId,
offset: BlockOffset,
},
}
impl std::fmt::Display for CommitError {
@@ -114,6 +118,11 @@ impl std::fmt::Display for CommitError {
Self::Io(e) => write!(f, "io: {}", e.as_ref()),
Self::Index(e) => write!(f, "index: {e}"),
Self::ChannelClosed => write!(f, "commit channel closed"),
Self::VerifyFailed { file_id, offset } => write!(
f,
"post-sync verify failed at {file_id}:{} (misdirected write or durable corruption)",
offset.raw()
),
}
}
}
@@ -122,7 +131,7 @@ impl std::error::Error for CommitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e.as_ref()),
Self::Index(_) | Self::ChannelClosed => None,
Self::Index(_) | Self::ChannelClosed | Self::VerifyFailed { .. } => None,
}
}
}
@@ -181,6 +190,7 @@ pub struct GroupCommitConfig {
pub channel_capacity: usize,
pub checkpoint_interval_ms: u64,
pub checkpoint_write_threshold: u64,
pub verify_persisted_blocks: bool,
}
impl Default for GroupCommitConfig {
@@ -190,6 +200,7 @@ impl Default for GroupCommitConfig {
channel_capacity: 4096,
checkpoint_interval_ms: 60_000,
checkpoint_write_threshold: 100_000,
verify_persisted_blocks: false,
}
}
}
@@ -200,6 +211,7 @@ struct ShardContext {
file_ids: Arc<FileIdAllocator>,
active_files: Arc<ActiveFileSet>,
hint_positions: Arc<ShardHintPositions>,
verify_persisted_blocks: bool,
}
struct ActiveState {
@@ -403,6 +415,7 @@ impl GroupCommitWriter {
file_ids: Arc::clone(&file_ids),
active_files: Arc::clone(&active_files),
hint_positions: Arc::clone(&hint_positions),
verify_persisted_blocks: config.verify_persisted_blocks,
};
SingleShardWriter::spawn(
ctx,
@@ -540,7 +553,8 @@ fn initialize_active_state<S: StorageIO>(
match cursor {
Some(wc) => {
let fd = manager.open_for_append(wc.file_id)?;
let handle = manager.open_for_append(wc.file_id)?;
let fd = handle.fd();
let file_size = manager.io().file_size(fd)?;
if file_size < wc.offset.raw() {
@@ -550,6 +564,16 @@ fn initialize_active_state<S: StorageIO>(
)));
}
let header_end = super::data_file::BLOCK_HEADER_SIZE as u64;
let position = match file_size < header_end {
true => {
let writer = DataFileWriter::new(manager.io(), fd, wc.file_id)?;
writer.sync()?;
writer.position()
}
false => BlockOffset::new(file_size),
};
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)?;
@@ -557,7 +581,7 @@ fn initialize_active_state<S: StorageIO>(
Ok(ActiveState {
file_id: wc.file_id,
fd,
position: BlockOffset::new(file_size),
position,
hint_fd,
hint_position: HintOffset::new(hint_size),
})
@@ -565,7 +589,8 @@ fn initialize_active_state<S: StorageIO>(
None => {
let file_id = file_ids.allocate();
let fd = manager.open_for_append(file_id)?;
let handle = manager.open_for_append(file_id)?;
let fd = handle.fd();
let writer = DataFileWriter::new(manager.io(), fd, file_id)?;
writer.sync()?;
let position = writer.position();
@@ -1051,9 +1076,124 @@ fn drain_and_process_remaining<S: StorageIO>(
shutdown_checkpoint(index, epoch, &ctx.hint_positions);
}
struct RotationState {
struct RotationState<S: StorageIO> {
file_id: DataFileId,
handle: Arc<super::manager::CachedHandle<S>>,
hint_fd: FileId,
}
fn verify_persisted_blocks<S: StorageIO>(
manager: &DataFileManager<S>,
entries: &[([u8; CID_SIZE], BlockLocation)],
) -> Result<(), CommitError> {
use std::collections::BTreeMap;
let by_file: BTreeMap<DataFileId, Vec<(&[u8; CID_SIZE], BlockLocation)>> =
entries.iter().fold(BTreeMap::new(), |mut acc, (cid, loc)| {
acc.entry(loc.file_id).or_default().push((cid, *loc));
acc
});
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 result = locations.into_iter().try_for_each(|(expected_cid, loc)| {
verify_block_at(manager, fd, file_size, expected_cid, loc)
});
let _ = manager.io().close(fd);
result
})
}
#[derive(Debug)]
enum VerifyOutcome {
NoFaultDetected,
Faulted,
}
fn verify_block_at<S: StorageIO>(
manager: &DataFileManager<S>,
fd: FileId,
file_size: u64,
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
)
});
match passed {
true => Ok(()),
false => Err(CommitError::VerifyFailed {
file_id: loc.file_id,
offset: loc.offset,
}),
}
}
fn verify_once<S: StorageIO>(
manager: &DataFileManager<S>,
fd: FileId,
file_size: u64,
expected_cid: &[u8; CID_SIZE],
loc: BlockLocation,
) -> VerifyOutcome {
match decode_block_record(manager.io(), fd, loc.offset, file_size) {
Ok(Some(ReadBlockRecord::Valid { cid_bytes, .. })) if cid_bytes == *expected_cid => {
VerifyOutcome::NoFaultDetected
}
Ok(Some(ReadBlockRecord::Valid { .. })) => {
tracing::warn!(
file_id = %loc.file_id,
offset = loc.offset.raw(),
"verify: stored CID mismatch (misdirected write)"
);
VerifyOutcome::Faulted
}
Ok(Some(ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. }))
| Ok(None) => {
tracing::warn!(
file_id = %loc.file_id,
offset = loc.offset.raw(),
"verify: block undecodable at location"
);
VerifyOutcome::Faulted
}
Err(_) => VerifyOutcome::NoFaultDetected,
}
}
const VERIFY_RETRY_ATTEMPTS: u32 = 4;
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);
rotations.iter().for_each(|rot| {
manager.rollback_rotation(rot.file_id);
let _ = manager.io().close(rot.hint_fd);
let _ = manager
.io()
.delete(&hint_file_path(manager.data_dir(), rot.file_id));
});
}
fn process_batch<S: StorageIO>(
@@ -1071,13 +1211,46 @@ fn process_batch<S: StorageIO>(
let mut all_decrements: Vec<[u8; CID_SIZE]> = Vec::new();
let mut current_hint_fd = state.hint_fd;
let mut rotation: Option<RotationState> = None;
let mut rotations: Vec<RotationState<S>> = Vec::new();
let mut data_writer =
DataFileWriter::resume(manager.io(), state.fd, state.file_id, state.position);
let mut hint_writer =
HintFileWriter::resume(manager.io(), current_hint_fd, state.hint_position);
if manager.should_rotate(data_writer.position()) {
data_writer.sync().map_err(CommitError::from)?;
hint_writer.sync().map_err(CommitError::from)?;
let next_id = ctx.file_ids.allocate();
let next_handle = manager.open_for_append(next_id)?;
let next_fd = next_handle.fd();
tracing::info!(
from = %data_writer.file_id(),
to = %next_id,
trigger = "batch_boundary",
"data file rotation"
);
data_writer = DataFileWriter::new(manager.io(), next_fd, next_id)?;
let new_hint_path = hint_file_path(manager.data_dir(), next_id);
let new_hint_fd = manager
.io()
.open(&new_hint_path, OpenOptions::read_write())?;
manager.io().sync_dir(manager.data_dir())?;
current_hint_fd = new_hint_fd;
hint_writer = HintFileWriter::new(manager.io(), new_hint_fd);
rotations.push(RotationState {
file_id: next_id,
handle: next_handle,
hint_fd: new_hint_fd,
});
}
let mut block_bytes: u64 = 0;
let mut block_count: u64 = 0;
let mut dedup_hits: u64 = 0;
@@ -1099,45 +1272,24 @@ fn process_batch<S: StorageIO>(
hint_writer.append_hint(cid_bytes, &loc)?;
loc
}
None => {
if manager.should_rotate(data_writer.position()) {
data_writer.sync()?;
hint_writer.sync()?;
let next_id = ctx.file_ids.allocate();
let next_fd = manager.open_for_append(next_id)?;
tracing::info!(
from = %data_writer.file_id(),
to = %next_id,
"data file rotation"
);
data_writer = DataFileWriter::new(manager.io(), next_fd, next_id)?;
let new_hint_path = hint_file_path(manager.data_dir(), next_id);
let new_hint_fd = manager
.io()
.open(&new_hint_path, OpenOptions::read_write())?;
manager.io().sync_dir(manager.data_dir())?;
current_hint_fd = new_hint_fd;
hint_writer = HintFileWriter::new(manager.io(), new_hint_fd);
rotation = Some(RotationState {
file_id: next_id,
fd: next_fd,
});
None => match index.get(cid_bytes) {
Some(existing) => {
dedup_hits = dedup_hits.saturating_add(1);
let loc = existing.location;
hint_writer.append_hint(cid_bytes, &loc)?;
dedup.insert(*cid_bytes, loc);
loc
}
None => {
let loc = data_writer.append_block(cid_bytes, data)?;
hint_writer.append_hint(cid_bytes, &loc)?;
let loc = data_writer.append_block(cid_bytes, data)?;
hint_writer.append_hint(cid_bytes, &loc)?;
block_bytes = block_bytes.saturating_add(data.len() as u64);
block_count = block_count.saturating_add(1);
dedup.insert(*cid_bytes, loc);
loc
}
block_bytes = block_bytes.saturating_add(data.len() as u64);
block_count = block_count.saturating_add(1);
dedup.insert(*cid_bytes, loc);
loc
}
},
};
index_entries.push((*cid_bytes, location));
@@ -1152,9 +1304,7 @@ fn process_batch<S: StorageIO>(
});
if let Err(e) = write_result {
if let Some(rot) = rotation {
manager.rollback_rotation(rot.file_id, rot.fd);
}
rollback_batch(manager, state, &rotations);
return Err(e);
}
@@ -1163,18 +1313,53 @@ fn process_batch<S: StorageIO>(
let current_epoch = epoch.current();
let now = crate::wall_clock_ms();
let rollback_on_err = |e: CommitError| -> CommitError {
rollback_batch(manager, state, &rotations);
e
};
all_decrements
.iter()
.try_for_each(|cid| hint_writer.append_decrement(cid, current_epoch, now))?;
.try_for_each(|cid| hint_writer.append_decrement(cid, current_epoch, now))
.map_err(|e| rollback_on_err(CommitError::from(e)))?;
let t = std::time::Instant::now();
data_writer.sync()?;
hint_writer.sync()?;
data_writer.sync().map_err(|e| rollback_on_err(e.into()))?;
if ctx.verify_persisted_blocks {
verify_persisted_blocks(manager, &index_entries).map_err(rollback_on_err)?;
}
let batch_record_count = u32::try_from(
block_count
.saturating_add(dedup_hits)
.saturating_add(all_decrements.len() as u64),
)
.unwrap_or(u32::MAX);
hint_writer
.append_commit_marker(
current_epoch.raw(),
batch_record_count,
data_writer.file_id(),
data_writer.position(),
)
.map_err(|e| rollback_on_err(CommitError::from(e)))?;
hint_writer.sync().map_err(|e| rollback_on_err(e.into()))?;
let sync_nanos = t.elapsed().as_nanos() as u64;
if let Some(ref rot) = rotation {
manager.commit_rotation(rot.file_id, rot.fd);
ctx.active_files.register(ctx.shard_id, rot.file_id);
if !rotations.is_empty() {
let old_file_id = state.file_id;
let old_hint_fd = state.hint_fd;
let last_idx = rotations.len() - 1;
rotations.iter().enumerate().for_each(|(i, rot)| {
if i == last_idx {
manager.commit_rotation(rot.file_id, &rot.handle);
ctx.active_files.register(ctx.shard_id, rot.file_id);
} else {
let _ = manager.io().close(rot.hint_fd);
manager.evict_handle(rot.file_id);
}
});
manager.evict_handle(old_file_id);
let _ = manager.io().close(old_hint_fd);
}
state.file_id = data_writer.file_id();
@@ -704,6 +704,7 @@ impl HashTable {
const CHECKPOINT_MAGIC: [u8; 8] = *b"TQCKPT01";
const CHECKPOINT_VERSION_V1: u32 = 1;
const CHECKPOINT_VERSION_V2: u32 = 2;
const CHECKPOINT_VERSION_V3: u32 = 3;
const CHECKPOINT_HEADER_SIZE: usize = 128;
const TRAILER_MAGIC: u64 = 0xDEAD_BEEF_CAFE_F00D;
const SLOT_SIZE: usize = std::mem::size_of::<Slot>();
@@ -733,6 +734,7 @@ const H_CHECKPOINT_EPOCH: usize = 56;
const H_HINT_FILE_ID: usize = 64;
const H_HINT_OFFSET: usize = 72;
const H_HEADER_CHECKSUM: usize = 80;
const H_GENERATION: usize = 88;
fn header_checksum(buf: &[u8; CHECKPOINT_HEADER_SIZE]) -> u64 {
xxhash_rust::xxh3::xxh3_64(&buf[..H_HEADER_CHECKSUM])
@@ -745,10 +747,11 @@ fn serialize_header(
cursor_offset: u64,
checkpoint_epoch: u64,
shard_count: u16,
generation: u64,
) -> [u8; CHECKPOINT_HEADER_SIZE] {
let mut buf = [0u8; CHECKPOINT_HEADER_SIZE];
buf[H_MAGIC..H_MAGIC + 8].copy_from_slice(&CHECKPOINT_MAGIC);
buf[H_VERSION..H_VERSION + 4].copy_from_slice(&CHECKPOINT_VERSION_V2.to_le_bytes());
buf[H_VERSION..H_VERSION + 4].copy_from_slice(&CHECKPOINT_VERSION_V3.to_le_bytes());
buf[H_SHARD_COUNT..H_SHARD_COUNT + 2].copy_from_slice(&shard_count.to_le_bytes());
buf[H_SLOT_COUNT..H_SLOT_COUNT + 8].copy_from_slice(&slot_count.to_le_bytes());
buf[H_ENTRY_COUNT..H_ENTRY_COUNT + 8].copy_from_slice(&entry_count.to_le_bytes());
@@ -756,6 +759,7 @@ fn serialize_header(
buf[H_CURSOR_OFFSET..H_CURSOR_OFFSET + 8].copy_from_slice(&cursor_offset.to_le_bytes());
buf[H_CHECKPOINT_EPOCH..H_CHECKPOINT_EPOCH + 8]
.copy_from_slice(&checkpoint_epoch.to_le_bytes());
buf[H_GENERATION..H_GENERATION + 8].copy_from_slice(&generation.to_le_bytes());
let checksum = header_checksum(&buf);
buf[H_HEADER_CHECKSUM..H_HEADER_CHECKSUM + 8].copy_from_slice(&checksum.to_le_bytes());
buf
@@ -796,6 +800,7 @@ pub fn write_checkpoint(
table: &HashTable,
path: &Path,
epoch: CommitEpoch,
generation: u64,
positions: &CheckpointPositions,
) -> io::Result<()> {
use std::io::Write;
@@ -817,6 +822,7 @@ pub fn write_checkpoint(
cursor_offset,
epoch.raw(),
shard_count,
generation,
);
let slot_bytes = slots_as_bytes(&table.slots);
@@ -844,7 +850,7 @@ pub fn write_checkpoint(
Ok(())
}
fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u64, u16)> {
fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u64, u16, u64)> {
if data.len() < CHECKPOINT_HEADER_SIZE + 16 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -863,7 +869,10 @@ fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u
}
let version = u32::from_le_bytes(hdr[H_VERSION..H_VERSION + 4].try_into().unwrap());
if version != CHECKPOINT_VERSION_V1 && version != CHECKPOINT_VERSION_V2 {
if version != CHECKPOINT_VERSION_V1
&& version != CHECKPOINT_VERSION_V2
&& version != CHECKPOINT_VERSION_V3
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("checkpoint version {version} unsupported"),
@@ -921,12 +930,19 @@ fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u
);
let shard_count = match version {
CHECKPOINT_VERSION_V2 => {
CHECKPOINT_VERSION_V2 | CHECKPOINT_VERSION_V3 => {
u16::from_le_bytes(hdr[H_SHARD_COUNT..H_SHARD_COUNT + 2].try_into().unwrap())
}
_ => 0,
};
let generation = match version {
CHECKPOINT_VERSION_V3 => {
u64::from_le_bytes(hdr[H_GENERATION..H_GENERATION + 8].try_into().unwrap())
}
_ => 0,
};
Ok((
slot_count,
entry_count,
@@ -934,6 +950,7 @@ fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u
cursor_offset,
checkpoint_epoch,
shard_count,
generation,
))
}
@@ -948,11 +965,20 @@ fn deserialize_shard_positions(data: &[u8], count: usize) -> Vec<(DataFileId, Hi
.collect()
}
pub fn read_checkpoint(path: &Path) -> io::Result<(HashTable, CommitEpoch, CheckpointPositions)> {
pub fn read_checkpoint(
path: &Path,
) -> io::Result<(HashTable, CommitEpoch, CheckpointPositions, u64)> {
let data = std::fs::read(path)?;
let (slot_count, entry_count, cursor_file_id, cursor_offset, checkpoint_epoch, shard_count) =
parse_checkpoint_header(&data)?;
let (
slot_count,
entry_count,
cursor_file_id,
cursor_offset,
checkpoint_epoch,
shard_count,
generation,
) = parse_checkpoint_header(&data)?;
let hdr: &[u8; CHECKPOINT_HEADER_SIZE] = data[..CHECKPOINT_HEADER_SIZE].try_into().unwrap();
let version = u32::from_le_bytes(hdr[H_VERSION..H_VERSION + 4].try_into().unwrap());
@@ -972,7 +998,7 @@ pub fn read_checkpoint(path: &Path) -> io::Result<(HashTable, CommitEpoch, Check
let shard_pos_region = &data[shard_pos_start..shard_pos_start + shard_pos_size];
let data_checksum = match version {
CHECKPOINT_VERSION_V2 => {
CHECKPOINT_VERSION_V2 | CHECKPOINT_VERSION_V3 => {
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
hasher.update(slot_region);
hasher.update(shard_pos_region);
@@ -1035,7 +1061,7 @@ pub fn read_checkpoint(path: &Path) -> io::Result<(HashTable, CommitEpoch, Check
let epoch = CommitEpoch::new(checkpoint_epoch);
let positions = match version {
CHECKPOINT_VERSION_V2 if shard_count > 0 => CheckpointPositions(
CHECKPOINT_VERSION_V2 | CHECKPOINT_VERSION_V3 if shard_count > 0 => CheckpointPositions(
deserialize_shard_positions(shard_pos_region, shard_count as usize),
),
_ => {
@@ -1047,12 +1073,12 @@ pub fn read_checkpoint(path: &Path) -> io::Result<(HashTable, CommitEpoch, Check
}
};
Ok((table, epoch, positions))
Ok((table, epoch, positions, generation))
}
pub fn load_best_checkpoint(
index_dir: &Path,
) -> Option<(HashTable, CommitEpoch, CheckpointPositions)> {
) -> Option<(HashTable, CommitEpoch, CheckpointPositions, u64)> {
let path_a = index_dir.join("checkpoint_a.tqc");
let path_b = index_dir.join("checkpoint_b.tqc");
@@ -1060,7 +1086,7 @@ pub fn load_best_checkpoint(
let result_b = read_checkpoint(&path_b).ok();
match (result_a, result_b) {
(Some(a), Some(b)) => match a.1.raw() >= b.1.raw() {
(Some(a), Some(b)) => match (a.3, a.1.raw()) >= (b.3, b.1.raw()) {
true => Some(a),
false => Some(b),
},
@@ -1070,7 +1096,7 @@ pub fn load_best_checkpoint(
}
}
fn read_checkpoint_epoch(path: &Path) -> Option<u64> {
fn read_checkpoint_meta(path: &Path) -> Option<(u64, u64)> {
let mut file = std::fs::File::open(path).ok()?;
let mut buf = [0u8; CHECKPOINT_HEADER_SIZE];
std::io::Read::read_exact(&mut file, &mut buf).ok()?;
@@ -1081,7 +1107,10 @@ fn read_checkpoint_epoch(path: &Path) -> Option<u64> {
}
let version = u32::from_le_bytes(buf[H_VERSION..H_VERSION + 4].try_into().ok()?);
if version != CHECKPOINT_VERSION_V1 && version != CHECKPOINT_VERSION_V2 {
if version != CHECKPOINT_VERSION_V1
&& version != CHECKPOINT_VERSION_V2
&& version != CHECKPOINT_VERSION_V3
{
return None;
}
@@ -1094,33 +1123,41 @@ fn read_checkpoint_epoch(path: &Path) -> Option<u64> {
return None;
}
Some(u64::from_le_bytes(
let epoch = u64::from_le_bytes(
buf[H_CHECKPOINT_EPOCH..H_CHECKPOINT_EPOCH + 8]
.try_into()
.ok()?,
))
);
let generation = match version {
CHECKPOINT_VERSION_V3 => {
u64::from_le_bytes(buf[H_GENERATION..H_GENERATION + 8].try_into().ok()?)
}
_ => 0,
};
Some((epoch, generation))
}
pub fn write_checkpoint_ab(
table: &HashTable,
index_dir: &Path,
epoch: CommitEpoch,
generation: u64,
positions: &CheckpointPositions,
) -> io::Result<()> {
let path_a = index_dir.join("checkpoint_a.tqc");
let path_b = index_dir.join("checkpoint_b.tqc");
let epoch_a = read_checkpoint_epoch(&path_a);
let epoch_b = read_checkpoint_epoch(&path_b);
let meta_a = read_checkpoint_meta(&path_a);
let meta_b = read_checkpoint_meta(&path_b);
let target_path = match (epoch_a, epoch_b) {
(Some(a), Some(b)) if a >= b => path_b,
let target_path = match (meta_a, meta_b) {
(Some(a), Some(b)) if (a.1, a.0) >= (b.1, b.0) => path_b,
(Some(_), Some(_)) => path_a,
(Some(_), None) => path_b,
(None, _) => path_a,
};
write_checkpoint(table, &target_path, epoch, positions)
write_checkpoint(table, &target_path, epoch, generation, positions)
}
#[derive(Debug)]
@@ -1146,6 +1183,7 @@ pub struct BlockIndex {
checkpoint_lock: parking_lot::Mutex<()>,
loaded_checkpoint_positions: Option<CheckpointPositions>,
loaded_checkpoint_epoch: Option<CommitEpoch>,
next_generation: std::sync::atomic::AtomicU64,
}
impl BlockIndex {
@@ -1156,33 +1194,36 @@ impl BlockIndex {
checkpoint_lock: parking_lot::Mutex::new(()),
loaded_checkpoint_positions: None,
loaded_checkpoint_epoch: None,
next_generation: std::sync::atomic::AtomicU64::new(1),
}
}
pub fn open(index_dir: &Path) -> io::Result<Self> {
std::fs::create_dir_all(index_dir)?;
let (table, checkpoint_positions, checkpoint_epoch) = match load_best_checkpoint(index_dir)
{
Some((table, epoch, positions)) => {
tracing::info!(
blocks = table.len(),
epoch = epoch.raw(),
shard_positions = positions.0.len(),
"loaded block index from checkpoint"
);
(table, Some(positions), Some(epoch))
}
None => {
tracing::info!("no valid checkpoint found, starting with empty index");
(HashTable::with_capacity(64), None, None)
}
};
let (table, checkpoint_positions, checkpoint_epoch, loaded_generation) =
match load_best_checkpoint(index_dir) {
Some((table, epoch, positions, gen_value)) => {
tracing::info!(
blocks = table.len(),
epoch = epoch.raw(),
shard_positions = positions.0.len(),
generation = gen_value,
"loaded block index from checkpoint"
);
(table, Some(positions), Some(epoch), gen_value)
}
None => {
tracing::info!("no valid checkpoint found, starting with empty index");
(HashTable::with_capacity(64), None, None, 0)
}
};
Ok(Self {
table: RwLock::new(table),
index_dir: index_dir.to_path_buf(),
checkpoint_lock: parking_lot::Mutex::new(()),
loaded_checkpoint_positions: checkpoint_positions,
loaded_checkpoint_epoch: checkpoint_epoch,
next_generation: std::sync::atomic::AtomicU64::new(loaded_generation + 1),
})
}
@@ -1481,9 +1522,12 @@ impl BlockIndex {
hint_positions: &ShardHintPositions,
) -> io::Result<()> {
let _guard = self.checkpoint_lock.lock();
let generation = self
.next_generation
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let table = self.table.read();
let positions = hint_positions.snapshot();
write_checkpoint_ab(&table, &self.index_dir, epoch, &positions)
write_checkpoint_ab(&table, &self.index_dir, epoch, generation, &positions)
}
pub fn write_checkpoint_with_positions(
@@ -1492,8 +1536,11 @@ impl BlockIndex {
positions: &CheckpointPositions,
) -> io::Result<()> {
let _guard = self.checkpoint_lock.lock();
let generation = self
.next_generation
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let table = self.table.read();
write_checkpoint_ab(&table, &self.index_dir, epoch, positions)
write_checkpoint_ab(&table, &self.index_dir, epoch, generation, positions)
}
pub fn index_dir(&self) -> &Path {
@@ -1590,7 +1637,8 @@ impl BlockIndex {
ReadHintRecord::Remove { cid_bytes } => {
let _ = table.remove(&cid_bytes);
}
ReadHintRecord::UnknownVersion { .. }
ReadHintRecord::CommitMarker { .. }
| ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::UnknownType { .. }
| ReadHintRecord::Corrupted
| ReadHintRecord::Truncated => {}
@@ -2045,8 +2093,8 @@ mod tests {
let epoch = CommitEpoch::new(42);
let positions = CheckpointPositions::single(DataFileId::new(5), HintOffset::new(12345));
write_checkpoint(&table, &path, epoch, &positions).unwrap();
let (restored, restored_epoch, restored_pos) = read_checkpoint(&path).unwrap();
write_checkpoint(&table, &path, epoch, 7, &positions).unwrap();
let (restored, restored_epoch, restored_pos, _gen) = read_checkpoint(&path).unwrap();
assert_eq!(restored.len(), 10);
assert_eq!(restored_epoch.raw(), 42);
@@ -2078,14 +2126,14 @@ mod tests {
table
.insert_or_increment(&test_cid(1), test_loc(0, 0, 10))
.unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), &pos).unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), 1, &pos).unwrap();
table
.insert_or_increment(&test_cid(2), test_loc(0, 100, 10))
.unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), &pos).unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), 2, &pos).unwrap();
let (best, epoch, _) = load_best_checkpoint(dir.path()).unwrap();
let (best, epoch, _, _) = load_best_checkpoint(dir.path()).unwrap();
assert_eq!(epoch.raw(), 2);
assert_eq!(best.len(), 2);
}
@@ -2099,16 +2147,16 @@ mod tests {
table
.insert_or_increment(&test_cid(1), test_loc(0, 0, 10))
.unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), &pos).unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), 1, &pos).unwrap();
table
.insert_or_increment(&test_cid(2), test_loc(0, 100, 10))
.unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), &pos).unwrap();
write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), 2, &pos).unwrap();
std::fs::write(dir.path().join("checkpoint_b.tqc"), b"corrupt").unwrap();
let (best, epoch, _) = load_best_checkpoint(dir.path()).unwrap();
let (best, epoch, _, _) = load_best_checkpoint(dir.path()).unwrap();
assert_eq!(epoch.raw(), 1);
assert_eq!(best.len(), 1);
}
+275 -104
View File
@@ -22,6 +22,7 @@ const RECORD_TYPE_PUT: u8 = 0x01;
const RECORD_TYPE_DECREMENT: u8 = 0x02;
const RECORD_TYPE_RELOCATE: u8 = 0x03;
const RECORD_TYPE_REMOVE: u8 = 0x04;
const RECORD_TYPE_COMMIT_MARKER: u8 = 0x05;
const HINT_FORMAT_VERSION: u8 = 1;
@@ -142,6 +143,36 @@ pub(crate) fn encode_decrement_record<S: StorageIO>(
write_hint_record(io, fd, write_offset, &record)
}
const MARKER_DATA_OFFSET_POS: usize = CID_OFFSET;
const MARKER_DATA_FILE_ID_POS: usize = CID_OFFSET + 8;
const MARKER_RECORD_COUNT_POS: usize = FIELD_B_OFFSET;
pub(crate) fn encode_commit_marker_record<S: StorageIO>(
io: &S,
fd: FileId,
write_offset: HintOffset,
batch_seq: u64,
record_count: u32,
data_file_id: DataFileId,
data_offset: BlockOffset,
) -> io::Result<()> {
let mut record = [0u8; HINT_RECORD_SIZE];
record[TYPE_OFFSET] = RECORD_TYPE_COMMIT_MARKER;
record[VERSION_OFFSET] = HINT_FORMAT_VERSION;
record[MARKER_DATA_OFFSET_POS..MARKER_DATA_OFFSET_POS + 8]
.copy_from_slice(&data_offset.raw().to_le_bytes());
record[MARKER_DATA_FILE_ID_POS..MARKER_DATA_FILE_ID_POS + 4]
.copy_from_slice(&data_file_id.raw().to_le_bytes());
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 8].copy_from_slice(&batch_seq.to_le_bytes());
record[MARKER_RECORD_COUNT_POS..MARKER_RECORD_COUNT_POS + 4]
.copy_from_slice(&record_count.to_le_bytes());
let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]);
record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes());
write_hint_record(io, fd, write_offset, &record)
}
#[must_use]
#[derive(Debug)]
pub enum ReadHintRecord {
@@ -166,6 +197,12 @@ pub enum ReadHintRecord {
Remove {
cid_bytes: [u8; CID_SIZE],
},
CommitMarker {
batch_seq: u64,
record_count: u32,
data_file_id: DataFileId,
data_offset: BlockOffset,
},
UnknownVersion {
version: u8,
},
@@ -295,6 +332,34 @@ pub fn decode_hint_record<S: StorageIO>(
}))
}
RECORD_TYPE_REMOVE => Ok(Some(ReadHintRecord::Remove { cid_bytes })),
RECORD_TYPE_COMMIT_MARKER => {
let data_offset = BlockOffset::new(u64::from_le_bytes(
record[MARKER_DATA_OFFSET_POS..MARKER_DATA_OFFSET_POS + 8]
.try_into()
.unwrap(),
));
let data_file_id = DataFileId::new(u32::from_le_bytes(
record[MARKER_DATA_FILE_ID_POS..MARKER_DATA_FILE_ID_POS + 4]
.try_into()
.unwrap(),
));
let batch_seq = u64::from_le_bytes(
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 8]
.try_into()
.unwrap(),
);
let record_count = u32::from_le_bytes(
record[MARKER_RECORD_COUNT_POS..MARKER_RECORD_COUNT_POS + 4]
.try_into()
.unwrap(),
);
Ok(Some(ReadHintRecord::CommitMarker {
batch_seq,
record_count,
data_file_id,
data_offset,
}))
}
other => Ok(Some(ReadHintRecord::UnknownType { record_type: other })),
}
}
@@ -356,6 +421,26 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> {
Ok(())
}
pub fn append_commit_marker(
&mut self,
batch_seq: u64,
record_count: u32,
data_file_id: DataFileId,
data_offset: BlockOffset,
) -> io::Result<()> {
encode_commit_marker_record(
self.io,
self.fd,
self.position,
batch_seq,
record_count,
data_file_id,
data_offset,
)?;
self.position = self.position.advance(HINT_RECORD_SIZE as u64);
Ok(())
}
pub fn sync(&self) -> io::Result<()> {
self.io.sync(self.fd)
}
@@ -416,12 +501,13 @@ impl<S: StorageIO> Iterator for HintFileReader<'_, S> {
| ReadHintRecord::Decrement { .. }
| ReadHintRecord::Relocate { .. }
| ReadHintRecord::Remove { .. }
| ReadHintRecord::UnknownType { .. } => {
| ReadHintRecord::CommitMarker { .. }
| ReadHintRecord::UnknownType { .. }
| ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::Corrupted => {
self.position = self.position.advance(HINT_RECORD_SIZE as u64);
}
ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::Corrupted
| ReadHintRecord::Truncated => {
ReadHintRecord::Truncated => {
self.position = HintOffset::new(self.file_size);
}
}
@@ -529,6 +615,7 @@ fn scan_single_hint_file<S: StorageIO>(
ReadHintRecord::Decrement { .. }
| ReadHintRecord::Relocate { .. }
| ReadHintRecord::Remove { .. }
| ReadHintRecord::CommitMarker { .. }
| ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::UnknownType { .. }
| ReadHintRecord::Corrupted
@@ -542,7 +629,95 @@ fn scan_single_hint_file<S: StorageIO>(
entries
}
const REPLAY_BATCH_SIZE: usize = 10_000;
#[derive(Default)]
struct PendingBatch {
puts: Vec<([u8; CID_SIZE], BlockLocation)>,
relocates: Vec<([u8; CID_SIZE], BlockLocation, u32)>,
removes: Vec<[u8; CID_SIZE]>,
decrements: Vec<([u8; CID_SIZE], CommitEpoch, WallClockMs)>,
file_cursors: HashMap<DataFileId, BlockOffset>,
max_cursor: Option<WriteCursor>,
record_count: u32,
boundary_lost: bool,
}
impl PendingBatch {
fn reset(&mut self) {
self.puts.clear();
self.relocates.clear();
self.removes.clear();
self.decrements.clear();
self.file_cursors.clear();
self.max_cursor = None;
self.record_count = 0;
self.boundary_lost = false;
}
fn note_record(&mut self) {
self.record_count = self.record_count.saturating_add(1);
}
fn track_cursor(&mut self, file_id: DataFileId, end: BlockOffset) {
let candidate = WriteCursor {
file_id,
offset: end,
};
self.max_cursor = Some(match self.max_cursor {
Some(c) => std::cmp::max_by_key(c, candidate, |w| (w.file_id, w.offset)),
None => candidate,
});
self.file_cursors
.entry(file_id)
.and_modify(|existing| {
if end > *existing {
*existing = end;
}
})
.or_insert(end);
}
}
fn commit_pending_batch(
pending: &mut PendingBatch,
index: &super::hash_index::BlockIndex,
file_cursors: &mut HashMap<DataFileId, BlockOffset>,
max_cursor: &mut Option<WriteCursor>,
replayed: &mut u64,
) -> Result<(), RebuildError> {
if !pending.puts.is_empty() {
index.batch_insert_buffered(&pending.puts)?;
}
if !pending.relocates.is_empty() {
index.batch_relocate(&pending.relocates)?;
}
if !pending.removes.is_empty() {
index.batch_remove(&pending.removes);
}
pending
.decrements
.iter()
.try_for_each(|(cid, epoch, ts)| index.batch_decrement(&[*cid], *epoch, *ts))?;
pending.file_cursors.iter().for_each(|(fid, end)| {
file_cursors
.entry(*fid)
.and_modify(|existing| {
if *end > *existing {
*existing = *end;
}
})
.or_insert(*end);
});
if let Some(c) = pending.max_cursor {
*max_cursor = Some(match *max_cursor {
Some(m) => std::cmp::max_by_key(m, c, |w| (w.file_id, w.offset)),
None => c,
});
}
*replayed = replayed.saturating_add(u64::from(pending.record_count));
pending.reset();
Ok(())
}
pub fn replay_hints_into_block_index<S: StorageIO>(
io: &S,
@@ -568,11 +743,7 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
let mut max_cursor: Option<WriteCursor> = None;
let mut file_cursors: HashMap<DataFileId, BlockOffset> = HashMap::new();
let mut replayed: u64 = 0;
let mut put_buffer: Vec<([u8; CID_SIZE], BlockLocation)> =
Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation, u32)> =
Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut remove_buffer: Vec<[u8; CID_SIZE]> = Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut pending = PendingBatch::default();
hint_files
.iter()
@@ -604,54 +775,19 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
offset,
length,
};
put_buffer.push((cid_bytes, loc));
let record_end =
offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64());
let candidate = WriteCursor {
file_id,
offset: record_end,
};
max_cursor = Some(match max_cursor {
Some(c) => {
std::cmp::max_by_key(c, candidate, |w| (w.file_id, w.offset))
}
None => candidate,
});
file_cursors
.entry(file_id)
.and_modify(|existing| {
if record_end > *existing {
*existing = record_end;
}
})
.or_insert(record_end);
replayed = replayed.saturating_add(1);
if put_buffer.len() >= REPLAY_BATCH_SIZE {
index.batch_insert_buffered(&put_buffer)?;
put_buffer.clear();
}
pending.puts.push((cid_bytes, loc));
pending.track_cursor(file_id, record_end);
pending.note_record();
}
ReadHintRecord::Decrement {
cid_bytes,
epoch,
timestamp,
} => {
if !put_buffer.is_empty() {
index.batch_insert_buffered(&put_buffer)?;
put_buffer.clear();
}
if !relocate_buffer.is_empty() {
index.batch_relocate(&relocate_buffer)?;
relocate_buffer.clear();
}
if !remove_buffer.is_empty() {
index.batch_remove(&remove_buffer);
remove_buffer.clear();
}
index.batch_decrement(&[cid_bytes], epoch, timestamp)?;
replayed = replayed.saturating_add(1);
pending.decrements.push((cid_bytes, epoch, timestamp));
pending.note_record();
}
ReadHintRecord::Relocate {
cid_bytes,
@@ -665,75 +801,71 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
offset,
length,
};
relocate_buffer.push((cid_bytes, loc, refcount));
let record_end =
offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64());
file_cursors
.entry(file_id)
.and_modify(|existing| {
if record_end > *existing {
*existing = record_end;
}
})
.or_insert(record_end);
replayed = replayed.saturating_add(1);
if relocate_buffer.len() >= REPLAY_BATCH_SIZE {
if !put_buffer.is_empty() {
index.batch_insert_buffered(&put_buffer)?;
put_buffer.clear();
}
index.batch_relocate(&relocate_buffer)?;
relocate_buffer.clear();
}
pending.relocates.push((cid_bytes, loc, refcount));
pending.track_cursor(file_id, record_end);
pending.note_record();
}
ReadHintRecord::Remove { cid_bytes } => {
remove_buffer.push(cid_bytes);
replayed = replayed.saturating_add(1);
if remove_buffer.len() >= REPLAY_BATCH_SIZE {
if !put_buffer.is_empty() {
index.batch_insert_buffered(&put_buffer)?;
put_buffer.clear();
pending.removes.push(cid_bytes);
pending.note_record();
}
ReadHintRecord::CommitMarker {
batch_seq,
record_count,
data_file_id,
data_offset,
} => {
let accepts =
!pending.boundary_lost && pending.record_count == record_count;
match accepts {
true => {
pending.track_cursor(data_file_id, data_offset);
commit_pending_batch(
&mut pending,
index,
&mut file_cursors,
&mut max_cursor,
&mut replayed,
)?;
}
if !relocate_buffer.is_empty() {
index.batch_relocate(&relocate_buffer)?;
relocate_buffer.clear();
false => {
tracing::warn!(
file_id = %fid,
batch_seq,
expected_count = record_count,
observed_count = pending.record_count,
boundary_lost = pending.boundary_lost,
"rolling back torn hint batch"
);
pending.reset();
}
index.batch_remove(&remove_buffer);
remove_buffer.clear();
}
}
ReadHintRecord::Corrupted => {
tracing::warn!(
file_id = %fid,
"corrupted hint record during replay, skipping"
);
ReadHintRecord::Corrupted
| ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::UnknownType { .. } => {
pending.boundary_lost = true;
}
ReadHintRecord::UnknownVersion { .. }
| ReadHintRecord::UnknownType { .. }
| ReadHintRecord::Truncated => {}
ReadHintRecord::Truncated => {}
}
Ok::<_, RebuildError>(())
})?;
if !put_buffer.is_empty() {
index.batch_insert_buffered(&put_buffer)?;
put_buffer.clear();
}
if !relocate_buffer.is_empty() {
index.batch_relocate(&relocate_buffer)?;
relocate_buffer.clear();
}
if !remove_buffer.is_empty() {
index.batch_remove(&remove_buffer);
remove_buffer.clear();
}
let _ = io.close(fd);
Ok(())
})?;
if pending.record_count > 0 || pending.boundary_lost {
tracing::warn!(
record_count = pending.record_count,
boundary_lost = pending.boundary_lost,
"discarding unterminated hint batch at replay end"
);
pending.reset();
}
if let Some(cursor) = max_cursor {
index.set_write_cursor(cursor)?;
}
@@ -1097,7 +1229,7 @@ mod tests {
}
#[test]
fn hint_reader_stops_on_corrupted() {
fn hint_reader_reports_corrupted_and_continues() {
let (sim, fd) = setup();
let mut writer = HintFileWriter::new(&sim, fd);
@@ -1115,9 +1247,48 @@ mod tests {
let reader = HintFileReader::open(&sim, fd).unwrap();
let records: Vec<_> = reader.map(|r| r.unwrap()).collect();
assert_eq!(records.len(), 2);
assert_eq!(records.len(), 3);
assert!(matches!(records[0], ReadHintRecord::Put { .. }));
assert!(matches!(records[1], ReadHintRecord::Corrupted));
assert!(matches!(records[2], ReadHintRecord::Put { .. }));
}
#[test]
fn commit_marker_round_trip() {
let (sim, fd) = setup();
let data_file_id = DataFileId::new(7);
let data_offset = BlockOffset::new(9_876);
encode_commit_marker_record(
&sim,
fd,
HintOffset::new(0),
42,
128,
data_file_id,
data_offset,
)
.unwrap();
let file_size = sim.file_size(fd).unwrap();
let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size)
.unwrap()
.unwrap();
match record {
ReadHintRecord::CommitMarker {
batch_seq,
record_count,
data_file_id: fid,
data_offset: off,
} => {
assert_eq!(batch_seq, 42);
assert_eq!(record_count, 128);
assert_eq!(fid, data_file_id);
assert_eq!(off, data_offset);
}
other => panic!("expected CommitMarker, got {other:?}"),
}
}
#[test]
+108 -96
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::RwLock;
@@ -13,22 +14,39 @@ pub const DEFAULT_MAX_FILE_SIZE: u64 = 256 * 1024 * 1024;
pub(crate) const DATA_FILE_EXTENSION: &str = "tqb";
struct CachedHandle {
pub struct CachedHandle<S: StorageIO> {
fd: FileId,
io: Arc<S>,
writable: bool,
}
impl<S: StorageIO> CachedHandle<S> {
pub fn fd(&self) -> FileId {
self.fd
}
pub fn is_writable(&self) -> bool {
self.writable
}
}
impl<S: StorageIO> Drop for CachedHandle<S> {
fn drop(&mut self) {
let _ = self.io.close(self.fd);
}
}
pub struct DataFileManager<S: StorageIO> {
io: S,
io: Arc<S>,
data_dir: PathBuf,
max_file_size: u64,
handles: RwLock<HashMap<DataFileId, CachedHandle>>,
handles: RwLock<HashMap<DataFileId, Arc<CachedHandle<S>>>>,
}
impl<S: StorageIO> DataFileManager<S> {
pub fn new(io: S, data_dir: PathBuf, max_file_size: u64) -> Self {
Self {
io,
io: Arc::new(io),
data_dir,
max_file_size,
handles: RwLock::new(HashMap::new()),
@@ -40,7 +58,7 @@ impl<S: StorageIO> DataFileManager<S> {
}
pub fn io(&self) -> &S {
&self.io
self.io.as_ref()
}
pub fn data_dir(&self) -> &Path {
@@ -56,77 +74,81 @@ impl<S: StorageIO> DataFileManager<S> {
.join(format!("{file_id}.{DATA_FILE_EXTENSION}"))
}
pub fn open_for_append(&self, file_id: DataFileId) -> io::Result<FileId> {
pub fn open_for_append(&self, file_id: DataFileId) -> io::Result<Arc<CachedHandle<S>>> {
{
let cache = self.handles.read();
if let Some(entry) = cache.get(&file_id)
&& entry.writable
{
return Ok(entry.fd);
return Ok(Arc::clone(entry));
}
}
let path = self.data_file_path(file_id);
let fd = self.io.open(&path, OpenOptions::read_write())?;
let mut cache = self.handles.write();
match cache.get(&file_id) {
match cache.get(&file_id).cloned() {
Some(entry) if entry.writable => {
let _ = self.io.close(fd);
Ok(entry.fd)
Ok(entry)
}
Some(entry) => {
let old_fd = entry.fd;
cache.insert(file_id, CachedHandle { fd, writable: true });
let _ = self.io.close(old_fd);
Ok(fd)
}
None => {
cache.insert(file_id, CachedHandle { fd, writable: true });
Ok(fd)
_ => {
let handle = Arc::new(CachedHandle {
fd,
io: Arc::clone(&self.io),
writable: true,
});
cache.insert(file_id, Arc::clone(&handle));
Ok(handle)
}
}
}
pub fn open_for_read(&self, file_id: DataFileId) -> io::Result<FileId> {
pub fn open_for_read(&self, file_id: DataFileId) -> io::Result<Arc<CachedHandle<S>>> {
if let Some(entry) = self.handles.read().get(&file_id) {
return Ok(entry.fd);
return Ok(Arc::clone(entry));
}
let path = self.data_file_path(file_id);
let fd = self.io.open(&path, OpenOptions::read_only_existing())?;
let mut cache = self.handles.write();
match cache.get(&file_id) {
match cache.get(&file_id).cloned() {
Some(entry) => {
let _ = self.io.close(fd);
Ok(entry.fd)
Ok(entry)
}
None => {
cache.insert(
file_id,
CachedHandle {
fd,
writable: false,
},
);
Ok(fd)
let handle = Arc::new(CachedHandle {
fd,
io: Arc::clone(&self.io),
writable: false,
});
cache.insert(file_id, Arc::clone(&handle));
Ok(handle)
}
}
}
pub fn prepare_rotation(&self, current: DataFileId) -> io::Result<(DataFileId, FileId)> {
pub fn prepare_rotation(
&self,
current: DataFileId,
) -> io::Result<(DataFileId, Arc<CachedHandle<S>>)> {
let next = current.next();
let path = self.data_file_path(next);
let fd = self.io.open(&path, OpenOptions::read_write())?;
Ok((next, fd))
let handle = Arc::new(CachedHandle {
fd,
io: Arc::clone(&self.io),
writable: true,
});
Ok((next, handle))
}
pub fn commit_rotation(&self, file_id: DataFileId, fd: FileId) {
self.handles
.write()
.insert(file_id, CachedHandle { fd, writable: true });
pub fn commit_rotation(&self, file_id: DataFileId, handle: &Arc<CachedHandle<S>>) {
self.handles.write().insert(file_id, Arc::clone(handle));
}
pub fn rollback_rotation(&self, file_id: DataFileId, fd: FileId) {
let _ = self.io.close(fd);
pub fn rollback_rotation(&self, file_id: DataFileId) {
self.handles.write().remove(&file_id);
let _ = self.io.delete(&self.data_file_path(file_id));
}
pub fn should_rotate(&self, position: BlockOffset) -> bool {
@@ -134,14 +156,11 @@ impl<S: StorageIO> DataFileManager<S> {
}
pub fn list_files(&self) -> io::Result<Vec<DataFileId>> {
list_files_by_extension(&self.io, &self.data_dir, DATA_FILE_EXTENSION)
list_files_by_extension(&*self.io, &self.data_dir, DATA_FILE_EXTENSION)
}
pub fn evict_handle(&self, file_id: DataFileId) {
let removed = self.handles.write().remove(&file_id);
if let Some(entry) = removed {
let _ = self.io.close(entry.fd);
}
self.handles.write().remove(&file_id);
}
pub fn delete_data_file(&self, file_id: DataFileId) -> io::Result<()> {
@@ -151,14 +170,6 @@ impl<S: StorageIO> DataFileManager<S> {
}
}
impl<S: StorageIO> Drop for DataFileManager<S> {
fn drop(&mut self) {
self.handles.write().drain().for_each(|(_, entry)| {
let _ = self.io.close(entry.fd);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -177,8 +188,8 @@ mod tests {
#[test]
fn open_for_append_creates_file() {
let mgr = setup_manager(1024);
let fd = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_eq!(mgr.io().file_size(fd).unwrap(), 0);
let handle = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_eq!(mgr.io().file_size(handle.fd()).unwrap(), 0);
}
#[test]
@@ -190,45 +201,46 @@ mod tests {
#[test]
fn handle_cache_returns_same_fd() {
let mgr = setup_manager(1024);
let fd1 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let fd2 = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_eq!(fd1, fd2);
let h1 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let h2 = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_eq!(h1.fd(), h2.fd());
}
#[test]
fn open_for_read_uses_cache_from_append() {
let mgr = setup_manager(1024);
let fd_write = mgr.open_for_append(DataFileId::new(0)).unwrap();
let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
assert_eq!(fd_write, fd_read);
let h_write = mgr.open_for_append(DataFileId::new(0)).unwrap();
let h_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
assert_eq!(h_write.fd(), h_read.fd());
}
#[test]
fn rotation_lifecycle_prepare_commit() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let (next_id, next_fd) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
let _h0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let (next_id, next_handle) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
assert_eq!(next_id, DataFileId::new(1));
assert_eq!(mgr.io().file_size(next_fd).unwrap(), 0);
assert_eq!(mgr.io().file_size(next_handle.fd()).unwrap(), 0);
mgr.io().sync_dir(mgr.data_dir()).unwrap();
mgr.commit_rotation(next_id, next_fd);
assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd);
mgr.commit_rotation(next_id, &next_handle);
assert_eq!(mgr.open_for_read(next_id).unwrap().fd(), next_handle.fd());
}
#[test]
fn rotation_rollback_cleans_handle() {
fn rotation_rollback_cleans_handle_and_deletes_file() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let (next_id, next_fd) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
mgr.commit_rotation(next_id, next_fd);
let _h0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let (next_id, next_handle) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
mgr.commit_rotation(next_id, &next_handle);
assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd);
mgr.rollback_rotation(next_id, next_fd);
assert_eq!(mgr.open_for_read(next_id).unwrap().fd(), next_handle.fd());
drop(next_handle);
mgr.rollback_rotation(next_id);
let reopened_fd = mgr.open_for_read(next_id).unwrap();
assert_ne!(
reopened_fd, next_fd,
"rollback should have closed the cached fd"
let reopen = mgr.open_for_read(next_id);
assert!(
reopen.is_err_and(|e| e.kind() == io::ErrorKind::NotFound),
"rollback_rotation must delete the data file so recovery cannot resurrect uncommitted bytes"
);
}
@@ -244,8 +256,8 @@ mod tests {
#[test]
fn list_files_finds_data_files() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let _fd3 = mgr.open_for_append(DataFileId::new(3)).unwrap();
let _h0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let _h3 = mgr.open_for_append(DataFileId::new(3)).unwrap();
let files = mgr.list_files().unwrap();
assert_eq!(files, vec![DataFileId::new(0), DataFileId::new(3)]);
@@ -254,7 +266,7 @@ mod tests {
#[test]
fn list_files_ignores_non_data_files() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let _h0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
mgr.io()
.open(Path::new("/data/notes.txt"), OpenOptions::read_write())
.unwrap();
@@ -279,32 +291,32 @@ mod tests {
#[test]
fn rotate_and_write_across_files() {
let mgr = setup_manager(1024);
let fd0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let mut writer0 = DataFileWriter::new(mgr.io(), fd0, DataFileId::new(0)).unwrap();
let h0 = mgr.open_for_append(DataFileId::new(0)).unwrap();
let mut writer0 = DataFileWriter::new(mgr.io(), h0.fd(), DataFileId::new(0)).unwrap();
let _ = writer0
.append_block(&test_cid(1), b"first file data")
.unwrap();
writer0.sync().unwrap();
let (id1, fd1) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
let (id1, h1) = mgr.prepare_rotation(DataFileId::new(0)).unwrap();
mgr.io().sync_dir(mgr.data_dir()).unwrap();
mgr.commit_rotation(id1, fd1);
let mut writer1 = DataFileWriter::new(mgr.io(), fd1, id1).unwrap();
mgr.commit_rotation(id1, &h1);
let mut writer1 = DataFileWriter::new(mgr.io(), h1.fd(), id1).unwrap();
let _ = writer1
.append_block(&test_cid(2), b"second file data")
.unwrap();
writer1.sync().unwrap();
let fd0_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
let blocks0 = DataFileReader::open(mgr.io(), fd0_read)
let h0_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
let blocks0 = DataFileReader::open(mgr.io(), h0_read.fd())
.unwrap()
.valid_blocks()
.unwrap();
assert_eq!(blocks0.len(), 1);
assert_eq!(blocks0[0].2, b"first file data");
let fd1_read = mgr.open_for_read(id1).unwrap();
let blocks1 = DataFileReader::open(mgr.io(), fd1_read)
let h1_read = mgr.open_for_read(id1).unwrap();
let blocks1 = DataFileReader::open(mgr.io(), h1_read.fd())
.unwrap()
.valid_blocks()
.unwrap();
@@ -315,11 +327,11 @@ mod tests {
#[test]
fn read_cache_hit_from_writable_entry() {
let mgr = setup_manager(1024);
let fd_write = mgr.open_for_append(DataFileId::new(0)).unwrap();
DataFileWriter::new(mgr.io(), fd_write, DataFileId::new(0)).unwrap();
let h_write = mgr.open_for_append(DataFileId::new(0)).unwrap();
DataFileWriter::new(mgr.io(), h_write.fd(), DataFileId::new(0)).unwrap();
let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
assert_eq!(fd_write, fd_read);
let h_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
assert_eq!(h_write.fd(), h_read.fd());
}
#[test]
@@ -338,15 +350,15 @@ mod tests {
mgr.io().sync_dir(mgr.data_dir()).unwrap();
mgr.io().close(raw_fd).unwrap();
let fd_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
let _reader = DataFileReader::open(mgr.io(), fd_read).unwrap();
let h_read = mgr.open_for_read(DataFileId::new(0)).unwrap();
let _reader = DataFileReader::open(mgr.io(), h_read.fd()).unwrap();
let fd_append = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_ne!(fd_read, fd_append);
let h_append = mgr.open_for_append(DataFileId::new(0)).unwrap();
assert_ne!(h_read.fd(), h_append.fd());
let mut writer = DataFileWriter::resume(
mgr.io(),
fd_append,
h_append.fd(),
DataFileId::new(0),
BlockOffset::new(BLOCK_HEADER_SIZE as u64),
);
@@ -355,7 +367,7 @@ mod tests {
.unwrap();
writer.sync().unwrap();
let blocks = DataFileReader::open(mgr.io(), fd_append)
let blocks = DataFileReader::open(mgr.io(), h_append.fd())
.unwrap()
.valid_blocks()
.unwrap();
+3 -1
View File
@@ -1,3 +1,4 @@
mod cid_util;
mod compaction;
mod data_file;
mod group_commit;
@@ -8,6 +9,7 @@ mod reader;
mod store;
mod types;
pub use cid_util::{DAG_CBOR_CODEC, SHA2_256_CODE, hash_to_cid, hash_to_cid_bytes};
pub use compaction::CompactionError;
pub use data_file::{
BLOCK_FORMAT_VERSION, BLOCK_HEADER_SIZE, BLOCK_MAGIC, BLOCK_RECORD_OVERHEAD, CID_SIZE,
@@ -22,7 +24,7 @@ pub use hint::{
HINT_FILE_EXTENSION, HINT_RECORD_SIZE, HintFileReader, HintFileWriter, HintIndex,
ReadHintRecord, RebuildError, decode_hint_record, hint_file_path, scan_hints_to_memory,
};
pub use manager::{DEFAULT_MAX_FILE_SIZE, DataFileManager};
pub use manager::{CachedHandle, DEFAULT_MAX_FILE_SIZE, DataFileManager};
pub use reader::{BlockStoreReader, ReadError};
pub use store::QuiesceGuard;
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, TranquilBlockStore};
+35 -24
View File
@@ -104,11 +104,11 @@ impl<S: StorageIO> BlockStoreReader<S> {
});
by_file.into_iter().try_for_each(|(file_id, mut entries)| {
let fd = self.manager.open_for_read(file_id)?;
let file_size = self.manager.io().file_size(fd)?;
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(fd, file_size, loc)?;
let data = self.decode_and_validate(handle.fd(), file_size, loc)?;
results[orig_idx] = Some(data);
Ok::<_, ReadError>(())
})
@@ -116,9 +116,9 @@ impl<S: StorageIO> BlockStoreReader<S> {
}
fn read_block_at(&self, location: BlockLocation) -> Result<Bytes, ReadError> {
let fd = self.manager.open_for_read(location.file_id)?;
let file_size = self.manager.io().file_size(fd)?;
self.decode_and_validate(fd, file_size, location)
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)
}
fn decode_and_validate(
@@ -127,26 +127,37 @@ impl<S: StorageIO> BlockStoreReader<S> {
file_size: u64,
location: BlockLocation,
) -> 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 =>
{
Ok(Bytes::from(data))
}
Some(ReadBlockRecord::Valid { .. }) => Err(ReadError::Corrupted {
file_id: location.file_id,
offset: location.offset,
}),
Some(ReadBlockRecord::Corrupted { offset } | ReadBlockRecord::Truncated { offset }) => {
Err(ReadError::Corrupted {
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 =>
{
Ok(Bytes::from(data))
}
Some(ReadBlockRecord::Valid { .. }) => Err(ReadError::Corrupted {
file_id: location.file_id,
offset: location.offset,
}),
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,
}),
}
None => Err(ReadError::Corrupted {
file_id: location.file_id,
offset: location.offset,
}),
}
};
(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)),
})
.unwrap_or_else(attempt_once)
}
}
const READ_RETRY_ATTEMPTS: u32 = 4;
+86 -56
View File
@@ -8,12 +8,11 @@ use cid::Cid;
use jacquard_repo::error::RepoError;
use jacquard_repo::repo::CommitData;
use jacquard_repo::storage::BlockStore;
use multihash::Multihash;
use sha2::{Digest, Sha256};
use crate::fsync_order::PostBlockstoreHook;
use crate::io::{OpenOptions, RealIO, StorageIO};
use super::cid_util::hash_to_cid;
use super::compaction::CompactionError;
use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, ReadBlockRecord};
use super::group_commit::{CommitError, CommitRequest, GroupCommitConfig, GroupCommitWriter};
@@ -21,13 +20,10 @@ use super::hash_index::BlockIndex;
use super::manager::DataFileManager;
use super::reader::{BlockStoreReader, ReadError};
use super::types::{
BlockLength, BlockLocation, BlockOffset, CollectionResult, CompactionResult, DataFileId,
EpochCounter, LivenessInfo, WallClockMs, WriteCursor,
BlockLocation, BlockOffset, CollectionResult, CompactionResult, DataFileId, EpochCounter,
LivenessInfo, WallClockMs,
};
const DAG_CBOR_CODEC: u64 = 0x71;
const SHA2_256_CODE: u64 = 0x12;
fn cid_to_bytes(cid: &Cid) -> Result<[u8; CID_SIZE], RepoError> {
let raw = cid.to_bytes();
let len = raw.len();
@@ -41,20 +37,6 @@ fn cid_to_bytes(cid: &Cid) -> Result<[u8; CID_SIZE], RepoError> {
})
}
fn hash_and_cid(data: &[u8]) -> Result<Cid, RepoError> {
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
let multihash = Multihash::wrap(SHA2_256_CODE, &hash).map_err(|e| {
RepoError::storage(io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
})?;
Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash))
}
fn block_index_err_to_repo(e: super::hash_index::BlockIndexError) -> RepoError {
RepoError::storage(io::Error::other(e.to_string()))
}
fn commit_error_to_repo(e: CommitError) -> RepoError {
match e {
CommitError::Io(io_err) => {
@@ -65,6 +47,10 @@ fn commit_error_to_repo(e: CommitError) -> RepoError {
io::ErrorKind::BrokenPipe,
"blockstore commit channel closed",
)),
CommitError::VerifyFailed { file_id, offset } => RepoError::storage(io::Error::new(
io::ErrorKind::InvalidData,
format!("post-sync verify failed at {file_id}:{}", offset.raw()),
)),
}
}
@@ -123,15 +109,26 @@ impl Drop for QuiesceGuard {
}
}
#[derive(Clone)]
pub struct TranquilBlockStore {
pub struct TranquilBlockStore<S: StorageIO + Send + Sync + 'static = RealIO> {
writer: Arc<WriterHandle>,
reader: Arc<BlockStoreReader<RealIO>>,
reader: Arc<BlockStoreReader<S>>,
index: Arc<BlockIndex>,
epoch: EpochCounter,
data_dir: PathBuf,
}
impl<S: StorageIO + Send + Sync + 'static> Clone for TranquilBlockStore<S> {
fn clone(&self) -> Self {
Self {
writer: Arc::clone(&self.writer),
reader: Arc::clone(&self.reader),
index: Arc::clone(&self.index),
epoch: self.epoch.clone(),
data_dir: self.data_dir.clone(),
}
}
}
struct WriterHandle {
inner: parking_lot::Mutex<Option<GroupCommitWriter>>,
}
@@ -153,7 +150,7 @@ impl Drop for WriterHandle {
}
}
impl TranquilBlockStore {
impl TranquilBlockStore<RealIO> {
pub fn open(config: BlockStoreConfig) -> Result<Self, RepoError> {
Self::open_with_hook(config, None)
}
@@ -162,6 +159,26 @@ impl TranquilBlockStore {
config: BlockStoreConfig,
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
) -> Result<Self, RepoError> {
Self::open_with_io_hook(config, RealIO::new, post_sync_hook)
}
}
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
pub fn open_with_io<F>(config: BlockStoreConfig, make_io: F) -> Result<Self, RepoError>
where
F: Fn() -> S + Send + Sync + Clone + 'static,
{
Self::open_with_io_hook(config, make_io, None)
}
pub fn open_with_io_hook<F>(
config: BlockStoreConfig,
make_io: F,
post_sync_hook: Option<Arc<dyn PostBlockstoreHook>>,
) -> Result<Self, RepoError>
where
F: Fn() -> S + Send + Sync + Clone + 'static,
{
if config.data_dir == config.index_dir {
return Err(RepoError::storage(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -173,7 +190,7 @@ impl TranquilBlockStore {
let index = BlockIndex::open(&config.index_dir).map_err(RepoError::storage)?;
let io = RealIO::new();
let io = make_io();
let (replayed, file_cursors) = super::hint::replay_hints_into_block_index(
&io,
@@ -195,8 +212,13 @@ impl TranquilBlockStore {
let max_file_size = config.max_file_size;
let shard_count = config.shard_count;
let data_dir_for_closure = data_dir.clone();
let make_io_for_manager = make_io.clone();
let make_manager = move || {
DataFileManager::new(RealIO::new(), data_dir_for_closure.clone(), max_file_size)
DataFileManager::new(
make_io_for_manager(),
data_dir_for_closure.clone(),
max_file_size,
)
};
let checkpoint_epoch = index.loaded_checkpoint_epoch();
@@ -214,7 +236,7 @@ impl TranquilBlockStore {
let epoch = writer.epoch().clone();
let manager_for_reader = Arc::new(DataFileManager::new(
RealIO::new(),
make_io(),
data_dir.clone(),
max_file_size,
));
@@ -234,7 +256,7 @@ impl TranquilBlockStore {
})
}
fn recover_from_file_cursors<S: StorageIO>(
fn recover_from_file_cursors(
io: &S,
data_dir: &Path,
index: &BlockIndex,
@@ -256,7 +278,7 @@ impl TranquilBlockStore {
})
}
fn replay_single_file<S: StorageIO>(
fn replay_single_file(
io: &S,
data_dir: &Path,
index: &BlockIndex,
@@ -277,19 +299,28 @@ impl TranquilBlockStore {
Err(e) => return Err(RepoError::storage(e)),
};
let result = Self::scan_and_index(io, index, fd, file_id, start_offset);
let hint_path = super::hint::hint_file_path(data_dir, file_id);
let hint_exists = io
.open(&hint_path, OpenOptions::read_only_existing())
.map(|fd| {
let _ = io.close(fd);
})
.is_ok();
let result = Self::scan_and_index(io, index, fd, file_id, start_offset, hint_exists);
let _ = io.close(fd);
result
}
fn scan_and_index<S: StorageIO>(
fn scan_and_index(
io: &S,
index: &BlockIndex,
fd: crate::io::FileId,
file_id: DataFileId,
start_offset: BlockOffset,
hint_exists: bool,
) -> Result<(), RepoError> {
let file_size = io.file_size(fd).map_err(RepoError::storage)?;
@@ -298,7 +329,7 @@ impl TranquilBlockStore {
}
let scan_pos = &mut { start_offset };
let (recovered_entries, last_valid_end) = std::iter::from_fn(|| {
let (scanned_entries, last_valid_end) = std::iter::from_fn(|| {
match super::data_file::decode_block_record(io, fd, *scan_pos, file_size) {
Err(e) => {
tracing::warn!(
@@ -319,9 +350,10 @@ impl TranquilBlockStore {
Ok(n) if n <= super::types::MAX_BLOCK_SIZE => n,
_ => return None,
};
let length = BlockLength::new(raw_len);
let length = super::types::BlockLength::new(raw_len);
let record_size = BLOCK_RECORD_OVERHEAD as u64 + u64::from(raw_len);
*scan_pos = scan_pos.advance(record_size);
let new_end = offset.advance(record_size);
*scan_pos = new_end;
Some((
cid_bytes,
BlockLocation {
@@ -329,6 +361,7 @@ impl TranquilBlockStore {
offset,
length,
},
new_end,
))
}
Ok(Some(ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. })) => {
@@ -338,11 +371,8 @@ impl TranquilBlockStore {
})
.fold(
(Vec::new(), start_offset),
|(mut entries, _), (cid_bytes, location)| {
let new_end = location
.offset
.advance(BLOCK_RECORD_OVERHEAD as u64 + location.length.as_u64());
entries.push((cid_bytes, location));
|(mut entries, _), (cid, loc, new_end)| {
entries.push((cid, loc));
(entries, new_end)
},
);
@@ -352,28 +382,28 @@ impl TranquilBlockStore {
file_id = %file_id,
truncating_from = last_valid_end.raw(),
file_size,
"truncating partial/corrupted tail"
scanned_count = scanned_entries.len(),
"truncating partial/unacked tail"
);
io.truncate(fd, last_valid_end.raw())
.map_err(RepoError::storage)?;
io.sync(fd).map_err(RepoError::storage)?;
}
if !recovered_entries.is_empty() {
let new_cursor = WriteCursor {
if !scanned_entries.is_empty() {
tracing::info!(
file_id = %file_id,
scanned = scanned_entries.len(),
hint_exists,
"reindexing blocks past hint coverage"
);
let cursor = super::types::WriteCursor {
file_id,
offset: last_valid_end,
};
let inserted = index
.batch_put_if_absent(&recovered_entries, new_cursor)
.map_err(block_index_err_to_repo)?;
tracing::info!(
file_id = %file_id,
scanned = recovered_entries.len(),
inserted,
new_cursor_offset = last_valid_end.raw(),
"recovery data file scan"
);
index
.batch_put_if_absent(&scanned_entries, cursor)
.map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?;
}
Ok(())
@@ -594,7 +624,7 @@ impl TranquilBlockStore {
}
}
impl BlockStore for TranquilBlockStore {
impl<S: StorageIO + Send + Sync + 'static> BlockStore for TranquilBlockStore<S> {
async fn get(&self, cid: &Cid) -> Result<Option<Bytes>, RepoError> {
let cid_bytes = cid_to_bytes(cid)?;
let reader = Arc::clone(&self.reader);
@@ -605,7 +635,7 @@ impl BlockStore for TranquilBlockStore {
}
async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> {
let cid = hash_and_cid(data)?;
let cid = hash_to_cid(data);
let cid_bytes = cid_to_bytes(&cid)?;
self.send_put_blocks(vec![(cid_bytes, data.to_vec())])
.await?;
@@ -666,7 +696,7 @@ impl BlockStore for TranquilBlockStore {
}
}
impl TranquilBlockStore {
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
pub async fn decrement_refs(&self, cids: &[Cid]) -> Result<(), RepoError> {
if cids.is_empty() {
return Ok(());
+127 -130
View File
@@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use parking_lot::RwLock;
@@ -25,17 +26,38 @@ pub fn parse_segment_id(path: &Path) -> Option<SegmentId> {
(ext == SEGMENT_FILE_EXTENSION).then(|| stem.parse::<u32>().ok().map(SegmentId::new))?
}
struct CachedSegmentHandle {
pub struct CachedSegmentHandle<S: StorageIO> {
fd: FileId,
sealed: bool,
io: Arc<S>,
sealed: AtomicBool,
writable: bool,
}
impl<S: StorageIO> CachedSegmentHandle<S> {
pub fn fd(&self) -> FileId {
self.fd
}
pub fn is_sealed(&self) -> bool {
self.sealed.load(Ordering::Acquire)
}
pub fn is_writable(&self) -> bool {
self.writable
}
}
impl<S: StorageIO> Drop for CachedSegmentHandle<S> {
fn drop(&mut self) {
let _ = self.io.close(self.fd);
}
}
pub struct SegmentManager<S: StorageIO> {
io: S,
io: Arc<S>,
segments_dir: PathBuf,
max_segment_size: u64,
handles: RwLock<HashMap<SegmentId, CachedSegmentHandle>>,
handles: RwLock<HashMap<SegmentId, Arc<CachedSegmentHandle<S>>>>,
retention_epoch: AtomicU64,
}
@@ -51,7 +73,7 @@ impl<S: StorageIO> SegmentManager<S> {
);
io.mkdir(&segments_dir)?;
Ok(Self {
io,
io: Arc::new(io),
segments_dir,
max_segment_size,
handles: RwLock::new(HashMap::new()),
@@ -60,7 +82,7 @@ impl<S: StorageIO> SegmentManager<S> {
}
pub fn io(&self) -> &S {
&self.io
self.io.as_ref()
}
pub fn segments_dir(&self) -> &Path {
@@ -92,52 +114,51 @@ impl<S: StorageIO> SegmentManager<S> {
Ok(ids)
}
pub fn open_for_read(&self, id: SegmentId) -> io::Result<FileId> {
pub fn open_for_read(&self, id: SegmentId) -> io::Result<Arc<CachedSegmentHandle<S>>> {
if let Some(entry) = self.handles.read().get(&id) {
return Ok(entry.fd);
return Ok(Arc::clone(entry));
}
let path = self.segment_path(id);
let fd = self.io.open(&path, OpenOptions::read_only_existing())?;
let mut cache = self.handles.write();
match cache.get(&id) {
match cache.get(&id).cloned() {
Some(entry) => {
let _ = self.io.close(fd);
Ok(entry.fd)
Ok(entry)
}
None => {
cache.insert(
id,
CachedSegmentHandle {
fd,
sealed: false,
writable: false,
},
);
Ok(fd)
let handle = Arc::new(CachedSegmentHandle {
fd,
io: Arc::clone(&self.io),
sealed: AtomicBool::new(false),
writable: false,
});
cache.insert(id, Arc::clone(&handle));
Ok(handle)
}
}
}
pub fn open_for_append(&self, id: SegmentId) -> io::Result<FileId> {
pub fn open_for_append(&self, id: SegmentId) -> io::Result<Arc<CachedSegmentHandle<S>>> {
{
let cache = self.handles.read();
if let Some(entry) = cache.get(&id) {
if entry.sealed {
if entry.is_sealed() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("cannot append to sealed segment {id}"),
));
}
if entry.writable {
return Ok(entry.fd);
return Ok(Arc::clone(entry));
}
}
}
let path = self.segment_path(id);
let fd = self.io.open(&path, OpenOptions::read_write())?;
let mut cache = self.handles.write();
match cache.get(&id) {
Some(entry) if entry.sealed => {
match cache.get(&id).cloned() {
Some(entry) if entry.is_sealed() => {
let _ = self.io.close(fd);
Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -146,31 +167,17 @@ impl<S: StorageIO> SegmentManager<S> {
}
Some(entry) if entry.writable => {
let _ = self.io.close(fd);
Ok(entry.fd)
Ok(entry)
}
Some(entry) => {
let old_fd = entry.fd;
cache.insert(
id,
CachedSegmentHandle {
fd,
sealed: false,
writable: true,
},
);
let _ = self.io.close(old_fd);
Ok(fd)
}
None => {
cache.insert(
id,
CachedSegmentHandle {
fd,
sealed: false,
writable: true,
},
);
Ok(fd)
_ => {
let handle = Arc::new(CachedSegmentHandle {
fd,
io: Arc::clone(&self.io),
sealed: AtomicBool::new(false),
writable: true,
});
cache.insert(id, Arc::clone(&handle));
Ok(handle)
}
}
}
@@ -179,37 +186,39 @@ impl<S: StorageIO> SegmentManager<S> {
position.raw() >= self.max_segment_size
}
pub fn prepare_rotation(&self, current_id: SegmentId) -> io::Result<(SegmentId, FileId)> {
pub fn prepare_rotation(
&self,
current_id: SegmentId,
) -> io::Result<(SegmentId, Arc<CachedSegmentHandle<S>>)> {
let next = current_id.next();
let path = self.segment_path(next);
let fd = self.io.open(&path, OpenOptions::read_write())?;
self.io.truncate(fd, 0)?;
self.io.sync_dir(&self.segments_dir)?;
Ok((next, fd))
let handle = Arc::new(CachedSegmentHandle {
fd,
io: Arc::clone(&self.io),
sealed: AtomicBool::new(false),
writable: true,
});
Ok((next, handle))
}
pub fn commit_rotation(&self, new_id: SegmentId, fd: FileId) {
self.handles.write().insert(
new_id,
CachedSegmentHandle {
fd,
sealed: false,
writable: true,
},
);
pub fn commit_rotation(&self, new_id: SegmentId, handle: &Arc<CachedSegmentHandle<S>>) {
self.handles.write().insert(new_id, Arc::clone(handle));
}
pub fn seal_segment(&self, id: SegmentId, index: &SegmentIndex) -> io::Result<()> {
let path = self.index_path(id);
index.save(&self.io, &path)?;
let mut cache = self.handles.write();
let entry = cache.get_mut(&id).ok_or_else(|| {
index.save(self.io.as_ref(), &path)?;
let cache = self.handles.read();
let entry = cache.get(&id).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("seal_segment: segment {id} not in handle cache"),
)
})?;
entry.sealed = true;
entry.sealed.store(true, Ordering::Release);
Ok(())
}
@@ -217,22 +226,16 @@ impl<S: StorageIO> SegmentManager<S> {
self.handles
.read()
.get(&id)
.is_some_and(|entry| entry.sealed)
.is_some_and(|entry| entry.is_sealed())
}
pub fn rollback_rotation(&self, new_id: SegmentId, fd: FileId) {
let _ = self.io.close(fd);
pub fn rollback_rotation(&self, new_id: SegmentId) {
self.handles.write().remove(&new_id);
let _ = self.io.delete(&self.segment_path(new_id));
}
pub fn delete_segment(&self, id: SegmentId) -> io::Result<()> {
{
let mut cache = self.handles.write();
if let Some(entry) = cache.remove(&id) {
let _ = self.io.close(entry.fd);
}
}
self.handles.write().remove(&id);
[self.index_path(id), self.sidecar_path(id)]
.iter()
.try_for_each(|path| match self.io.delete(path) {
@@ -255,15 +258,7 @@ impl<S: StorageIO> SegmentManager<S> {
}
pub fn shutdown(&self) {
self.handles.write().drain().for_each(|(_, handle)| {
let _ = self.io.close(handle.fd);
});
}
}
impl<S: StorageIO> Drop for SegmentManager<S> {
fn drop(&mut self) {
self.shutdown();
self.handles.write().clear();
}
}
@@ -329,7 +324,7 @@ mod tests {
#[test]
fn open_for_append_creates_file() {
let mgr = setup_manager(1024);
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
assert_eq!(mgr.io().file_size(fd).unwrap(), 0);
}
@@ -342,24 +337,24 @@ mod tests {
#[test]
fn handle_cache_returns_same_fd() {
let mgr = setup_manager(1024);
let fd1 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd2 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd1 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let fd2 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
assert_eq!(fd1, fd2);
}
#[test]
fn open_for_read_uses_cache_from_append() {
let mgr = setup_manager(1024);
let fd_write = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd_read = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd_write = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let fd_read = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
assert_eq!(fd_write, fd_read);
}
#[test]
fn list_segments_finds_segment_files() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(3)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.open_for_append(SegmentId::new(3)).unwrap().fd();
let segments = mgr.list_segments().unwrap();
assert_eq!(segments, vec![SegmentId::new(1), SegmentId::new(3)]);
@@ -368,7 +363,7 @@ mod tests {
#[test]
fn list_segments_ignores_non_segment_files() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.io()
.open(Path::new("/segments/notes.txt"), OpenOptions::read_write())
.unwrap();
@@ -380,7 +375,7 @@ mod tests {
#[test]
fn list_segments_ignores_index_files() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.io()
.open(
Path::new("/segments/00000001.tqi"),
@@ -395,9 +390,9 @@ mod tests {
#[test]
fn list_segments_sorted_ascending() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(5)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(3)).unwrap();
mgr.open_for_append(SegmentId::new(5)).unwrap().fd();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.open_for_append(SegmentId::new(3)).unwrap().fd();
let segments = mgr.list_segments().unwrap();
assert_eq!(
@@ -418,23 +413,24 @@ mod tests {
#[test]
fn rotation_lifecycle_prepare_commit() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let (next_id, next_fd) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
let _h0 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let (next_id, next_handle) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
assert_eq!(next_id, SegmentId::new(2));
assert_eq!(mgr.io().file_size(next_fd).unwrap(), 0);
mgr.commit_rotation(next_id, next_fd);
assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd);
assert_eq!(mgr.io().file_size(next_handle.fd()).unwrap(), 0);
mgr.commit_rotation(next_id, &next_handle);
assert_eq!(mgr.open_for_read(next_id).unwrap().fd(), next_handle.fd());
}
#[test]
fn rotation_rollback_cleans_up() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let (next_id, next_fd) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
mgr.commit_rotation(next_id, next_fd);
let _h0 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let (next_id, next_handle) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
mgr.commit_rotation(next_id, &next_handle);
assert_eq!(mgr.open_for_read(next_id).unwrap(), next_fd);
mgr.rollback_rotation(next_id, next_fd);
assert_eq!(mgr.open_for_read(next_id).unwrap().fd(), next_handle.fd());
drop(next_handle);
mgr.rollback_rotation(next_id);
let segments = mgr.list_segments().unwrap();
assert_eq!(segments, vec![SegmentId::new(1)]);
@@ -443,7 +439,7 @@ mod tests {
#[test]
fn seal_segment_persists_index_and_marks_sealed() {
let mgr = setup_manager(64 * 1024);
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let mut writer = SegmentWriter::new(
mgr.io(),
fd,
@@ -476,7 +472,7 @@ mod tests {
#[test]
fn delete_segment_removes_files_and_handle() {
let mgr = setup_manager(64 * 1024);
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let mut writer = SegmentWriter::new(
mgr.io(),
fd,
@@ -507,9 +503,9 @@ mod tests {
let mgr = setup_manager(1024);
assert_eq!(mgr.oldest_segment().unwrap(), None);
mgr.open_for_append(SegmentId::new(3)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(5)).unwrap();
mgr.open_for_append(SegmentId::new(3)).unwrap().fd();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.open_for_append(SegmentId::new(5)).unwrap().fd();
assert_eq!(mgr.oldest_segment().unwrap(), Some(SegmentId::new(1)));
}
@@ -524,7 +520,7 @@ mod tests {
fn rotate_and_write_across_segments() {
let mgr = setup_manager(1024);
let fd1 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd1 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let mut writer1 = SegmentWriter::new(
mgr.io(),
fd1,
@@ -538,8 +534,9 @@ mod tests {
.unwrap();
writer1.sync(mgr.io()).unwrap();
let (id2, fd2) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
mgr.commit_rotation(id2, fd2);
let (id2, handle2) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
let fd2 = handle2.fd();
mgr.commit_rotation(id2, &handle2);
let mut writer2 =
SegmentWriter::new(mgr.io(), fd2, id2, EventSequence::new(2), MAX_EVENT_PAYLOAD)
@@ -549,7 +546,7 @@ mod tests {
.unwrap();
writer2.sync(mgr.io()).unwrap();
let fd1_read = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd1_read = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let events1 = crate::eventlog::SegmentReader::open(mgr.io(), fd1_read, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -557,7 +554,7 @@ mod tests {
assert_eq!(events1.len(), 1);
assert_eq!(events1[0].payload, b"first segment");
let fd2_read = mgr.open_for_read(id2).unwrap();
let fd2_read = mgr.open_for_read(id2).unwrap().fd();
let events2 = crate::eventlog::SegmentReader::open(mgr.io(), fd2_read, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -569,7 +566,7 @@ mod tests {
#[test]
fn seal_then_append_errors() {
let mgr = setup_manager(64 * 1024);
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
SegmentWriter::new(
mgr.io(),
fd,
@@ -596,9 +593,9 @@ mod tests {
#[test]
fn multiple_deletions_increment_epoch() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(2)).unwrap();
mgr.open_for_append(SegmentId::new(3)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.open_for_append(SegmentId::new(2)).unwrap().fd();
mgr.open_for_append(SegmentId::new(3)).unwrap().fd();
assert_eq!(mgr.retention_epoch(), 0);
mgr.delete_segment(SegmentId::new(1)).unwrap();
@@ -610,7 +607,7 @@ mod tests {
#[test]
fn open_for_read_does_not_infer_sealed_from_index_file() {
let mgr = setup_manager(64 * 1024);
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let mut writer = SegmentWriter::new(
mgr.io(),
fd,
@@ -630,26 +627,26 @@ mod tests {
mgr.handles.write().remove(&SegmentId::new(1));
let _read_fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let _read_fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
assert!(!mgr.is_sealed(SegmentId::new(1)));
}
#[test]
fn open_for_read_unsealed_allows_append() {
let mgr = setup_manager(1024);
let _fd = mgr.open_for_append(SegmentId::new(1)).unwrap();
let _fd = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.handles.write().remove(&SegmentId::new(1));
let _read_fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let _read_fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
assert!(!mgr.is_sealed(SegmentId::new(1)));
}
#[test]
fn shutdown_clears_handles() {
let mgr = setup_manager(1024);
mgr.open_for_append(SegmentId::new(1)).unwrap();
mgr.open_for_append(SegmentId::new(2)).unwrap();
mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.open_for_append(SegmentId::new(2)).unwrap().fd();
mgr.shutdown();
assert!(mgr.handles.read().is_empty());
@@ -665,7 +662,7 @@ mod tests {
#[test]
fn prepare_rotation_truncates_stale_file() {
let mgr = setup_manager(1024);
let _fd0 = mgr.open_for_append(SegmentId::new(1)).unwrap();
let _fd0 = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
let stale_path = mgr.segment_path(SegmentId::new(2));
let stale_fd = mgr
@@ -677,23 +674,23 @@ mod tests {
assert_eq!(mgr.io().file_size(stale_fd).unwrap(), 4096);
mgr.io().close(stale_fd).unwrap();
let (next_id, next_fd) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
let (next_id, next_handle) = mgr.prepare_rotation(SegmentId::new(1)).unwrap();
assert_eq!(next_id, SegmentId::new(2));
assert_eq!(mgr.io().file_size(next_fd).unwrap(), 0);
assert_eq!(mgr.io().file_size(next_handle.fd()).unwrap(), 0);
}
#[test]
fn open_for_append_upgrades_read_only_handle() {
let mgr = setup_manager(1024);
let fd_append = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd_append = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
mgr.handles.write().remove(&SegmentId::new(1));
let fd_read = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd_read = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
assert_ne!(fd_read, fd_append);
assert!(!mgr.handles.read().get(&SegmentId::new(1)).unwrap().writable);
let fd_upgraded = mgr.open_for_append(SegmentId::new(1)).unwrap();
let fd_upgraded = mgr.open_for_append(SegmentId::new(1)).unwrap().fd();
assert_ne!(fd_upgraded, fd_read);
assert!(mgr.handles.read().get(&SegmentId::new(1)).unwrap().writable);
}
+2 -2
View File
@@ -411,8 +411,8 @@ impl<S: StorageIO + 'static> EventLog<S> {
pub fn disk_usage(&self) -> io::Result<u64> {
let segments = self.manager.list_segments()?;
segments.iter().try_fold(0u64, |acc, &id| {
let fd = self.manager.open_for_read(id)?;
let size = self.manager.io().file_size(fd)?;
let handle = self.manager.open_for_read(id)?;
let size = self.manager.io().file_size(handle.fd())?;
Ok(acc.saturating_add(size))
})
}
+9 -9
View File
@@ -145,10 +145,10 @@ impl<S: StorageIO> EventLogReader<S> {
}
fn rebuild_index(&self, segment_id: SegmentId) -> io::Result<SegmentIndex> {
let fd = self.manager.open_for_read(segment_id)?;
let handle = self.manager.open_for_read(segment_id)?;
let (idx, _) = rebuild_from_segment(
self.manager.io(),
fd,
handle.fd(),
DEFAULT_INDEX_INTERVAL,
self.max_payload,
)?;
@@ -243,8 +243,8 @@ impl<S: StorageIO> EventLogReader<S> {
return Ok(Arc::clone(m));
}
let fd = self.manager.open_for_read(segment_id)?;
let mapped = self.manager.io().mmap_file(fd)?;
let handle = self.manager.open_for_read(segment_id)?;
let mapped = self.manager.io().mmap_file(handle.fd())?;
let arc = Arc::new(mapped);
self.mmaps.write().insert(segment_id, Arc::clone(&arc));
Ok(arc)
@@ -269,10 +269,10 @@ impl<S: StorageIO> EventLogReader<S> {
predicate,
)
} else {
let fd = self.manager.open_for_read(segment_id)?;
let file_size = self.manager.io().file_size(fd)?;
let handle = self.manager.open_for_read(segment_id)?;
let file_size = self.manager.io().file_size(handle.fd())?;
self.scan_direct(
fd,
handle.fd(),
file_size,
start_offset,
start_seq,
@@ -513,8 +513,8 @@ impl<S: StorageIO> EventLogReader<S> {
}
fn rebuild_sidecar(&self, segment_id: SegmentId) -> io::Result<SidecarIndex> {
let fd = self.manager.open_for_read(segment_id)?;
let sidecar = build_sidecar_from_segment(self.manager.io(), fd, self.max_payload)?;
let handle = self.manager.open_for_read(segment_id)?;
let sidecar = build_sidecar_from_segment(self.manager.io(), handle.fd(), self.max_payload)?;
let _ = sidecar.save(self.manager.io(), &self.manager.sidecar_path(segment_id));
Ok(sidecar)
}
+23 -16
View File
@@ -66,9 +66,10 @@ impl<S: StorageIO> EventLogWriter<S> {
index_interval: usize,
max_payload: u32,
) -> io::Result<Self> {
let fd = manager.open_for_append(segment_id)?;
manager.io().truncate(fd, 0)?;
let writer = SegmentWriter::new(manager.io(), fd, segment_id, next_seq, max_payload)?;
let handle = manager.open_for_append(segment_id)?;
manager.io().truncate(handle.fd(), 0)?;
let writer =
SegmentWriter::new(manager.io(), handle.fd(), segment_id, next_seq, max_payload)?;
writer.sync(manager.io())?;
manager.io().sync_dir(manager.segments_dir())?;
@@ -93,7 +94,8 @@ impl<S: StorageIO> EventLogWriter<S> {
index_interval: usize,
max_payload: u32,
) -> io::Result<Self> {
let fd = manager.open_for_append(active_id)?;
let handle = manager.open_for_append(active_id)?;
let fd = handle.fd();
let (index, last_seq_in_active) = match rebuild_from_segment(
manager.io(),
@@ -308,11 +310,11 @@ impl<S: StorageIO> EventLogWriter<S> {
Err(e) => warn!(segment = %old_id, error = %e, "non-fatal sidecar build failure"),
}
let (new_id, new_fd) = self.manager.prepare_rotation(old_id)?;
let (new_id, new_handle) = self.manager.prepare_rotation(old_id)?;
match SegmentWriter::new::<S>(
self.manager.io(),
new_fd,
new_handle.fd(),
new_id,
self.next_seq,
self.max_payload,
@@ -322,11 +324,12 @@ impl<S: StorageIO> EventLogWriter<S> {
self.active_index = SegmentIndex::new();
self.event_count_in_segment = 0;
self.last_event_offset = None;
self.manager.commit_rotation(new_id, new_fd);
self.manager.commit_rotation(new_id, &new_handle);
Ok(Some(old_id))
}
Err(e) => {
self.manager.rollback_rotation(new_id, new_fd);
drop(new_handle);
self.manager.rollback_rotation(new_id);
Err(e)
}
}
@@ -361,8 +364,8 @@ impl<S: StorageIO> EventLogWriter<S> {
}
fn build_sidecar_for_segment(&self, segment_id: SegmentId) -> io::Result<()> {
let fd = self.manager.open_for_read(segment_id)?;
let sidecar = build_sidecar_from_segment(self.manager.io(), fd, self.max_payload)?;
let handle = self.manager.open_for_read(segment_id)?;
let sidecar = build_sidecar_from_segment(self.manager.io(), handle.fd(), self.max_payload)?;
let path = self.manager.sidecar_path(segment_id);
sidecar.save(self.manager.io(), &path)
}
@@ -397,9 +400,13 @@ fn find_last_seq_from_segments<S: StorageIO>(
Ok(Some(idx)) => Ok(idx.last_seq()),
Err(e) if e.kind() != io::ErrorKind::InvalidData => Err(e),
_ => {
let fd = manager.open_for_read(seg_id)?;
let (_, last_seq) =
rebuild_from_segment(manager.io(), fd, DEFAULT_INDEX_INTERVAL, max_payload)?;
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)
}
}
@@ -551,7 +558,7 @@ mod tests {
assert_eq!(writer.synced_seq(), EventSequence::new(5));
assert_eq!(writer.active_segment_id(), SegmentId::new(1));
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -796,7 +803,7 @@ mod tests {
.unwrap();
assert_eq!(writer.next_seq, EventSequence::new(3));
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -1003,7 +1010,7 @@ mod tests {
assert_eq!(seq, EventSequence::new(4));
writer.sync().unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
+50 -5
View File
@@ -1,10 +1,15 @@
use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::time::{Duration, Instant};
use rayon::prelude::*;
use tokio::runtime::Runtime;
use super::op::Seed;
use super::runner::{Gauntlet, GauntletConfig, GauntletReport};
use super::invariants::InvariantViolation;
use super::op::{OpStream, Seed};
use super::runner::{
Gauntlet, GauntletConfig, GauntletReport, OpErrorCount, OpsExecuted, RestartCount,
};
thread_local! {
static RUNTIME: RefCell<Option<Runtime>> = const { RefCell::new(None) };
@@ -26,6 +31,19 @@ fn with_runtime<R>(f: impl FnOnce(&Runtime) -> R) -> R {
}
pub fn run_many<F>(make_config: F, seeds: impl IntoIterator<Item = Seed>) -> Vec<GauntletReport>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
run_many_timed(make_config, seeds)
.into_iter()
.map(|(r, _)| r)
.collect()
}
pub fn run_many_timed<F>(
make_config: F,
seeds: impl IntoIterator<Item = Seed>,
) -> Vec<(GauntletReport, Duration)>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
@@ -33,9 +51,36 @@ where
seeds
.into_par_iter()
.map(|s| {
let cfg = make_config(s);
let gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
with_runtime(|rt| rt.block_on(gauntlet.run()))
let start = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| {
let cfg = make_config(s);
let gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
with_runtime(|rt| rt.block_on(gauntlet.run()))
}));
let report = outcome.unwrap_or_else(|payload| {
RUNTIME.with(|cell| cell.borrow_mut().take());
panic_report(s, payload)
});
(report, start.elapsed())
})
.collect()
}
fn panic_report(seed: Seed, payload: Box<dyn std::any::Any + Send>) -> GauntletReport {
let msg = payload
.downcast_ref::<&'static str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
GauntletReport {
seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(0),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "FarmPanic",
detail: msg,
}],
ops: OpStream::empty(),
}
}
+448
View File
@@ -0,0 +1,448 @@
use std::env;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use tempfile::TempDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UpIntervalSecs(pub NonZeroU32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DownIntervalSecs(pub NonZeroU32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BackingMegabytes(pub u32);
const fn nz(n: u32) -> NonZeroU32 {
match NonZeroU32::new(n) {
Some(v) => v,
None => panic!("zero interval not permitted"),
}
}
#[derive(Debug, Clone, Copy)]
pub struct FlakyConfig {
pub up_interval: UpIntervalSecs,
pub down_interval: DownIntervalSecs,
pub backing_mb: BackingMegabytes,
}
impl FlakyConfig {
pub const fn default_stress() -> Self {
Self {
up_interval: UpIntervalSecs(nz(8)),
down_interval: DownIntervalSecs(nz(2)),
backing_mb: BackingMegabytes(256),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum FlakyError {
#[error("not running as root, EUID != 0")]
NotRoot,
#[error("tool missing: {0}")]
ToolMissing(&'static str),
#[error("kernel target dm-flakey unavailable: {0}")]
DmFlakeyMissing(String),
#[error("{tool} failed: status={status}, stderr={stderr}")]
CommandFailed {
tool: &'static str,
status: String,
stderr: String,
},
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
impl FlakyError {
pub const fn is_env_absent(&self) -> bool {
matches!(
self,
Self::NotRoot | Self::ToolMissing(_) | Self::DmFlakeyMissing(_)
)
}
}
static MOUNT_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct FlakyMount {
mount_point: TempDir,
mapper_name: String,
mapper_path: PathBuf,
loop_device: PathBuf,
backing_file: PathBuf,
_backing_tempdir: TempDir,
}
impl FlakyMount {
pub fn try_new(cfg: &FlakyConfig) -> Result<Self, FlakyError> {
ensure_root()?;
["losetup", "dmsetup", "mkfs.ext4", "mount", "umount"]
.iter()
.copied()
.try_for_each(ensure_tool)?;
probe_dm_flakey()?;
let _ = reap_stale_mounts();
let backing_dir = TempDir::new()?;
let backing_file = backing_dir.path().join("backing.img");
allocate_backing(&backing_file, cfg.backing_mb)?;
let loop_device = attach_loop(&backing_file)?;
let sectors = sector_count(&loop_device)?;
if let Err(e) = mkfs_ext4(&loop_device) {
let _ = detach_loop(&loop_device);
return Err(e);
}
let mapper_name = format!(
"tranquil-flaky-{}-{}",
std::process::id(),
MOUNT_COUNTER.fetch_add(1, Ordering::Relaxed),
);
match dm_create(
&mapper_name,
&loop_device,
sectors,
cfg.up_interval,
cfg.down_interval,
) {
Ok(()) => {}
Err(e) => {
let _ = detach_loop(&loop_device);
return Err(e);
}
}
let mapper_path = PathBuf::from(format!("/dev/mapper/{mapper_name}"));
let mount_point = TempDir::new()?;
if let Err(e) = mount_ext4(&mapper_path, mount_point.path()) {
let _ = dm_remove(&mapper_name);
let _ = detach_loop(&loop_device);
return Err(e);
}
Ok(Self {
mount_point,
mapper_name,
mapper_path,
loop_device,
backing_file,
_backing_tempdir: backing_dir,
})
}
pub fn path(&self) -> &Path {
self.mount_point.path()
}
pub fn mapper_name(&self) -> &str {
&self.mapper_name
}
pub fn mapper_path(&self) -> &Path {
&self.mapper_path
}
pub fn loop_device(&self) -> &Path {
&self.loop_device
}
pub fn backing_file(&self) -> &Path {
&self.backing_file
}
}
impl Drop for FlakyMount {
fn drop(&mut self) {
if let Err(e) = umount(self.mount_point.path()) {
tracing::warn!(
mount = %self.mount_point.path().display(),
error = %e,
"flaky umount failed, trying lazy unmount",
);
if let Err(e2) = umount_lazy(self.mount_point.path()) {
tracing::warn!(
mount = %self.mount_point.path().display(),
error = %e2,
"flaky lazy unmount also failed, device may leak",
);
}
}
if let Err(e) = dm_remove(&self.mapper_name) {
tracing::warn!(
name = %self.mapper_name,
error = %e,
"flaky dm remove failed, mapper device may leak",
);
}
if let Err(e) = detach_loop(&self.loop_device) {
tracing::warn!(
device = %self.loop_device.display(),
error = %e,
"flaky loop detach failed, loop device may leak",
);
}
}
}
#[cfg(unix)]
fn ensure_root() -> Result<(), FlakyError> {
if unsafe { libc::geteuid() } == 0 {
Ok(())
} else {
Err(FlakyError::NotRoot)
}
}
#[cfg(not(unix))]
fn ensure_root() -> Result<(), FlakyError> {
Err(FlakyError::NotRoot)
}
fn ensure_tool(tool: &'static str) -> Result<(), FlakyError> {
match find_in_path(tool) {
Some(_) => Ok(()),
None => Err(FlakyError::ToolMissing(tool)),
}
}
fn find_in_path(tool: &str) -> Option<PathBuf> {
let path = env::var_os("PATH")?;
env::split_paths(&path).find_map(|dir| {
let candidate = dir.join(tool);
is_executable_file(&candidate).then_some(candidate)
})
}
#[cfg(unix)]
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(p: &Path) -> bool {
p.is_file()
}
fn probe_dm_flakey() -> Result<(), FlakyError> {
let out = Command::new("dmsetup").arg("targets").output()?;
if !out.status.success() {
return Err(FlakyError::DmFlakeyMissing(stringify_output(&out)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
if !stdout.lines().any(|l| l.starts_with("flakey")) {
return Err(FlakyError::DmFlakeyMissing(stdout.into_owned()));
}
Ok(())
}
fn reap_stale_mounts() -> Result<(), FlakyError> {
let out = Command::new("dmsetup")
.arg("ls")
.arg("--target")
.arg("flakey")
.output()?;
if !out.status.success() {
return Ok(());
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(parse_flaky_entry)
.filter(|(_, pid)| !pid_alive(*pid))
.for_each(|(name, _)| {
let loop_device = mapper_backing_loop(&name);
if let Err(e) = dm_remove(&name) {
tracing::warn!(name, error = %e, "reap: dm_remove stale mapper failed");
return;
}
if let Some(loop_dev) = loop_device
&& let Err(e) = detach_loop(&loop_dev)
{
tracing::warn!(
device = %loop_dev.display(),
error = %e,
"reap: detach_loop stale device failed",
);
}
});
Ok(())
}
fn parse_flaky_entry(line: &str) -> Option<(String, u32)> {
let name = line.split_whitespace().next()?;
let suffix = name.strip_prefix("tranquil-flaky-")?;
let pid_str = suffix.split('-').next()?;
let pid = pid_str.parse::<u32>().ok()?;
Some((name.to_string(), pid))
}
fn pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
fn mapper_backing_loop(name: &str) -> Option<PathBuf> {
let out = Command::new("dmsetup")
.arg("deps")
.arg("-o")
.arg("devname")
.arg(name)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let inner = text.split('(').nth(1)?;
let dev = inner.split(')').next()?.trim();
if dev.is_empty() {
None
} else {
Some(PathBuf::from(format!("/dev/{dev}")))
}
}
fn allocate_backing(path: &Path, size: BackingMegabytes) -> Result<(), FlakyError> {
let out = Command::new("truncate")
.arg("-s")
.arg(format!("{}M", size.0))
.arg(path)
.output()?;
check_status("truncate", &out)
}
fn attach_loop(backing: &Path) -> Result<PathBuf, FlakyError> {
let out = Command::new("losetup")
.arg("--find")
.arg("--show")
.arg(backing)
.output()?;
check_status("losetup", &out)?;
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() {
return Err(FlakyError::CommandFailed {
tool: "losetup",
status: "exit 0".to_string(),
stderr: "no device path on stdout".to_string(),
});
}
Ok(PathBuf::from(raw))
}
fn detach_loop(device: &Path) -> Result<(), FlakyError> {
let out = Command::new("losetup").arg("-d").arg(device).output()?;
check_status("losetup -d", &out)
}
fn sector_count(device: &Path) -> Result<u64, FlakyError> {
let out = Command::new("blockdev")
.arg("--getsz")
.arg(device)
.output()?;
check_status("blockdev", &out)?;
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
raw.parse::<u64>().map_err(|_| FlakyError::CommandFailed {
tool: "blockdev",
status: "exit 0".to_string(),
stderr: format!("could not parse sector count: {raw:?}"),
})
}
fn dm_create(
name: &str,
loop_device: &Path,
sectors: u64,
up: UpIntervalSecs,
down: DownIntervalSecs,
) -> Result<(), FlakyError> {
let table = format!(
"0 {sectors} flakey {} 0 {} {}",
loop_device.display(),
up.0.get(),
down.0.get(),
);
let mut child = Command::new("dmsetup")
.arg("create")
.arg(name)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
stdin.write_all(table.as_bytes())?;
}
let out = child.wait_with_output()?;
check_status("dmsetup create", &out)
}
fn dm_remove(name: &str) -> Result<(), FlakyError> {
let out = Command::new("dmsetup")
.arg("remove")
.arg("--retry")
.arg(name)
.output()?;
check_status("dmsetup remove", &out)
}
fn mkfs_ext4(device: &Path) -> Result<(), FlakyError> {
let out = Command::new("mkfs.ext4")
.arg("-q")
.arg("-F")
.arg(device)
.output()?;
check_status("mkfs.ext4", &out)
}
fn mount_ext4(device: &Path, target: &Path) -> Result<(), FlakyError> {
let out = Command::new("mount")
.arg("-t")
.arg("ext4")
.arg(device)
.arg(target)
.output()?;
check_status("mount", &out)
}
fn umount(target: &Path) -> Result<(), FlakyError> {
let out = Command::new("umount").arg(target).output()?;
check_status("umount", &out)
}
fn umount_lazy(target: &Path) -> Result<(), FlakyError> {
let out = Command::new("umount").arg("-l").arg(target).output()?;
check_status("umount -l", &out)
}
fn check_status(tool: &'static str, out: &Output) -> Result<(), FlakyError> {
if out.status.success() {
Ok(())
} else {
Err(FlakyError::CommandFailed {
tool,
status: format!("{}", out.status),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
fn stringify_output(out: &Output) -> String {
let mut s = String::new();
if !out.stdout.is_empty() {
s.push_str(&String::from_utf8_lossy(&out.stdout));
}
if !out.stderr.is_empty() {
if !s.is_empty() {
s.push('\n');
}
s.push_str(&String::from_utf8_lossy(&out.stderr));
}
s
}
+529 -17
View File
@@ -1,4 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
@@ -6,7 +7,11 @@ use cid::Cid;
use jacquard_repo::mst::Mst;
use super::oracle::{Oracle, hex_short, try_cid_to_fixed};
use crate::blockstore::{CidBytes, TranquilBlockStore};
use crate::blockstore::{
BLOCK_HEADER_SIZE, CidBytes, CompactionError, TranquilBlockStore, hash_to_cid_bytes,
};
use crate::eventlog::{EventSequence, SegmentId};
use crate::io::{RealIO, StorageIO};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvariantSet(u32);
@@ -18,12 +23,28 @@ impl InvariantSet {
pub const ACKED_WRITE_PERSISTENCE: Self = Self(1 << 2);
pub const READ_AFTER_WRITE: Self = Self(1 << 3);
pub const RESTART_IDEMPOTENT: Self = Self(1 << 4);
pub const COMPACTION_IDEMPOTENT: Self = Self(1 << 5);
pub const NO_ORPHAN_FILES: Self = Self(1 << 6);
pub const BYTE_BUDGET: Self = Self(1 << 7);
pub const MANIFEST_EQUALS_REALITY: Self = Self(1 << 8);
pub const CHECKSUM_COVERAGE: Self = Self(1 << 9);
pub const MONOTONIC_SEQ: Self = Self(1 << 10);
pub const FSYNC_ORDERING: Self = Self(1 << 11);
pub const TOMBSTONE_BOUND: Self = Self(1 << 12);
const ALL_KNOWN: u32 = Self::REFCOUNT_CONSERVATION.0
| Self::REACHABILITY.0
| Self::ACKED_WRITE_PERSISTENCE.0
| Self::READ_AFTER_WRITE.0
| Self::RESTART_IDEMPOTENT.0;
| Self::RESTART_IDEMPOTENT.0
| Self::COMPACTION_IDEMPOTENT.0
| Self::NO_ORPHAN_FILES.0
| Self::BYTE_BUDGET.0
| Self::MANIFEST_EQUALS_REALITY.0
| Self::CHECKSUM_COVERAGE.0
| Self::MONOTONIC_SEQ.0
| Self::FSYNC_ORDERING.0
| Self::TOMBSTONE_BOUND.0;
pub const fn contains(self, other: Self) -> bool {
(self.0 & other.0) == other.0
@@ -49,33 +70,52 @@ impl std::ops::BitOr for InvariantSet {
}
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct InvariantViolation {
pub invariant: &'static str,
pub detail: String,
}
pub struct InvariantCtx<'a> {
pub store: &'a Arc<TranquilBlockStore>,
#[derive(Debug, Clone, Copy)]
pub struct SnapshotEvent {
pub seq: EventSequence,
pub timestamp_us: u64,
pub event_type_raw: u8,
pub did_hash: u32,
}
#[derive(Debug, Clone)]
pub struct EventLogSnapshot {
pub segments_dir: PathBuf,
pub max_segment_size: u64,
pub synced_seq: EventSequence,
pub segments: Vec<SegmentId>,
pub events: Vec<SnapshotEvent>,
pub segment_last_ts: Vec<(SegmentId, u64)>,
}
pub struct InvariantCtx<'a, S: StorageIO + Send + Sync + 'static = RealIO> {
pub store: &'a Arc<TranquilBlockStore<S>>,
pub oracle: &'a Oracle,
pub root: Option<Cid>,
pub eventlog: Option<&'a EventLogSnapshot>,
}
#[async_trait]
pub trait Invariant: Send + Sync {
pub trait Invariant<S: StorageIO + Send + Sync + 'static>: Send + Sync {
fn name(&self) -> &'static str;
async fn check(&self, ctx: &InvariantCtx<'_>) -> Result<(), InvariantViolation>;
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation>;
}
pub struct RefcountConservation;
#[async_trait]
impl Invariant for RefcountConservation {
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for RefcountConservation {
fn name(&self) -> &'static str {
"RefcountConservation"
}
async fn check(&self, ctx: &InvariantCtx<'_>) -> Result<(), InvariantViolation> {
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> 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
@@ -116,12 +156,12 @@ impl Invariant for RefcountConservation {
pub struct Reachability;
#[async_trait]
impl Invariant for Reachability {
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for Reachability {
fn name(&self) -> &'static str {
"Reachability"
}
async fn check(&self, ctx: &InvariantCtx<'_>) -> Result<(), InvariantViolation> {
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let violations: Vec<String> = ctx
.oracle
.live_cids_labeled()
@@ -147,12 +187,12 @@ impl Invariant for Reachability {
pub struct AckedWritePersistence;
#[async_trait]
impl Invariant for AckedWritePersistence {
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for AckedWritePersistence {
fn name(&self) -> &'static str {
"AckedWritePersistence"
}
async fn check(&self, ctx: &InvariantCtx<'_>) -> Result<(), InvariantViolation> {
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let Some(root) = ctx.root else {
if ctx.oracle.live_count() == 0 {
return Ok(());
@@ -195,12 +235,12 @@ impl Invariant for AckedWritePersistence {
pub struct ReadAfterWrite;
#[async_trait]
impl Invariant for ReadAfterWrite {
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ReadAfterWrite {
fn name(&self) -> &'static str {
"ReadAfterWrite"
}
async fn check(&self, ctx: &InvariantCtx<'_>) -> Result<(), InvariantViolation> {
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let Some(root) = ctx.root else {
return Ok(());
};
@@ -246,13 +286,471 @@ impl Invariant for ReadAfterWrite {
}
}
pub fn invariants_for(set: InvariantSet) -> Vec<Box<dyn Invariant>> {
pub struct CompactionIdempotent;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for CompactionIdempotent {
fn name(&self) -> &'static str {
"CompactionIdempotent"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store_a = ctx.store.clone();
let first = tokio::task::spawn_blocking(move || compact_by_liveness(&store_a))
.await
.map_err(|e| InvariantViolation {
invariant: "CompactionIdempotent",
detail: format!("first compaction join: {e}"),
})?;
if let Err(e) = first {
return Err(InvariantViolation {
invariant: "CompactionIdempotent",
detail: format!("first compaction: {e}"),
});
}
let pre = snapshot(ctx.store);
let store_b = ctx.store.clone();
let second = tokio::task::spawn_blocking(move || compact_by_liveness(&store_b))
.await
.map_err(|e| InvariantViolation {
invariant: "CompactionIdempotent",
detail: format!("second compaction join: {e}"),
})?;
if let Err(e) = second {
return Err(InvariantViolation {
invariant: "CompactionIdempotent",
detail: format!("second compaction: {e}"),
});
}
let post = snapshot(ctx.store);
if pre == post {
Ok(())
} else {
Err(InvariantViolation {
invariant: "CompactionIdempotent",
detail: format!(
"second compaction changed observable state: pre={} entries, post={} entries",
pre.len(),
post.len(),
),
})
}
}
}
fn snapshot<S: StorageIO + Send + Sync + 'static>(
store: &Arc<TranquilBlockStore<S>>,
) -> Vec<(CidBytes, u32)> {
let mut v: Vec<(CidBytes, u32)> = store
.block_index()
.live_entries_snapshot()
.into_iter()
.map(|(c, r)| (c, r.raw()))
.collect();
v.sort_unstable_by_key(|a| a.0);
v
}
const COMPACT_LIVENESS_CEILING: f64 = 0.99;
fn compact_by_liveness<S: StorageIO + Send + Sync + 'static>(
store: &TranquilBlockStore<S>,
) -> Result<(), String> {
let liveness = store
.compaction_liveness(0)
.map_err(|e| format!("compaction_liveness: {e}"))?;
let targets: Vec<_> = liveness
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < COMPACT_LIVENESS_CEILING)
.map(|(&fid, _)| fid)
.collect();
targets
.into_iter()
.try_for_each(|fid| match store.compact_file(fid, 0) {
Ok(_) => Ok(()),
Err(CompactionError::ActiveFileCannotBeCompacted) => Ok(()),
Err(e) => Err(format!("{fid}: {e}")),
})
}
pub struct NoOrphanFiles;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for NoOrphanFiles {
fn name(&self) -> &'static str {
"NoOrphanFiles"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> 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())?;
let liveness = store_c.compaction_liveness(0).map_err(|e| e.to_string())?;
let header = BLOCK_HEADER_SIZE as u64;
let orphans: Vec<String> = disk
.iter()
.filter(|fid| !liveness.contains_key(fid))
.filter_map(|fid| {
let path = store_c.data_file_path(*fid);
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
match size > header {
true => Some(format!("{fid} ({size} B)")),
false => None,
}
})
.collect();
Ok::<_, String>(orphans)
})
.await
.map_err(|e| InvariantViolation {
invariant: "NoOrphanFiles",
detail: format!("join: {e}"),
})?;
let orphans = result.map_err(|e| InvariantViolation {
invariant: "NoOrphanFiles",
detail: e,
})?;
if orphans.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "NoOrphanFiles",
detail: format!("files on disk missing from index: {}", orphans.join(", ")),
})
}
}
}
pub struct ByteBudget {
pub overhead_factor: f64,
pub floor_bytes: u64,
}
impl Default for ByteBudget {
fn default() -> Self {
Self {
overhead_factor: 8.0,
floor_bytes: 1 << 20,
}
}
}
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ByteBudget {
fn name(&self) -> &'static str {
"ByteBudget"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store = ctx.store.clone();
let factor = self.overhead_factor;
let floor = self.floor_bytes;
tokio::task::spawn_blocking(move || {
let liveness = store.compaction_liveness(0).map_err(|e| e.to_string())?;
let live: u64 = liveness.values().map(|i| i.live_bytes).sum();
let total: u64 = liveness.values().map(|i| i.total_bytes).sum();
let budget = (live as f64 * factor) as u64 + floor;
if total <= budget {
Ok(())
} else {
Err(format!(
"total_bytes {total} exceeds budget {budget}: live_bytes {live}, factor {factor}, floor {floor}"
))
}
})
.await
.map_err(|e| InvariantViolation {
invariant: "ByteBudget",
detail: format!("join: {e}"),
})?
.map_err(|e| InvariantViolation {
invariant: "ByteBudget",
detail: e,
})
}
}
pub struct ManifestEqualsReality;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ManifestEqualsReality {
fn name(&self) -> &'static str {
"ManifestEqualsReality"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let store = ctx.store.clone();
tokio::task::spawn_blocking(move || {
let listed = store.list_data_files().map_err(|e| e.to_string())?;
let liveness = store.compaction_liveness(0).map_err(|e| e.to_string())?;
let header = BLOCK_HEADER_SIZE as u64;
let mut violations: Vec<String> = Vec::new();
listed.iter().for_each(|fid| {
let path = store.data_file_path(*fid);
match std::fs::metadata(&path) {
Err(e) => violations.push(format!("{fid}: metadata {e}")),
Ok(meta) => {
let on_disk = meta.len();
let content = on_disk.saturating_sub(header);
match liveness.get(fid) {
None if on_disk > header => violations.push(format!(
"{fid}: listed on disk at {on_disk} B but not in index liveness"
)),
None => {}
Some(info) if content < info.total_bytes => {
violations.push(format!(
"{fid}: on-disk {on_disk} B (content {content}) < index total_bytes {}",
info.total_bytes
));
}
Some(info) if content > info.total_bytes => {
violations.push(format!(
"{fid}: on-disk {on_disk} B (content {content}) > index total_bytes {}, {} B unaccounted",
info.total_bytes,
content - info.total_bytes
));
}
Some(_) => {}
}
}
}
});
let listed_set: std::collections::HashSet<_> = listed.into_iter().collect();
liveness.keys().for_each(|fid| {
if !listed_set.contains(fid) {
violations.push(format!("{fid}: in index liveness but missing on disk"));
}
});
if violations.is_empty() {
Ok(())
} else {
Err(violations.join("; "))
}
})
.await
.map_err(|e| InvariantViolation {
invariant: "ManifestEqualsReality",
detail: format!("join: {e}"),
})?
.map_err(|e| InvariantViolation {
invariant: "ManifestEqualsReality",
detail: e,
})
}
}
pub struct ChecksumCoverage;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for ChecksumCoverage {
fn name(&self) -> &'static str {
"ChecksumCoverage"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let violations: Vec<String> = ctx
.oracle
.live_cids_labeled()
.into_iter()
.filter_map(|(label, expected)| match ctx.store.get_block_sync(&expected) {
Ok(Some(bytes)) => {
let actual = hash_to_cid_bytes(&bytes);
(actual != expected).then(|| {
format!(
"{label}: silent corruption, bytes hash to {} but store returned them under {}",
hex_short(&actual),
hex_short(&expected),
)
})
}
Ok(None) => Some(format!(
"{label}: live CID {} missing from store",
hex_short(&expected)
)),
Err(e) => Some(format!(
"{label}: read error for live CID {}: {e}",
hex_short(&expected)
)),
})
.collect();
if violations.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "ChecksumCoverage",
detail: violations.join("; "),
})
}
}
}
pub struct MonotonicSeq;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for MonotonicSeq {
fn name(&self) -> &'static str {
"MonotonicSeq"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let Some(el) = ctx.eventlog else {
return Ok(());
};
let mut violations: Vec<String> = Vec::new();
el.events
.iter()
.zip(el.events.iter().skip(1))
.for_each(|(prev, next)| match next.seq.raw() {
n if n == prev.seq.raw() + 1 => {}
n if n == prev.seq.raw() => violations.push(format!("duplicate seq {n}")),
n => violations.push(format!(
"gap: seq {} followed by {n}, expected {}",
prev.seq.raw(),
prev.seq.raw() + 1
)),
});
if ctx.oracle.last_retention_cutoff_us().is_none()
&& let Some(first) = el.events.first()
&& first.seq.raw() != 1
{
violations.push(format!(
"first persisted seq is {}, expected 1",
first.seq.raw()
));
}
let acked_max = ctx
.oracle
.synced_events()
.iter()
.map(|e| e.seq.raw())
.max()
.unwrap_or(0);
let disk_max = el.events.last().map(|e| e.seq.raw()).unwrap_or(0);
if disk_max < acked_max {
violations.push(format!(
"acked seq {acked_max} missing on disk, disk max {disk_max}"
));
}
if violations.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "MonotonicSeq",
detail: violations.join("; "),
})
}
}
}
pub struct FsyncOrdering;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for FsyncOrdering {
fn name(&self) -> &'static str {
"FsyncOrdering"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let Some(el) = ctx.eventlog else {
return Ok(());
};
let mut violations: Vec<String> = Vec::new();
let acked_seqs: HashSet<u64> = ctx
.oracle
.synced_events()
.iter()
.map(|e| e.seq.raw())
.collect();
let disk_seqs: HashSet<u64> = el.events.iter().map(|e| e.seq.raw()).collect();
let missing: Vec<u64> = acked_seqs.difference(&disk_seqs).copied().collect();
if !missing.is_empty() {
let mut sorted = missing;
sorted.sort_unstable();
violations.push(format!(
"{} acked events lost on disk, lowest missing seq {}",
sorted.len(),
sorted[0]
));
}
if let Some(last_synced) = ctx.oracle.last_synced_seq()
&& el.synced_seq.raw() != 0
&& el.synced_seq.raw() < last_synced.raw()
{
violations.push(format!(
"writer synced_seq {} below oracle last_synced_seq {}",
el.synced_seq.raw(),
last_synced.raw()
));
}
if violations.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "FsyncOrdering",
detail: violations.join("; "),
})
}
}
}
pub struct TombstoneBound;
#[async_trait]
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for TombstoneBound {
fn name(&self) -> &'static str {
"TombstoneBound"
}
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
let Some(el) = ctx.eventlog else {
return Ok(());
};
let Some(cutoff_us) = ctx.oracle.last_retention_cutoff_us() else {
return Ok(());
};
let active = el.segments.last().copied();
let stale: Vec<String> = el
.segment_last_ts
.iter()
.filter(|(id, last_ts)| Some(*id) != active && *last_ts < cutoff_us)
.map(|(id, last_ts)| format!("segment {id} last_ts {last_ts} < cutoff {cutoff_us}"))
.collect();
if stale.is_empty() {
Ok(())
} else {
Err(InvariantViolation {
invariant: "TombstoneBound",
detail: stale.join("; "),
})
}
}
}
pub fn invariants_for<S: StorageIO + Send + Sync + 'static>(
set: InvariantSet,
) -> Vec<Box<dyn Invariant<S>>> {
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>)> = vec![
let candidates: Vec<(InvariantSet, Box<dyn Invariant<S>>)> = vec![
(
InvariantSet::REFCOUNT_CONSERVATION,
Box::new(RefcountConservation),
@@ -263,6 +761,20 @@ pub fn invariants_for(set: InvariantSet) -> Vec<Box<dyn Invariant>> {
Box::new(AckedWritePersistence),
),
(InvariantSet::READ_AFTER_WRITE, Box::new(ReadAfterWrite)),
(
InvariantSet::COMPACTION_IDEMPOTENT,
Box::new(CompactionIdempotent),
),
(InvariantSet::NO_ORPHAN_FILES, Box::new(NoOrphanFiles)),
(InvariantSet::BYTE_BUDGET, Box::new(ByteBudget::default())),
(
InvariantSet::MANIFEST_EQUALS_REALITY,
Box::new(ManifestEqualsReality),
),
(InvariantSet::CHECKSUM_COVERAGE, Box::new(ChecksumCoverage)),
(InvariantSet::MONOTONIC_SEQ, Box::new(MonotonicSeq)),
(InvariantSet::FSYNC_ORDERING, Box::new(FsyncOrdering)),
(InvariantSet::TOMBSTONE_BOUND, Box::new(TombstoneBound)),
];
candidates
.into_iter()
+234
View File
@@ -0,0 +1,234 @@
use std::num::NonZeroU64;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::metrics::{MetricName, MetricsSample};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LeakGateConfig {
pub warmup_ms: u64,
pub window_ms: NonZeroU64,
pub growth_limit_pct: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct LeakGateBuildError(pub &'static str);
impl std::fmt::Display for LeakGateBuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for LeakGateBuildError {}
impl LeakGateConfig {
pub fn try_new(
warmup_ms: u64,
window_ms: u64,
growth_limit_pct: f64,
) -> Result<Self, LeakGateBuildError> {
let window_ms = NonZeroU64::new(window_ms)
.ok_or(LeakGateBuildError("leak gate window_ms must be > 0"))?;
if !growth_limit_pct.is_finite() || growth_limit_pct < 0.0 {
return Err(LeakGateBuildError(
"leak gate growth_limit_pct must be finite and non-negative",
));
}
Ok(Self {
warmup_ms,
window_ms,
growth_limit_pct,
})
}
pub fn standard() -> Self {
Self::try_new(60 * 60 * 1_000, 4 * 60 * 60 * 1_000, 5.0)
.expect("standard leak gate config is valid")
}
pub fn short_for_tests() -> Self {
Self::try_new(60_000, 4 * 60_000, 5.0).expect("short_for_tests leak gate config is valid")
}
pub fn warmup(&self) -> Duration {
Duration::from_millis(self.warmup_ms)
}
pub fn window(&self) -> Duration {
Duration::from_millis(self.window_ms.get())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeakViolation {
pub metric: String,
pub start_ms: u64,
pub end_ms: u64,
pub start_value: u64,
pub end_value: u64,
pub growth_pct: f64,
pub limit_pct: f64,
}
pub fn evaluate(samples: &[MetricsSample], cfg: LeakGateConfig) -> Vec<LeakViolation> {
if samples.len() < 2 {
return Vec::new();
}
MetricName::ALL
.iter()
.flat_map(|&m| evaluate_metric(samples, m, cfg))
.collect()
}
fn evaluate_metric(
samples: &[MetricsSample],
metric: MetricName,
cfg: LeakGateConfig,
) -> Option<LeakViolation> {
let post_warmup: Vec<&MetricsSample> = samples
.iter()
.filter(|s| s.elapsed_ms >= cfg.warmup_ms)
.collect();
if post_warmup.len() < 2 {
return None;
}
let min_delta = metric.min_absolute_delta();
let window = cfg.window_ms.get();
let mut worst: Option<LeakViolation> = None;
for (i, start) in post_warmup.iter().enumerate() {
let Some(start_v) = start.metric(metric) else {
continue;
};
if start_v == 0 {
continue;
}
let deadline = start.elapsed_ms.saturating_add(window);
for end in post_warmup.iter().skip(i + 1) {
if end.elapsed_ms > deadline {
break;
}
let Some(end_v) = end.metric(metric) else {
continue;
};
if end_v <= start_v {
continue;
}
let delta = end_v - start_v;
if delta < min_delta {
continue;
}
let growth = (delta as f64 / start_v as f64) * 100.0;
if growth <= cfg.growth_limit_pct {
continue;
}
let candidate = LeakViolation {
metric: metric.as_str().to_string(),
start_ms: start.elapsed_ms,
end_ms: end.elapsed_ms,
start_value: start_v,
end_value: end_v,
growth_pct: growth,
limit_pct: cfg.growth_limit_pct,
};
match &worst {
Some(w) if w.growth_pct >= candidate.growth_pct => {}
_ => worst = Some(candidate),
}
}
}
worst
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1024 * 1024 * 1024;
fn sample(elapsed_ms: u64, rss: u64) -> MetricsSample {
MetricsSample {
elapsed_ms,
rss_bytes: Some(rss),
fd_count: Some(10),
data_dir_bytes: 0,
index_dir_bytes: 0,
segments_dir_bytes: 0,
data_file_count: Some(0),
segment_count: Some(0),
block_index_entries: 0,
hint_file_bytes: 0,
}
}
#[test]
fn flat_metrics_no_violation() {
let cfg = LeakGateConfig::short_for_tests();
let series: Vec<MetricsSample> =
(0..20).map(|i| sample(60_000 + i * 60_000, GIB)).collect();
assert!(evaluate(&series, cfg).is_empty());
}
#[test]
fn growing_rss_flagged() {
let cfg = LeakGateConfig::short_for_tests();
let series: Vec<MetricsSample> = (0..20)
.map(|i| sample(60_000 + i * 60_000, GIB + i * 64 * 1024 * 1024))
.collect();
let v = evaluate(&series, cfg);
assert!(!v.is_empty());
assert_eq!(v[0].metric, "rss_bytes");
assert!(v[0].growth_pct > 5.0);
}
#[test]
fn warmup_samples_ignored() {
let cfg = LeakGateConfig::short_for_tests();
let mut series: Vec<MetricsSample> = Vec::new();
series.push(sample(10_000, 1));
series.push(sample(30_000, GIB));
(0..10).for_each(|i| {
series.push(sample(60_000 + i * 60_000, GIB));
});
assert!(evaluate(&series, cfg).is_empty());
}
#[test]
fn window_bound_honored() {
let cfg = LeakGateConfig::try_new(0, 2 * 60_000, 5.0).unwrap();
let series = vec![sample(0, GIB), sample(200_000, 2 * GIB)];
assert!(
evaluate(&series, cfg).is_empty(),
"200s gap exceeds 120s window, growth must not be flagged"
);
}
#[test]
fn small_absolute_delta_not_flagged() {
let cfg = LeakGateConfig::short_for_tests();
let series: Vec<MetricsSample> = (0..10)
.map(|i| sample(60_000 + i * 60_000, GIB + i * 1024))
.collect();
assert!(
evaluate(&series, cfg).is_empty(),
"kilobyte growth is below the RSS absolute-delta floor"
);
}
#[test]
fn missing_metric_samples_skipped() {
let cfg = LeakGateConfig::short_for_tests();
let mut series: Vec<MetricsSample> =
(0..10).map(|i| sample(60_000 + i * 60_000, GIB)).collect();
series[3].rss_bytes = None;
series[7].rss_bytes = None;
assert!(evaluate(&series, cfg).is_empty());
}
#[test]
fn zero_window_rejected_at_construction() {
assert!(LeakGateConfig::try_new(0, 0, 5.0).is_err());
}
}
@@ -0,0 +1,247 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::runner::{EventLogState, Harness};
use crate::blockstore::TranquilBlockStore;
use crate::io::StorageIO;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct MetricsSample {
pub elapsed_ms: u64,
pub rss_bytes: Option<u64>,
pub fd_count: Option<u64>,
pub data_dir_bytes: u64,
pub index_dir_bytes: u64,
pub segments_dir_bytes: u64,
pub data_file_count: Option<u64>,
pub segment_count: Option<u64>,
pub block_index_entries: u64,
pub hint_file_bytes: u64,
}
impl MetricsSample {
pub fn metric(&self, name: MetricName) -> Option<u64> {
match name {
MetricName::RssBytes => self.rss_bytes,
MetricName::FdCount => self.fd_count,
MetricName::DataDirBytes => Some(self.data_dir_bytes),
MetricName::IndexDirBytes => Some(self.index_dir_bytes),
MetricName::SegmentsDirBytes => Some(self.segments_dir_bytes),
MetricName::DataFileCount => self.data_file_count,
MetricName::SegmentCount => self.segment_count,
MetricName::BlockIndexEntries => Some(self.block_index_entries),
MetricName::HintFileBytes => Some(self.hint_file_bytes),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetricName {
RssBytes,
FdCount,
DataDirBytes,
IndexDirBytes,
SegmentsDirBytes,
DataFileCount,
SegmentCount,
BlockIndexEntries,
HintFileBytes,
}
impl MetricName {
pub const ALL: &'static [MetricName] = &[
Self::RssBytes,
Self::FdCount,
Self::DataDirBytes,
Self::IndexDirBytes,
Self::SegmentsDirBytes,
Self::DataFileCount,
Self::SegmentCount,
Self::BlockIndexEntries,
Self::HintFileBytes,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::RssBytes => "rss_bytes",
Self::FdCount => "fd_count",
Self::DataDirBytes => "data_dir_bytes",
Self::IndexDirBytes => "index_dir_bytes",
Self::SegmentsDirBytes => "segments_dir_bytes",
Self::DataFileCount => "data_file_count",
Self::SegmentCount => "segment_count",
Self::BlockIndexEntries => "block_index_entries",
Self::HintFileBytes => "hint_file_bytes",
}
}
pub const fn min_absolute_delta(self) -> u64 {
match self {
Self::RssBytes => 16 * 1024 * 1024,
Self::FdCount => 16,
Self::DataDirBytes => 16 * 1024 * 1024,
Self::IndexDirBytes => 1024 * 1024,
Self::SegmentsDirBytes => 16 * 1024 * 1024,
Self::DataFileCount => 16,
Self::SegmentCount => 4,
Self::BlockIndexEntries => 1024,
Self::HintFileBytes => 1024 * 1024,
}
}
}
pub fn sample_harness<S: StorageIO + Send + Sync + 'static>(
harness: &Harness<S>,
elapsed: Duration,
) -> MetricsSample {
MetricsSample {
elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
rss_bytes: read_rss(),
fd_count: count_open_fds(),
data_dir_bytes: dir_bytes(harness.store.data_dir()),
index_dir_bytes: dir_bytes(harness.store.block_index().index_dir()),
segments_dir_bytes: harness
.eventlog
.as_ref()
.map(|el| dir_bytes(&el.segments_dir))
.unwrap_or(0),
data_file_count: data_file_count(&harness.store),
segment_count: harness.eventlog.as_ref().and_then(segment_count),
block_index_entries: harness.store.block_index().approximate_block_count(),
hint_file_bytes: hint_bytes(harness.store.data_dir()),
}
}
fn data_file_count<S: StorageIO + Send + Sync + 'static>(
store: &Arc<TranquilBlockStore<S>>,
) -> Option<u64> {
match store.list_data_files() {
Ok(v) => Some(v.len() as u64),
Err(e) => {
warn!(error = %e, "gauntlet metrics: list_data_files failed");
None
}
}
}
fn segment_count<S: StorageIO + Send + Sync + 'static>(el: &EventLogState<S>) -> Option<u64> {
match el.manager.list_segments() {
Ok(v) => Some(v.len() as u64),
Err(e) => {
warn!(error = %e, "gauntlet metrics: list_segments failed");
None
}
}
}
fn dir_bytes(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.filter_map(Result::ok)
.map(|entry| match entry.file_type() {
Ok(ft) if ft.is_dir() => dir_bytes(&entry.path()),
Ok(_) => entry.metadata().map(|m| m.len()).unwrap_or(0),
Err(_) => 0,
})
.sum()
}
fn hint_bytes(data_dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(data_dir) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter(|entry| {
entry
.path()
.extension()
.and_then(|e| e.to_str())
.map(|e| e == "tqh")
.unwrap_or(false)
})
.map(|entry| entry.metadata().map(|m| m.len()).unwrap_or(0))
.sum()
}
#[cfg(target_os = "linux")]
fn read_rss() -> Option<u64> {
let status = match std::fs::read_to_string("/proc/self/status") {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "gauntlet metrics: read /proc/self/status failed");
return None;
}
};
let parsed = status.lines().find_map(|line| {
let rest = line.strip_prefix("VmRSS:")?;
let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
Some(kb * 1024)
});
if parsed.is_none() {
warn!("gauntlet metrics: VmRSS line missing from /proc/self/status");
}
parsed
}
#[cfg(not(target_os = "linux"))]
fn read_rss() -> Option<u64> {
None
}
#[cfg(target_os = "linux")]
fn count_open_fds() -> Option<u64> {
match std::fs::read_dir("/proc/self/fd") {
Ok(entries) => Some(entries.filter_map(Result::ok).count() as u64),
Err(e) => {
warn!(error = %e, "gauntlet metrics: read /proc/self/fd failed");
None
}
}
}
#[cfg(not(target_os = "linux"))]
fn count_open_fds() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metric_names_roundtrip_strings() {
MetricName::ALL.iter().for_each(|m| {
let s = m.as_str();
assert!(!s.is_empty());
});
}
#[test]
#[cfg_attr(not(target_os = "linux"), ignore = "linux /proc only")]
fn rss_reads_nonzero() {
let rss = read_rss().expect("rss");
assert!(rss > 0, "rss should be positive, got {rss}");
}
#[test]
#[cfg_attr(not(target_os = "linux"), ignore = "linux /proc only")]
fn fd_count_reads_nonzero() {
let fd = count_open_fds().expect("fd");
assert!(fd > 0);
}
#[test]
fn dir_bytes_sums_entries() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("a"), b"1234").unwrap();
std::fs::write(dir.path().join("b"), b"5678").unwrap();
assert_eq!(dir_bytes(dir.path()), 8);
}
}
+33 -8
View File
@@ -1,20 +1,45 @@
pub mod farm;
pub mod flaky;
pub mod invariants;
pub mod leak;
pub mod metrics;
pub mod op;
pub mod oracle;
pub mod overrides;
pub mod regression;
pub mod runner;
pub mod scenarios;
pub mod shrink;
pub mod soak;
pub mod workload;
pub use invariants::{Invariant, InvariantSet, InvariantViolation, invariants_for};
pub use op::{CollectionName, Op, OpStream, RecordKey, Seed, ValueSeed};
pub use oracle::Oracle;
pub use flaky::{
BackingMegabytes, DownIntervalSecs, FlakyConfig, FlakyError, FlakyMount, UpIntervalSecs,
};
pub use invariants::{
EventLogSnapshot, Invariant, InvariantSet, InvariantViolation, SnapshotEvent, invariants_for,
};
pub use leak::{LeakGateBuildError, LeakGateConfig, LeakViolation, evaluate as evaluate_leak_gate};
pub use metrics::{MetricName, MetricsSample, sample_harness};
pub use op::{
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
ValueSeed,
};
pub use oracle::{EventExpectation, Oracle};
pub use overrides::{ConfigOverrides, GroupCommitOverrides, StoreOverrides};
pub use regression::{RegressionRecord, RegressionViolation, default_root as regression_root};
pub use runner::{
Gauntlet, GauntletBuildError, GauntletConfig, GauntletReport, IoBackend, MaxFileSize, OpIndex,
OpInterval, OpsExecuted, RestartCount, RestartPolicy, RunLimits, ShardCount, StoreConfig,
WallMs,
EventLogConfig, Gauntlet, GauntletBuildError, GauntletConfig, GauntletReport, Harness,
IoBackend, MaxFileSize, MaxSegmentSize, OpErrorCount, OpIndex, OpInterval, OpsExecuted,
RestartCount, RestartPolicy, RunLimits, ShardCount, StoreConfig, WallMs, WriterConcurrency,
};
pub use scenarios::{Scenario, UnknownScenario, config_for};
pub use shrink::{ShrinkOutcome, shrink_failure};
pub use soak::{
DEFAULT_CHUNK_OPS, DEFAULT_SAMPLE_INTERVAL_MS, InvariantViolationRecord, SoakConfig, SoakError,
SoakEvent, SoakReport, run_soak,
};
pub use scenarios::{Scenario, config_for};
pub use workload::{
ByteRange, KeySpaceSize, OpCount, OpWeights, SizeDistribution, ValueBytes, WorkloadModel,
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
ValueBytes, WorkloadModel,
};
+148 -10
View File
@@ -1,16 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Seed(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CollectionName(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RecordKey(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ValueSeed(pub u32);
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DidSeed(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
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 enum EventKind {
Commit,
Identity,
Account,
Sync,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Op {
AddRecord {
collection: CollectionName,
@@ -23,9 +42,31 @@ pub enum Op {
},
Compact,
Checkpoint,
AppendEvent {
did_seed: DidSeed,
event_kind: EventKind,
payload_seed: PayloadSeed,
},
SyncEventLog,
RunRetention {
max_age_secs: RetentionSecs,
},
ReadRecord {
collection: CollectionName,
rkey: RecordKey,
},
ReadBlock {
value_seed: ValueSeed,
},
}
#[derive(Debug, Clone)]
impl Op {
pub const fn is_read_only(&self) -> bool {
matches!(self, Op::ReadRecord { .. } | Op::ReadBlock { .. })
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpStream {
ops: Vec<Op>,
}
@@ -35,6 +76,14 @@ impl OpStream {
Self { ops }
}
pub fn empty() -> Self {
Self { ops: Vec::new() }
}
pub fn as_slice(&self) -> &[Op] {
&self.ops
}
pub fn into_vec(self) -> Vec<Op> {
self.ops
}
@@ -51,10 +100,99 @@ impl OpStream {
self.ops.is_empty()
}
pub fn shrink(&self) -> Option<OpStream> {
(self.ops.len() >= 2).then(|| {
let half = self.ops.len() / 2;
OpStream::from_vec(self.ops[..half].to_vec())
pub fn shrink_candidates(&self) -> impl Iterator<Item = OpStream> + '_ {
let len = self.ops.len();
let chunk_sizes: Vec<usize> = std::iter::successors((len >= 2).then_some(len / 2), |&s| {
(s >= 2).then_some(s / 2)
})
.collect();
let chunk_candidates = chunk_sizes.into_iter().flat_map(move |chunk_size| {
let count = len.div_ceil(chunk_size);
(0..count).map(move |i| {
let start = i * chunk_size;
let end = (start + chunk_size).min(len);
let mut reduced = Vec::with_capacity(len - (end - start));
reduced.extend_from_slice(&self.ops[..start]);
reduced.extend_from_slice(&self.ops[end..]);
OpStream::from_vec(reduced)
})
});
let single_candidates = (0..len).map(move |i| {
let mut reduced = self.ops.clone();
reduced.remove(i);
OpStream::from_vec(reduced)
});
chunk_candidates.chain(single_candidates)
}
pub fn shrink_to_fixpoint(mut self, mut fails: impl FnMut(&OpStream) -> bool) -> OpStream {
loop {
let next = self.shrink_candidates().find(|c| !c.is_empty() && fails(c));
match next {
Some(smaller) => self = smaller,
None => return self,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stream(n: usize) -> OpStream {
OpStream::from_vec(
(0..n)
.map(|i| Op::AddRecord {
collection: CollectionName("c".into()),
rkey: RecordKey(format!("{i:04}")),
value_seed: ValueSeed(i as u32),
})
.collect(),
)
}
fn contains_index(s: &OpStream, target: u32) -> bool {
s.iter()
.any(|op| matches!(op, Op::AddRecord { value_seed, .. } if value_seed.0 == target))
}
#[test]
fn shrink_candidates_nonempty_for_len_ge_2() {
let s = stream(8);
let count = s.shrink_candidates().count();
assert!(count > 0);
}
#[test]
fn shrink_candidates_empty_for_len_0() {
let s = OpStream::from_vec(Vec::new());
assert_eq!(s.shrink_candidates().count(), 0);
}
#[test]
fn shrink_candidates_includes_every_single_removal() {
let s = stream(5);
let singles: Vec<_> = s.shrink_candidates().filter(|c| c.len() == 4).collect();
assert!(
singles.len() >= 5,
"expected at least 5 size-4 candidates, got {}",
singles.len()
);
}
#[test]
fn shrink_to_fixpoint_converges_to_culprit() {
let s = stream(64);
let shrunk = s.shrink_to_fixpoint(|c| contains_index(c, 17));
assert!(contains_index(&shrunk, 17));
assert!(
shrunk.len() < 4,
"expected shrink to close on culprit, got {} ops",
shrunk.len()
);
}
}
+57 -1
View File
@@ -2,8 +2,9 @@ use std::collections::HashMap;
use cid::Cid;
use super::op::{CollectionName, RecordKey};
use super::op::{CollectionName, EventKind, RecordKey};
use crate::blockstore::CidBytes;
use crate::eventlog::EventSequence;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[error("unexpected CID encoding: got {actual} bytes, expected 36 for sha256 CIDv1")]
@@ -11,11 +12,23 @@ pub struct CidFormatError {
pub actual: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EventExpectation {
pub seq: EventSequence,
pub timestamp_us: u64,
pub kind: EventKind,
pub did_hash: u32,
}
#[derive(Debug, Default)]
pub struct Oracle {
live: HashMap<(CollectionName, RecordKey), CidBytes>,
current_root: Option<Cid>,
mst_node_cids: Vec<CidBytes>,
synced_events: Vec<EventExpectation>,
unsynced_events: Vec<EventExpectation>,
last_synced_seq: Option<EventSequence>,
last_retention_cutoff_us: Option<u64>,
}
impl Oracle {
@@ -36,6 +49,10 @@ impl Oracle {
self.live.remove(&(coll.clone(), rkey.clone()))
}
pub fn contains_record(&self, coll: &CollectionName, rkey: &RecordKey) -> bool {
self.live.contains_key(&(coll.clone(), rkey.clone()))
}
pub fn set_root(&mut self, root: Cid) {
self.current_root = Some(root);
}
@@ -71,6 +88,45 @@ impl Oracle {
.map(|(c, r, v)| (format!("record {}/{}", c.0, r.0), *v));
nodes.chain(records).collect()
}
pub fn record_event_append(&mut self, event: EventExpectation) {
self.unsynced_events.push(event);
}
pub fn record_event_sync(&mut self, synced_through: EventSequence) {
let (promoted, remaining): (Vec<_>, Vec<_>) = self
.unsynced_events
.drain(..)
.partition(|e| e.seq <= synced_through);
self.synced_events.extend(promoted);
self.unsynced_events = remaining;
self.last_synced_seq = Some(synced_through);
}
pub fn record_crash(&mut self) {
self.unsynced_events.clear();
}
pub fn record_retention(&mut self, cutoff_us: u64) {
self.synced_events.retain(|e| e.timestamp_us >= cutoff_us);
self.last_retention_cutoff_us = Some(cutoff_us);
}
pub fn synced_events(&self) -> &[EventExpectation] {
&self.synced_events
}
pub fn unsynced_events(&self) -> &[EventExpectation] {
&self.unsynced_events
}
pub fn last_synced_seq(&self) -> Option<EventSequence> {
self.last_synced_seq
}
pub fn last_retention_cutoff_us(&self) -> Option<u64> {
self.last_retention_cutoff_us
}
}
pub(super) fn try_cid_to_fixed(cid: &Cid) -> Result<CidBytes, CidFormatError> {
@@ -0,0 +1,247 @@
use serde::{Deserialize, Serialize};
use super::runner::{
GauntletConfig, IoBackend, MaxFileSize, OpInterval, RestartPolicy, RunLimits, ShardCount,
WallMs, WriterConcurrency,
};
use super::workload::{KeySpaceSize, OpCount, SizeDistribution, ValueBytes};
use crate::sim::FaultConfig;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub op_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wall_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writer_concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_space: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_bytes: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fault_density_scale: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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 = "StoreOverrides::is_empty")]
pub store: StoreOverrides,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct StoreOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shard_count: Option<u8>,
#[serde(default, skip_serializing_if = "GroupCommitOverrides::is_empty")]
pub group_commit: GroupCommitOverrides,
}
impl StoreOverrides {
pub fn is_empty(&self) -> bool {
self.max_file_size.is_none() && self.shard_count.is_none() && self.group_commit.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GroupCommitOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_batch_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_capacity: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint_interval_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint_write_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_persisted_blocks: Option<bool>,
}
impl GroupCommitOverrides {
pub fn is_empty(&self) -> bool {
self.max_batch_size.is_none()
&& self.channel_capacity.is_none()
&& self.checkpoint_interval_ms.is_none()
&& self.checkpoint_write_threshold.is_none()
&& self.verify_persisted_blocks.is_none()
}
}
impl ConfigOverrides {
pub fn apply_to(&self, cfg: &mut GauntletConfig) {
if let Some(n) = self.op_count {
cfg.op_count = OpCount(n);
}
if let Some(ms) = self.max_wall_ms {
cfg.limits = RunLimits {
max_wall_ms: Some(WallMs(ms)),
};
}
if let Some(n) = self.writer_concurrency {
cfg.writer_concurrency = WriterConcurrency(n.max(1));
}
if let Some(n) = self.key_space {
cfg.workload.key_space = KeySpaceSize(n.max(1));
}
if let Some(n) = self.value_bytes {
cfg.workload.size_distribution = SizeDistribution::Fixed(ValueBytes(n));
}
if let Some(m) = self.fault_density_scale
&& let IoBackend::Simulated { fault } = cfg.io
{
cfg.io = IoBackend::Simulated {
fault: fault.scale_probabilities(m),
};
}
if let Some(d) = self.fault_density_uniform {
cfg.io = IoBackend::Simulated {
fault: FaultConfig::uniform_density(d.clamp(0.0, 1.0)),
};
}
if let Some(n) = self.restart_every_n_ops {
cfg.restart_policy = if n == 0 {
RestartPolicy::Never
} else {
RestartPolicy::EveryNOps(OpInterval(n))
};
}
if let Some(n) = self.store.max_file_size {
cfg.store.max_file_size = MaxFileSize(n);
}
if let Some(n) = self.store.shard_count {
cfg.store.shard_count = ShardCount(n);
}
let gc = &self.store.group_commit;
if let Some(n) = gc.max_batch_size {
cfg.store.group_commit.max_batch_size = n;
}
if let Some(n) = gc.channel_capacity {
cfg.store.group_commit.channel_capacity = n;
}
if let Some(n) = gc.checkpoint_interval_ms {
cfg.store.group_commit.checkpoint_interval_ms = n;
}
if let Some(n) = gc.checkpoint_write_threshold {
cfg.store.group_commit.checkpoint_write_threshold = n;
}
if let Some(b) = gc.verify_persisted_blocks {
cfg.store.group_commit.verify_persisted_blocks = b;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_overrides_serialize_empty() {
let o = ConfigOverrides::default();
let json = serde_json::to_string(&o).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn round_trip_preserves_set_fields() {
let o = ConfigOverrides {
op_count: Some(42),
writer_concurrency: Some(16),
key_space: Some(1_000_000),
value_bytes: Some(4096),
fault_density_scale: Some(1e-3),
fault_density_uniform: Some(5e-4),
restart_every_n_ops: Some(10_000),
store: StoreOverrides {
max_file_size: Some(4096),
group_commit: GroupCommitOverrides {
max_batch_size: Some(16),
..GroupCommitOverrides::default()
},
..StoreOverrides::default()
},
..ConfigOverrides::default()
};
let json = serde_json::to_string(&o).unwrap();
let back: ConfigOverrides = serde_json::from_str(&json).unwrap();
assert_eq!(o, back);
}
#[test]
fn fault_density_scale_scales_moderate() {
use crate::gauntlet::op::Seed;
use crate::gauntlet::scenarios::{Scenario, config_for};
let mut cfg = config_for(Scenario::ModerateFaults, Seed(1));
let o = ConfigOverrides {
fault_density_scale: Some(0.1),
..ConfigOverrides::default()
};
o.apply_to(&mut cfg);
match cfg.io {
IoBackend::Simulated { fault } => {
assert!(fault.torn_page_probability.raw() < 0.02);
assert!(fault.torn_page_probability.raw() > 0.0);
}
_ => panic!("expected simulated io"),
}
}
#[test]
fn fault_density_scale_zero_kills_probabilities() {
use crate::gauntlet::op::Seed;
use crate::gauntlet::scenarios::{Scenario, config_for};
let mut cfg = config_for(Scenario::ModerateFaults, Seed(1));
let o = ConfigOverrides {
fault_density_scale: Some(0.0),
..ConfigOverrides::default()
};
o.apply_to(&mut cfg);
match cfg.io {
IoBackend::Simulated { fault } => {
assert_eq!(fault.partial_write_probability.raw(), 0.0);
assert_eq!(fault.torn_page_probability.raw(), 0.0);
assert_eq!(fault.io_error_probability.raw(), 0.0);
assert_eq!(fault.sync_failure_probability.raw(), 0.0);
}
_ => panic!("expected simulated io"),
}
}
#[test]
fn fault_density_scale_is_noop_on_real_backend() {
use crate::gauntlet::op::Seed;
use crate::gauntlet::scenarios::{Scenario, config_for};
let mut cfg = config_for(Scenario::SmokePR, Seed(1));
assert!(matches!(cfg.io, IoBackend::Real));
let o = ConfigOverrides {
fault_density_scale: Some(0.5),
..ConfigOverrides::default()
};
o.apply_to(&mut cfg);
assert!(matches!(cfg.io, IoBackend::Real));
}
#[test]
fn fault_density_uniform_forces_simulated_backend() {
use crate::gauntlet::op::Seed;
use crate::gauntlet::scenarios::{Scenario, config_for};
let mut cfg = config_for(Scenario::SmokePR, Seed(1));
assert!(matches!(cfg.io, IoBackend::Real));
let o = ConfigOverrides {
fault_density_uniform: Some(0.25),
..ConfigOverrides::default()
};
o.apply_to(&mut cfg);
match cfg.io {
IoBackend::Simulated { fault } => {
assert_eq!(fault.torn_page_probability.raw(), 0.25);
assert_eq!(fault.io_error_probability.raw(), 0.25);
}
_ => panic!("expected simulated io"),
}
}
}
@@ -0,0 +1,290 @@
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::invariants::InvariantViolation;
use super::op::{Op, OpStream, Seed};
use super::overrides::ConfigOverrides;
use super::runner::{GauntletConfig, GauntletReport};
use super::scenarios::{Scenario, UnknownScenario, config_for};
pub const SCHEMA_VERSION: u32 = 1;
pub const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegressionViolation {
pub invariant: String,
pub detail: String,
}
impl From<&InvariantViolation> for RegressionViolation {
fn from(v: &InvariantViolation) -> Self {
Self {
invariant: v.invariant.to_string(),
detail: v.detail.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegressionRecord {
pub schema_version: u32,
pub scenario: String,
pub seed: Seed,
#[serde(default)]
pub overrides: ConfigOverrides,
pub violations: Vec<RegressionViolation>,
pub ops: Vec<Op>,
#[serde(default)]
pub original_ops_len: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum RegressionLoadError {
#[error("read {path}: {source}")]
Read { path: PathBuf, source: io::Error },
#[error("parse {path}: {source}")]
Parse {
path: PathBuf,
source: serde_json::Error,
},
#[error("schema version {found} outside supported range {min}..={max}")]
UnsupportedVersion { found: u32, min: u32, max: u32 },
#[error(transparent)]
UnknownScenario(#[from] UnknownScenario),
}
impl RegressionRecord {
pub fn from_report(
scenario: Scenario,
overrides: ConfigOverrides,
report: &GauntletReport,
original_ops_len: usize,
shrunk_ops: OpStream,
) -> Self {
Self {
schema_version: SCHEMA_VERSION,
scenario: scenario.name().to_string(),
seed: report.seed,
overrides,
violations: report
.violations
.iter()
.map(RegressionViolation::from)
.collect(),
ops: shrunk_ops.into_vec(),
original_ops_len,
}
}
pub fn file_path(&self, root: &Path) -> PathBuf {
root.join("gauntlet")
.join(sanitize(&self.scenario))
.join(format!("{:016x}.json", self.seed.0))
}
pub fn write_to(&self, root: &Path) -> io::Result<PathBuf> {
let path = self.file_path(root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
{
let mut f = std::fs::File::create(&tmp)?;
io::Write::write_all(&mut f, &json)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
if let Some(parent) = path.parent()
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
Ok(path)
}
pub fn load(path: &Path) -> Result<Self, RegressionLoadError> {
let raw = std::fs::read(path).map_err(|source| RegressionLoadError::Read {
path: path.to_path_buf(),
source,
})?;
let record: RegressionRecord =
serde_json::from_slice(&raw).map_err(|source| RegressionLoadError::Parse {
path: path.to_path_buf(),
source,
})?;
if record.schema_version < MIN_SUPPORTED_SCHEMA_VERSION
|| record.schema_version > SCHEMA_VERSION
{
return Err(RegressionLoadError::UnsupportedVersion {
found: record.schema_version,
min: MIN_SUPPORTED_SCHEMA_VERSION,
max: SCHEMA_VERSION,
});
}
Ok(record)
}
pub fn scenario_enum(&self) -> Result<Scenario, UnknownScenario> {
self.scenario.parse::<Scenario>()
}
pub fn build_config(&self) -> Result<GauntletConfig, UnknownScenario> {
let scenario = self.scenario_enum()?;
let mut cfg = config_for(scenario, self.seed);
self.overrides.apply_to(&mut cfg);
Ok(cfg)
}
pub fn op_stream(&self) -> OpStream {
OpStream::from_vec(self.ops.clone())
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' => c,
_ => '_',
})
.collect()
}
pub fn default_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("proptest-regressions")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gauntlet::op::{CollectionName, RecordKey, ValueSeed};
use crate::gauntlet::overrides::ConfigOverrides;
fn sample_record() -> RegressionRecord {
use crate::gauntlet::overrides::StoreOverrides;
let ops = vec![
Op::AddRecord {
collection: CollectionName("c".into()),
rkey: RecordKey("r".into()),
value_seed: ValueSeed(1),
},
Op::Compact,
];
let overrides = ConfigOverrides {
op_count: Some(128),
store: StoreOverrides {
max_file_size: Some(4096),
..StoreOverrides::default()
},
..ConfigOverrides::default()
};
RegressionRecord {
schema_version: SCHEMA_VERSION,
scenario: "HugeValues".to_string(),
seed: Seed(0xdeadbeef),
overrides,
violations: vec![RegressionViolation {
invariant: "ByteBudget".to_string(),
detail: "exceeded".to_string(),
}],
ops,
original_ops_len: 500,
}
}
#[test]
fn round_trip_preserves_all_fields() {
let dir = tempfile::TempDir::new().unwrap();
let original = sample_record();
let path = original.write_to(dir.path()).unwrap();
assert!(path.exists());
let loaded = RegressionRecord::load(&path).unwrap();
assert_eq!(loaded.schema_version, original.schema_version);
assert_eq!(loaded.scenario, original.scenario);
assert_eq!(loaded.seed.0, original.seed.0);
assert_eq!(loaded.overrides, original.overrides);
assert_eq!(loaded.violations, original.violations);
assert_eq!(loaded.ops.len(), original.ops.len());
assert_eq!(loaded.original_ops_len, original.original_ops_len);
}
#[test]
fn build_config_applies_overrides() {
let record = sample_record();
let cfg = record.build_config().unwrap();
assert_eq!(cfg.op_count.0, 128);
assert_eq!(cfg.store.max_file_size.0, 4096);
}
#[test]
fn rejects_future_schema_version() {
let dir = tempfile::TempDir::new().unwrap();
let mut r = sample_record();
r.schema_version = SCHEMA_VERSION + 1;
let path = r.write_to(dir.path()).unwrap();
match RegressionRecord::load(&path) {
Err(RegressionLoadError::UnsupportedVersion { found, min, max }) => {
assert_eq!(found, SCHEMA_VERSION + 1);
assert_eq!(min, MIN_SUPPORTED_SCHEMA_VERSION);
assert_eq!(max, SCHEMA_VERSION);
}
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn rejects_past_schema_version_below_min() {
let dir = tempfile::TempDir::new().unwrap();
let mut r = sample_record();
r.schema_version = MIN_SUPPORTED_SCHEMA_VERSION.saturating_sub(1);
let path = r.write_to(dir.path()).unwrap();
match RegressionRecord::load(&path) {
Err(RegressionLoadError::UnsupportedVersion { found, min, max }) => {
assert_eq!(found, MIN_SUPPORTED_SCHEMA_VERSION.saturating_sub(1));
assert_eq!(min, MIN_SUPPORTED_SCHEMA_VERSION);
assert_eq!(max, SCHEMA_VERSION);
}
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn atomic_write_leaves_no_tmp_file() {
let dir = tempfile::TempDir::new().unwrap();
let r = sample_record();
let path = r.write_to(dir.path()).unwrap();
assert!(path.exists());
let tmp = path.with_extension("json.tmp");
assert!(
!tmp.exists(),
"tmp sibling {tmp:?} should have been renamed"
);
}
#[test]
fn rejects_malformed_json() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"{not json").unwrap();
assert!(matches!(
RegressionRecord::load(&path),
Err(RegressionLoadError::Parse { .. })
));
}
#[test]
fn sanitize_strips_slashes_and_traversal() {
assert_eq!(sanitize("foo/bar baz"), "foo_bar_baz");
assert_eq!(sanitize("../etc"), "___etc");
}
#[test]
fn unknown_scenario_name_errors() {
let mut r = sample_record();
r.scenario = "BogusScenario".to_string();
assert!(r.build_config().is_err());
}
}
File diff suppressed because it is too large Load Diff
+639 -48
View File
@@ -1,20 +1,195 @@
use super::flaky::FlakyConfig;
use super::invariants::InvariantSet;
use super::op::{CollectionName, Seed};
use super::runner::{
GauntletConfig, IoBackend, MaxFileSize, OpInterval, RestartPolicy, RunLimits, ShardCount,
StoreConfig, WallMs,
EventLogConfig, GauntletConfig, IoBackend, MaxFileSize, MaxSegmentSize, OpInterval,
RestartPolicy, RunLimits, ShardCount, StoreConfig, WallMs, WriterConcurrency,
};
use super::workload::{
KeySpaceSize, OpCount, OpWeights, SizeDistribution, ValueBytes, WorkloadModel,
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
ValueBytes, WorkloadModel,
};
use crate::blockstore::GroupCommitConfig;
use crate::sim::FaultConfig;
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scenario {
SmokePR,
MstChurn,
MstRestartChurn,
FullStackRestart,
CatastrophicChurn,
HugeValues,
TinyBatches,
GiantBatches,
ManyFiles,
ModerateFaults,
AggressiveFaults,
TornPages,
Fsyncgate,
FirehoseFanout,
ContendedReaders,
ContendedWriters,
FlakyDevice,
}
impl Scenario {
pub const fn name(self) -> &'static str {
match self {
Self::SmokePR => "SmokePR",
Self::MstChurn => "MstChurn",
Self::MstRestartChurn => "MstRestartChurn",
Self::FullStackRestart => "FullStackRestart",
Self::CatastrophicChurn => "CatastrophicChurn",
Self::HugeValues => "HugeValues",
Self::TinyBatches => "TinyBatches",
Self::GiantBatches => "GiantBatches",
Self::ManyFiles => "ManyFiles",
Self::ModerateFaults => "ModerateFaults",
Self::AggressiveFaults => "AggressiveFaults",
Self::TornPages => "TornPages",
Self::Fsyncgate => "Fsyncgate",
Self::FirehoseFanout => "FirehoseFanout",
Self::ContendedReaders => "ContendedReaders",
Self::ContendedWriters => "ContendedWriters",
Self::FlakyDevice => "FlakyDevice",
}
}
pub const fn cli_name(self) -> &'static str {
match self {
Self::SmokePR => "smoke-pr",
Self::MstChurn => "mst-churn",
Self::MstRestartChurn => "mst-restart-churn",
Self::FullStackRestart => "full-stack-restart",
Self::CatastrophicChurn => "catastrophic-churn",
Self::HugeValues => "huge-values",
Self::TinyBatches => "tiny-batches",
Self::GiantBatches => "giant-batches",
Self::ManyFiles => "many-files",
Self::ModerateFaults => "moderate-faults",
Self::AggressiveFaults => "aggressive-faults",
Self::TornPages => "torn-pages",
Self::Fsyncgate => "fsyncgate",
Self::FirehoseFanout => "firehose-fanout",
Self::ContendedReaders => "contended-readers",
Self::ContendedWriters => "contended-writers",
Self::FlakyDevice => "flaky-device",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::SmokePR => "60s canary, 10k ops, core invariants. Default PR gate.",
Self::MstChurn => "100k churn, no restart. Refcount + reachability focus.",
Self::MstRestartChurn => "100k churn with Poisson restart bursts every ~5k ops.",
Self::FullStackRestart => "5k ops, deterministic restart every 500 ops.",
Self::CatastrophicChurn => {
"1M ops, phase-2 invariants, Poisson restart. 30 min budget."
}
Self::HugeValues => "Heavy-tail values up to 16 MiB. 32 MiB file cap.",
Self::TinyBatches => "Group-commit batch size 1, tight checkpoints, 4 KiB files.",
Self::GiantBatches => "Group-commit batch size 100k, 16 MiB files.",
Self::ManyFiles => "256-byte file cap, many segments, delete-heavy.",
Self::ModerateFaults => {
"Simulated IO with moderate fault config. CrashAtSyscall restarts."
}
Self::AggressiveFaults => {
"Simulated IO with aggressive fault config. CrashAtSyscall restarts."
}
Self::TornPages => "Torn-page 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."
}
Self::ContendedReaders => "60% reads, 64 writer tasks, simulated moderate faults.",
Self::ContendedWriters => {
"Add/delete heavy, 32 writer tasks, simulated moderate faults."
}
Self::FlakyDevice => {
"Real IO on ext4 atop dm-flakey. Requires root with dm-flakey available, skips otherwise."
}
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|s| s.name() == name)
}
pub fn from_cli_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|s| s.cli_name() == name)
}
pub const ALL: &'static [Scenario] = &[
Self::SmokePR,
Self::MstChurn,
Self::MstRestartChurn,
Self::FullStackRestart,
Self::CatastrophicChurn,
Self::HugeValues,
Self::TinyBatches,
Self::GiantBatches,
Self::ManyFiles,
Self::ModerateFaults,
Self::AggressiveFaults,
Self::TornPages,
Self::Fsyncgate,
Self::FirehoseFanout,
Self::ContendedReaders,
Self::ContendedWriters,
Self::FlakyDevice,
];
}
impl serde::Serialize for Scenario {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.cli_name())
}
}
impl<'de> serde::Deserialize<'de> for Scenario {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
Self::from_cli_name(&s).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown scenario {s:?}; expected one of {}",
Self::ALL
.iter()
.map(|s| s.cli_name())
.collect::<Vec<_>>()
.join(", ")
))
})
}
}
#[cfg(feature = "gauntlet-cli")]
impl clap::ValueEnum for Scenario {
fn value_variants<'a>() -> &'a [Self] {
Self::ALL
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
Some(clap::builder::PossibleValue::new(self.cli_name()).help(self.description()))
}
}
impl std::fmt::Display for Scenario {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, thiserror::Error)]
#[error("unknown scenario: {0}")]
pub struct UnknownScenario(pub String);
impl std::str::FromStr for Scenario {
type Err = UnknownScenario;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_name(s).ok_or_else(|| UnknownScenario(s.to_string()))
}
}
pub fn config_for(scenario: Scenario, seed: Seed) -> GauntletConfig {
@@ -23,6 +198,19 @@ pub fn config_for(scenario: Scenario, seed: Seed) -> GauntletConfig {
Scenario::MstChurn => mst_churn(seed),
Scenario::MstRestartChurn => mst_restart_churn(seed),
Scenario::FullStackRestart => full_stack_restart(seed),
Scenario::CatastrophicChurn => catastrophic_churn(seed),
Scenario::HugeValues => huge_values(seed),
Scenario::TinyBatches => tiny_batches(seed),
Scenario::GiantBatches => giant_batches(seed),
Scenario::ManyFiles => many_files(seed),
Scenario::ModerateFaults => moderate_faults(seed),
Scenario::AggressiveFaults => aggressive_faults(seed),
Scenario::TornPages => torn_pages(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),
}
}
@@ -33,6 +221,31 @@ fn default_collections() -> Vec<CollectionName> {
]
}
fn block_weights(add: u32, delete: u32, compact: u32, checkpoint: u32) -> OpWeights {
OpWeights {
add,
delete,
compact,
checkpoint,
..OpWeights::default()
}
}
fn block_workload(
weights: OpWeights,
size_distribution: SizeDistribution,
key_space: KeySpaceSize,
) -> WorkloadModel {
WorkloadModel {
weights,
size_distribution,
collections: default_collections(),
key_space,
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
}
}
fn tiny_store() -> StoreConfig {
StoreConfig {
max_file_size: MaxFileSize(4096),
@@ -49,17 +262,11 @@ fn smoke_pr(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 80,
delete: 0,
compact: 10,
checkpoint: 10,
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: default_collections(),
key_space: KeySpaceSize(200),
},
workload: block_workload(
block_weights(80, 0, 10, 10),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(200),
),
op_count: OpCount(10_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
@@ -71,6 +278,8 @@ fn smoke_pr(seed: Seed) -> GauntletConfig {
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(2_000)),
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
@@ -78,17 +287,11 @@ fn mst_churn(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 85,
delete: 0,
compact: 10,
checkpoint: 5,
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: default_collections(),
key_space: KeySpaceSize(2_000),
},
workload: block_workload(
block_weights(85, 0, 10, 5),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(2_000),
),
op_count: OpCount(100_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
@@ -100,6 +303,8 @@ fn mst_churn(seed: Seed) -> GauntletConfig {
},
restart_policy: RestartPolicy::Never,
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
@@ -107,17 +312,11 @@ fn mst_restart_churn(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 85,
delete: 0,
compact: 10,
checkpoint: 5,
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: default_collections(),
key_space: KeySpaceSize(2_000),
},
workload: block_workload(
block_weights(85, 0, 10, 5),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(2_000),
),
op_count: OpCount(100_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
@@ -129,6 +328,8 @@ fn mst_restart_churn(seed: Seed) -> GauntletConfig {
},
restart_policy: RestartPolicy::PoissonByOps(OpInterval(5_000)),
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
@@ -136,17 +337,11 @@ fn full_stack_restart(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 80,
delete: 0,
compact: 15,
checkpoint: 5,
},
size_distribution: SizeDistribution::Fixed(ValueBytes(80)),
collections: default_collections(),
key_space: KeySpaceSize(500),
},
workload: block_workload(
block_weights(80, 0, 15, 5),
SizeDistribution::Fixed(ValueBytes(80)),
KeySpaceSize(500),
),
op_count: OpCount(5_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
@@ -162,5 +357,401 @@ fn full_stack_restart(seed: Seed) -> GauntletConfig {
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn phase2_invariants() -> InvariantSet {
InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT
| InvariantSet::COMPACTION_IDEMPOTENT
| InvariantSet::BYTE_BUDGET
| InvariantSet::MANIFEST_EQUALS_REALITY
| InvariantSet::CHECKSUM_COVERAGE
}
fn catastrophic_churn(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
block_weights(94, 0, 5, 1),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(200),
),
op_count: OpCount(1_000_000),
invariants: phase2_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(30 * 60_000)),
},
restart_policy: RestartPolicy::PoissonByOps(OpInterval(50_000)),
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn huge_values(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
block_weights(85, 5, 8, 2),
SizeDistribution::HeavyTail(
ByteRange::new(ValueBytes(256), ValueBytes(16 * 1024 * 1024))
.expect("huge_values ByteRange"),
),
KeySpaceSize(64),
),
op_count: OpCount(2_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT,
limits: RunLimits {
max_wall_ms: Some(WallMs(10 * 60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(500)),
store: StoreConfig {
max_file_size: MaxFileSize(32 * 1024 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn tiny_batches(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
block_weights(85, 0, 5, 10),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(500),
),
op_count: OpCount(10_000),
invariants: phase2_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(120_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(2_000)),
store: StoreConfig {
max_file_size: MaxFileSize(4096),
group_commit: GroupCommitConfig {
max_batch_size: 1,
checkpoint_interval_ms: 100,
checkpoint_write_threshold: 1,
..GroupCommitConfig::default()
},
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn giant_batches(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
block_weights(95, 0, 3, 2),
SizeDistribution::Fixed(ValueBytes(64)),
KeySpaceSize(5_000),
),
op_count: OpCount(50_000),
invariants: phase2_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(10 * 60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(10_000)),
store: StoreConfig {
max_file_size: MaxFileSize(16 * 1024 * 1024),
group_commit: GroupCommitConfig {
max_batch_size: 100_000,
checkpoint_interval_ms: 5_000,
checkpoint_write_threshold: 100_000,
..GroupCommitConfig::default()
},
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn many_files(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Real,
workload: block_workload(
block_weights(80, 10, 5, 5),
SizeDistribution::Fixed(ValueBytes(128)),
KeySpaceSize(2_000),
),
op_count: OpCount(200_000),
invariants: phase2_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(20 * 60_000)),
},
restart_policy: RestartPolicy::PoissonByOps(OpInterval(5_000)),
store: StoreConfig {
max_file_size: MaxFileSize(256),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn sim_invariants() -> InvariantSet {
InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT
| InvariantSet::NO_ORPHAN_FILES
| InvariantSet::BYTE_BUDGET
| InvariantSet::CHECKSUM_COVERAGE
}
fn sim_microbench_workload() -> WorkloadModel {
block_workload(
block_weights(80, 10, 5, 5),
SizeDistribution::Fixed(ValueBytes(128)),
KeySpaceSize(500),
)
}
fn sim_store() -> StoreConfig {
StoreConfig {
max_file_size: MaxFileSize(16 * 1024),
group_commit: GroupCommitConfig {
verify_persisted_blocks: true,
..GroupCommitConfig::default()
},
shard_count: ShardCount(1),
}
}
fn moderate_faults(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::moderate(),
},
workload: sim_microbench_workload(),
op_count: OpCount(50_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),
}
}
fn aggressive_faults(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::aggressive(),
},
workload: sim_microbench_workload(),
op_count: OpCount(50_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),
}
}
fn torn_pages(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::torn_pages_only(),
},
workload: sim_microbench_workload(),
op_count: OpCount(20_000),
invariants: sim_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(5 * 60_000)),
},
restart_policy: RestartPolicy::CrashAtSyscall(OpInterval(1_000)),
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn fsyncgate(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::fsyncgate_only(),
},
workload: sim_microbench_workload(),
op_count: OpCount(10_000),
invariants: sim_invariants(),
limits: RunLimits {
max_wall_ms: Some(WallMs(5 * 60_000)),
},
restart_policy: RestartPolicy::CrashAtSyscall(OpInterval(500)),
store: sim_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn firehose_fanout(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::moderate(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 20,
compact: 2,
checkpoint: 3,
append_event: 60,
sync_event_log: 10,
run_retention: 5,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: default_collections(),
key_space: KeySpaceSize(500),
did_space: DidSpaceSize(64),
retention_max_secs: RetentionMaxSecs(60),
},
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(64 * 1024),
}),
writer_concurrency: WriterConcurrency(1),
}
}
fn contended_readers(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::moderate(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 15,
delete: 1,
compact: 2,
checkpoint: 2,
read_record: 60,
read_block: 20,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: default_collections(),
key_space: KeySpaceSize(400),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
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(64),
}
}
fn flaky_device(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::RealWithFlaky {
flaky: FlakyConfig::default_stress(),
},
workload: block_workload(
block_weights(80, 5, 10, 5),
SizeDistribution::Fixed(ValueBytes(128)),
KeySpaceSize(500),
),
op_count: OpCount(20_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT
| InvariantSet::NO_ORPHAN_FILES
| InvariantSet::MANIFEST_EQUALS_REALITY
| InvariantSet::BYTE_BUDGET
| InvariantSet::CHECKSUM_COVERAGE,
limits: RunLimits {
max_wall_ms: Some(WallMs(30 * 60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(1_000)),
store: tiny_store(),
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn contended_writers(seed: Seed) -> GauntletConfig {
GauntletConfig {
seed,
io: IoBackend::Simulated {
fault: FaultConfig::moderate(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 85,
delete: 5,
compact: 3,
checkpoint: 2,
read_record: 4,
read_block: 1,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: default_collections(),
key_space: KeySpaceSize(1_000),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
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(32),
}
}
@@ -0,0 +1,191 @@
use std::collections::BTreeSet;
use super::op::OpStream;
use super::runner::{Gauntlet, GauntletConfig, GauntletReport};
pub const DEFAULT_MAX_SHRINK_ITERATIONS: usize = 256;
#[derive(Debug)]
pub struct ShrinkOutcome {
pub ops: OpStream,
pub report: GauntletReport,
pub iterations: usize,
}
pub async fn shrink_failure(
config: GauntletConfig,
initial_ops: OpStream,
initial_report: GauntletReport,
max_iterations: usize,
) -> ShrinkOutcome {
let target: BTreeSet<&'static str> = initial_report.violation_invariants();
if target.is_empty() {
return ShrinkOutcome {
ops: initial_ops,
report: initial_report,
iterations: 0,
};
}
let mut current_ops = initial_ops;
let mut current_report = initial_report;
let mut iterations = 0usize;
while iterations < max_iterations {
match try_one_shrink_round(&config, &current_ops, &target, max_iterations - iterations)
.await
{
ShrinkRound::Progress {
ops,
report,
runs_used,
} => {
current_ops = ops;
current_report = report;
iterations += runs_used;
}
ShrinkRound::Exhausted { runs_used } => {
iterations += runs_used;
break;
}
}
}
ShrinkOutcome {
ops: current_ops,
report: current_report,
iterations,
}
}
enum ShrinkRound {
Progress {
ops: OpStream,
report: GauntletReport,
runs_used: usize,
},
Exhausted {
runs_used: usize,
},
}
async fn try_one_shrink_round(
config: &GauntletConfig,
current_ops: &OpStream,
target: &BTreeSet<&'static str>,
budget: usize,
) -> ShrinkRound {
let mut runs_used = 0usize;
for candidate in current_ops.shrink_candidates() {
if candidate.is_empty() || candidate.len() >= current_ops.len() {
continue;
}
if runs_used >= budget {
return ShrinkRound::Exhausted { runs_used };
}
runs_used += 1;
let gauntlet = match Gauntlet::new(config.clone()) {
Ok(g) => g,
Err(_) => continue,
};
let report = gauntlet.run_with_ops(candidate.clone()).await;
let got: BTreeSet<&'static str> = report.violation_invariants();
if !got.is_disjoint(target) {
return ShrinkRound::Progress {
ops: candidate,
report,
runs_used,
};
}
}
ShrinkRound::Exhausted { runs_used }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blockstore::GroupCommitConfig;
use crate::gauntlet::invariants::{InvariantSet, InvariantViolation};
use crate::gauntlet::op::{CollectionName, Op, OpStream, RecordKey, Seed, ValueSeed};
use crate::gauntlet::runner::{
GauntletConfig, IoBackend, MaxFileSize, OpErrorCount, OpsExecuted, RestartCount,
RestartPolicy, RunLimits, ShardCount, StoreConfig, WriterConcurrency,
};
use crate::gauntlet::workload::{
DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
ValueBytes, WorkloadModel,
};
use crate::sim::FaultConfig;
fn dummy_config() -> GauntletConfig {
GauntletConfig {
seed: Seed(1),
io: IoBackend::Simulated {
fault: FaultConfig::none(),
},
workload: WorkloadModel {
weights: OpWeights::default(),
size_distribution: SizeDistribution::Fixed(ValueBytes(16)),
collections: vec![CollectionName("c".into())],
key_space: KeySpaceSize(4),
did_space: DidSpaceSize(1),
retention_max_secs: RetentionMaxSecs(60),
},
op_count: OpCount(4),
invariants: InvariantSet::EMPTY,
limits: RunLimits { max_wall_ms: None },
restart_policy: RestartPolicy::Never,
store: StoreConfig {
max_file_size: MaxFileSize(4096),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn fake_report(seed: u64, names: &[&'static str]) -> GauntletReport {
GauntletReport {
seed: Seed(seed),
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(0),
restarts: RestartCount(0),
violations: names
.iter()
.copied()
.map(|n| InvariantViolation {
invariant: n,
detail: "x".to_string(),
})
.collect(),
ops: OpStream::empty(),
}
}
fn sample_stream() -> OpStream {
OpStream::from_vec(vec![
Op::AddRecord {
collection: CollectionName("c".into()),
rkey: RecordKey("a".into()),
value_seed: ValueSeed(1),
},
Op::Compact,
])
}
#[test]
fn clean_report_returns_input_unchanged() {
let cfg = dummy_config();
let ops = sample_stream();
let clean = fake_report(1, &[]);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let before_len = ops.len();
let out = rt.block_on(shrink_failure(cfg, ops, clean, 8));
assert_eq!(out.iterations, 0);
assert_eq!(out.ops.len(), before_len);
}
}
+326
View File
@@ -0,0 +1,326 @@
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use cid::Cid;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::invariants::{InvariantSet, InvariantViolation};
use super::leak::{LeakGateConfig, LeakViolation, evaluate as evaluate_leak_gate};
use super::metrics::{MetricsSample, sample_harness};
use super::op::{OpStream, Seed};
use super::oracle::Oracle;
use super::runner::{
EventLogState, GauntletConfig, Harness, IoBackend, apply_op, blockstore_config,
eventlog_snapshot, open_eventlog, refresh_oracle_graph, run_invariants, segments_subdir,
};
use super::workload::OpCount;
use crate::blockstore::TranquilBlockStore;
use crate::io::{RealIO, StorageIO};
const OP_ERROR_LOG_THROTTLE: u64 = 1024;
pub const DEFAULT_CHUNK_OPS: usize = 5_000;
pub const DEFAULT_SAMPLE_INTERVAL_MS: u64 = 60_000;
#[derive(Debug, Clone)]
pub struct SoakConfig {
pub gauntlet: GauntletConfig,
pub total_duration: Duration,
pub sample_interval: Duration,
pub chunk_ops: usize,
pub leak_gate: LeakGateConfig,
}
impl SoakConfig {
pub fn new(gauntlet: GauntletConfig, total_duration: Duration) -> Self {
Self {
gauntlet,
total_duration,
sample_interval: Duration::from_millis(DEFAULT_SAMPLE_INTERVAL_MS),
chunk_ops: DEFAULT_CHUNK_OPS,
leak_gate: LeakGateConfig::standard(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SoakError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("soak requires IoBackend::Real; scenario configured Simulated")]
SimulatedBackendRejected,
#[error("open block store: {0}")]
StoreOpen(String),
#[error("open event log: {0}")]
EventLogOpen(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoakReport {
pub seed: Seed,
pub ops_executed: u64,
pub op_errors: u64,
pub chunks: u64,
pub samples: Vec<MetricsSample>,
pub invariant_violations: Vec<InvariantViolationRecord>,
pub leak_violations: Vec<LeakViolation>,
pub total_wall_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvariantViolationRecord {
pub invariant: String,
pub detail: String,
}
impl SoakReport {
pub fn is_clean(&self) -> bool {
self.invariant_violations.is_empty() && self.leak_violations.is_empty()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SoakEvent {
#[serde(rename = "sample")]
Sample {
seed: u64,
chunk: u64,
ops_executed: u64,
sample: MetricsSample,
},
#[serde(rename = "invariant_violation")]
Invariant {
seed: u64,
invariant: String,
detail: String,
},
#[serde(rename = "summary")]
Summary {
seed: u64,
total_wall_ms: u64,
ops_executed: u64,
op_errors: u64,
chunks: u64,
clean: bool,
invariant_violations: usize,
leak_violations: Vec<LeakViolation>,
},
}
pub async fn run_soak<W: Write + Send>(
cfg: SoakConfig,
mut emitter: W,
) -> Result<SoakReport, SoakError> {
if !matches!(cfg.gauntlet.io, IoBackend::Real) {
return Err(SoakError::SimulatedBackendRejected);
}
let dir = tempfile::TempDir::new()?;
let store_cfg = blockstore_config(dir.path(), &cfg.gauntlet.store);
let segments_dir: PathBuf = segments_subdir(dir.path());
let store = TranquilBlockStore::open(store_cfg)
.map(Arc::new)
.map_err(|e| SoakError::StoreOpen(e.to_string()))?;
let eventlog: Option<EventLogState<RealIO>> = match cfg.gauntlet.eventlog {
None => None,
Some(ec) => Some(
open_eventlog(RealIO::new(), segments_dir, ec.max_segment_size.0)
.map_err(|e| SoakError::EventLogOpen(e.to_string()))?,
),
};
let mut harness = Harness { store, eventlog };
let outcome = drive_soak(&mut harness, &cfg, &mut emitter).await;
shutdown_harness(&mut harness);
outcome
}
fn shutdown_harness<S: StorageIO + Send + Sync + 'static>(harness: &mut Harness<S>) {
if let Some(el) = harness.eventlog.as_mut() {
if let Err(e) = el.writer.shutdown() {
warn!(error = %e, "soak: event log writer shutdown failed");
}
el.manager.shutdown();
}
}
async fn drive_soak<S, W>(
harness: &mut Harness<S>,
cfg: &SoakConfig,
emitter: &mut W,
) -> Result<SoakReport, SoakError>
where
S: StorageIO + Send + Sync + 'static,
W: Write + Send,
{
let mut oracle = Oracle::new();
let mut root: Option<Cid> = None;
let start = Instant::now();
let mut samples: Vec<MetricsSample> = Vec::new();
let mut invariant_records: Vec<InvariantViolationRecord> = Vec::new();
let mut ops_executed: u64 = 0;
let mut op_errors: u64 = 0;
let mut chunks: u64 = 0;
let mut last_sample = start;
let mut next_error_log_at: u64 = 1;
let initial = sample_harness(harness, Duration::ZERO);
emit_event(
emitter,
&SoakEvent::Sample {
seed: cfg.gauntlet.seed.0,
chunk: 0,
ops_executed: 0,
sample: initial,
},
)?;
samples.push(initial);
while start.elapsed() < cfg.total_duration {
let chunk_seed = Seed(
cfg.gauntlet
.seed
.0
.wrapping_add(chunks.wrapping_mul(0x9E37_79B9_7F4A_7C15)),
);
let stream: OpStream = cfg
.gauntlet
.workload
.generate(chunk_seed, OpCount(cfg.chunk_ops));
for op in stream.iter() {
if start.elapsed() >= cfg.total_duration {
break;
}
match apply_op(harness, &mut root, &mut oracle, op, &cfg.gauntlet.workload).await {
Ok(()) => {
ops_executed = ops_executed.saturating_add(1);
}
Err(e) => {
op_errors = op_errors.saturating_add(1);
if op_errors >= next_error_log_at {
warn!(
op_errors,
ops_executed,
elapsed_ms = u64::try_from(start.elapsed().as_millis())
.unwrap_or(u64::MAX),
error = %e,
"soak: op error milestone"
);
next_error_log_at = next_error_log_at
.saturating_mul(2)
.max(OP_ERROR_LOG_THROTTLE);
}
}
}
if last_sample.elapsed() >= cfg.sample_interval {
let elapsed = start.elapsed();
let s = sample_harness(harness, elapsed);
emit_event(
emitter,
&SoakEvent::Sample {
seed: cfg.gauntlet.seed.0,
chunk: chunks,
ops_executed,
sample: s,
},
)?;
samples.push(s);
last_sample = Instant::now();
}
}
chunks = chunks.saturating_add(1);
tokio::task::yield_now().await;
}
let final_elapsed = start.elapsed();
let final_sample = sample_harness(harness, final_elapsed);
emit_event(
emitter,
&SoakEvent::Sample {
seed: cfg.gauntlet.seed.0,
chunk: chunks,
ops_executed,
sample: final_sample,
},
)?;
samples.push(final_sample);
let invariants = match refresh_oracle_graph(&harness.store, &mut oracle, root).await {
Ok(()) => {
let snapshot = eventlog_snapshot(harness.eventlog.as_ref());
let set = cfg
.gauntlet
.invariants
.without(InvariantSet::RESTART_IDEMPOTENT);
run_invariants(&harness.store, &oracle, root, snapshot, set).await
}
Err(e) => vec![InvariantViolation {
invariant: "MstRootDurability",
detail: format!("refresh: {e}"),
}],
};
for v in invariants.iter() {
let rec = InvariantViolationRecord {
invariant: v.invariant.to_string(),
detail: v.detail.clone(),
};
emit_event(
emitter,
&SoakEvent::Invariant {
seed: cfg.gauntlet.seed.0,
invariant: rec.invariant.clone(),
detail: rec.detail.clone(),
},
)?;
invariant_records.push(rec);
}
let leak_violations = evaluate_leak_gate(&samples, cfg.leak_gate);
let total_wall_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let clean = invariant_records.is_empty() && leak_violations.is_empty();
emit_event(
emitter,
&SoakEvent::Summary {
seed: cfg.gauntlet.seed.0,
total_wall_ms,
ops_executed,
op_errors,
chunks,
clean,
invariant_violations: invariant_records.len(),
leak_violations: leak_violations.clone(),
},
)?;
Ok(SoakReport {
seed: cfg.gauntlet.seed,
ops_executed,
op_errors,
chunks,
samples,
invariant_violations: invariant_records,
leak_violations,
total_wall_ms,
})
}
fn emit_event<W: Write>(emitter: &mut W, event: &SoakEvent) -> io::Result<()> {
let line = serde_json::to_string(event).map_err(io::Error::other)?;
writeln!(emitter, "{line}")?;
emitter.flush()
}
#[cfg(test)]
mod tests {
use super::*;
fn send_sync<T: Send + Sync>() {}
#[test]
fn soak_error_is_send_sync() {
send_sync::<SoakError>();
}
}
+96 -12
View File
@@ -1,4 +1,7 @@
use super::op::{CollectionName, Op, OpStream, RecordKey, Seed, ValueSeed};
use super::op::{
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
ValueSeed,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ValueBytes(pub u32);
@@ -9,17 +12,34 @@ pub struct KeySpaceSize(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OpCount(pub usize);
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Default)]
pub struct OpWeights {
pub add: u32,
pub delete: u32,
pub compact: u32,
pub checkpoint: u32,
pub append_event: u32,
pub sync_event_log: u32,
pub run_retention: u32,
pub read_record: u32,
pub read_block: u32,
}
impl OpWeights {
pub const fn total(&self) -> u32 {
self.add + self.delete + self.compact + self.checkpoint
self.add
+ self.delete
+ self.compact
+ self.checkpoint
+ self.append_event
+ self.sync_event_log
+ self.run_retention
+ self.read_record
+ self.read_block
}
pub const fn touches_eventlog(&self) -> bool {
self.append_event > 0 || self.sync_event_log > 0 || self.run_retention > 0
}
}
@@ -51,14 +71,46 @@ impl ByteRange {
pub enum SizeDistribution {
Fixed(ValueBytes),
Uniform(ByteRange),
HeavyTail(ByteRange),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DidSpaceSize(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RetentionMaxSecs(pub u32);
#[derive(Debug, Clone)]
pub struct WorkloadModel {
pub weights: OpWeights,
pub size_distribution: SizeDistribution,
pub collections: Vec<CollectionName>,
pub key_space: KeySpaceSize,
pub did_space: DidSpaceSize,
pub retention_max_secs: RetentionMaxSecs,
}
impl Default for WorkloadModel {
fn default() -> Self {
Self {
weights: OpWeights {
add: 80,
delete: 10,
compact: 5,
checkpoint: 5,
append_event: 0,
sync_event_log: 0,
run_retention: 0,
read_record: 0,
read_block: 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),
}
}
}
impl WorkloadModel {
@@ -77,23 +129,46 @@ impl WorkloadModel {
let coll = self.collections[rng.next_usize() % self.collections.len()].clone();
let rkey = RecordKey(format!("{:06}", rng.next_u32() % self.key_space.0.max(1)));
let (a, d, c) = (
self.weights.add,
self.weights.add + self.weights.delete,
self.weights.add + self.weights.delete + self.weights.compact,
);
let w = &self.weights;
let t1 = w.add;
let t2 = t1 + w.delete;
let t3 = t2 + w.compact;
let t4 = t3 + w.checkpoint;
let t5 = t4 + w.append_event;
let t6 = t5 + w.sync_event_log;
let t7 = t6 + w.run_retention;
let t8 = t7 + w.read_record;
match bucket {
b if b < a => Op::AddRecord {
b if b < t1 => Op::AddRecord {
collection: coll,
rkey,
value_seed: ValueSeed(rng.next_u32()),
},
b if b < d => Op::DeleteRecord {
b if b < t2 => Op::DeleteRecord {
collection: coll,
rkey,
},
b if b < c => Op::Compact,
_ => Op::Checkpoint,
b if b < t3 => Op::Compact,
b if b < t4 => Op::Checkpoint,
b if b < t5 => Op::AppendEvent {
did_seed: DidSeed(rng.next_u32() % self.did_space.0.max(1)),
event_kind: event_kind_for(rng.next_u32()),
payload_seed: PayloadSeed(rng.next_u32()),
},
b if b < t6 => Op::SyncEventLog,
b if b < t7 => Op::RunRetention {
max_age_secs: RetentionSecs(
rng.next_u32() % self.retention_max_secs.0.max(1),
),
},
b if b < t8 => Op::ReadRecord {
collection: coll,
rkey,
},
_ => Op::ReadBlock {
value_seed: ValueSeed(rng.next_u32()),
},
}
})
.collect();
@@ -101,6 +176,15 @@ impl WorkloadModel {
}
}
fn event_kind_for(n: u32) -> EventKind {
match n & 0b11 {
0 => EventKind::Commit,
1 => EventKind::Identity,
2 => EventKind::Account,
_ => EventKind::Sync,
}
}
pub struct Lcg {
state: u64,
}
+2 -2
View File
@@ -28,8 +28,8 @@ pub use record::{
};
#[cfg(any(test, feature = "test-harness"))]
pub use sim::{
FaultConfig, OpRecord, SimulatedIO, sim_proptest_cases, sim_seed_count, sim_seed_range,
sim_single_seed,
FaultConfig, LatencyNs, OpRecord, Probability, SimulatedIO, SyncReorderWindow,
sim_proptest_cases, sim_seed_count, sim_seed_range, sim_single_seed,
};
pub(crate) fn wall_clock_ms() -> blockstore::WallClockMs {
+507 -46
View File
@@ -1,51 +1,170 @@
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::io::{FileId, OpenOptions, StorageIO};
pub const TORN_PAGE_BYTES: usize = 4096;
pub const SECTOR_BYTES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Probability(f64);
impl Probability {
pub const ZERO: Self = Self(0.0);
pub fn new(p: f64) -> Self {
assert!(
p.is_finite() && (0.0..=1.0).contains(&p),
"probability out of range: {p}"
);
Self(p)
}
pub fn raw(self) -> f64 {
self.0
}
pub fn is_nonzero(self) -> bool {
self.0 > 0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SyncReorderWindow(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LatencyNs(pub u64);
#[derive(Debug, Clone, Copy)]
pub struct FaultConfig {
pub partial_write_probability: f64,
pub bit_flip_on_read_probability: f64,
pub sync_failure_probability: f64,
pub dir_sync_failure_probability: f64,
pub misdirected_write_probability: f64,
pub io_error_probability: f64,
pub partial_write_probability: Probability,
pub bit_flip_on_read_probability: Probability,
pub sync_failure_probability: Probability,
pub dir_sync_failure_probability: Probability,
pub misdirected_write_probability: Probability,
pub io_error_probability: Probability,
pub torn_page_probability: Probability,
pub misdirected_read_probability: Probability,
pub delayed_io_error_probability: Probability,
pub sync_reorder_window: SyncReorderWindow,
pub latency_distribution_ns: LatencyNs,
}
impl FaultConfig {
pub fn none() -> Self {
Self {
partial_write_probability: 0.0,
bit_flip_on_read_probability: 0.0,
sync_failure_probability: 0.0,
dir_sync_failure_probability: 0.0,
misdirected_write_probability: 0.0,
io_error_probability: 0.0,
partial_write_probability: Probability::ZERO,
bit_flip_on_read_probability: Probability::ZERO,
sync_failure_probability: Probability::ZERO,
dir_sync_failure_probability: Probability::ZERO,
misdirected_write_probability: Probability::ZERO,
io_error_probability: Probability::ZERO,
torn_page_probability: Probability::ZERO,
misdirected_read_probability: Probability::ZERO,
delayed_io_error_probability: Probability::ZERO,
sync_reorder_window: SyncReorderWindow(0),
latency_distribution_ns: LatencyNs(0),
}
}
pub fn moderate() -> Self {
Self {
partial_write_probability: 0.05,
bit_flip_on_read_probability: 0.01,
sync_failure_probability: 0.03,
dir_sync_failure_probability: 0.02,
misdirected_write_probability: 0.01,
io_error_probability: 0.02,
partial_write_probability: Probability::new(0.05),
bit_flip_on_read_probability: Probability::new(0.01),
sync_failure_probability: Probability::new(0.03),
dir_sync_failure_probability: Probability::new(0.02),
misdirected_write_probability: Probability::new(0.01),
io_error_probability: Probability::new(0.02),
torn_page_probability: Probability::new(0.01),
misdirected_read_probability: Probability::new(0.005),
delayed_io_error_probability: Probability::new(0.01),
sync_reorder_window: SyncReorderWindow(4),
latency_distribution_ns: LatencyNs(50_000),
}
}
pub fn aggressive() -> Self {
Self {
partial_write_probability: 0.15,
bit_flip_on_read_probability: 0.05,
sync_failure_probability: 0.10,
dir_sync_failure_probability: 0.05,
misdirected_write_probability: 0.05,
io_error_probability: 0.08,
partial_write_probability: Probability::new(0.15),
bit_flip_on_read_probability: Probability::new(0.05),
sync_failure_probability: Probability::new(0.10),
dir_sync_failure_probability: Probability::new(0.05),
misdirected_write_probability: Probability::new(0.05),
io_error_probability: Probability::new(0.08),
torn_page_probability: Probability::new(0.05),
misdirected_read_probability: Probability::new(0.02),
delayed_io_error_probability: Probability::new(0.05),
sync_reorder_window: SyncReorderWindow(8),
latency_distribution_ns: LatencyNs(250_000),
}
}
pub fn torn_pages_only() -> Self {
Self {
torn_page_probability: Probability::new(0.25),
..Self::none()
}
}
pub fn fsyncgate_only() -> Self {
Self {
delayed_io_error_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()
|| self.sync_failure_probability.is_nonzero()
|| self.dir_sync_failure_probability.is_nonzero()
|| self.misdirected_write_probability.is_nonzero()
|| self.io_error_probability.is_nonzero()
|| self.torn_page_probability.is_nonzero()
|| self.misdirected_read_probability.is_nonzero()
|| self.delayed_io_error_probability.is_nonzero()
|| self.sync_reorder_window.0 > 0
}
pub fn scale_probabilities(self, factor: f64) -> Self {
let scale = |p: Probability| Probability::new((p.raw() * factor).clamp(0.0, 1.0));
Self {
partial_write_probability: scale(self.partial_write_probability),
bit_flip_on_read_probability: scale(self.bit_flip_on_read_probability),
sync_failure_probability: scale(self.sync_failure_probability),
dir_sync_failure_probability: scale(self.dir_sync_failure_probability),
misdirected_write_probability: scale(self.misdirected_write_probability),
io_error_probability: scale(self.io_error_probability),
torn_page_probability: scale(self.torn_page_probability),
misdirected_read_probability: scale(self.misdirected_read_probability),
delayed_io_error_probability: scale(self.delayed_io_error_probability),
sync_reorder_window: self.sync_reorder_window,
latency_distribution_ns: self.latency_distribution_ns,
}
}
pub fn uniform_density(density: f64) -> Self {
assert!(
density.is_finite() && (0.0..=1.0).contains(&density),
"fault density out of range: {density}"
);
let p = Probability::new(density);
Self {
partial_write_probability: p,
bit_flip_on_read_probability: p,
sync_failure_probability: p,
dir_sync_failure_probability: p,
misdirected_write_probability: p,
io_error_probability: p,
torn_page_probability: p,
misdirected_read_probability: p,
delayed_io_error_probability: p,
sync_reorder_window: SyncReorderWindow(0),
latency_distribution_ns: LatencyNs(0),
}
}
}
@@ -57,6 +176,7 @@ struct SimStorage {
buffered: Vec<u8>,
durable: Vec<u8>,
dir_entry_durable: bool,
io_poisoned: bool,
}
struct SimFd {
@@ -108,6 +228,17 @@ pub enum OpRecord {
},
}
struct PendingSync {
storage_id: StorageId,
snapshot: Vec<u8>,
}
struct PendingDelete {
path: PathBuf,
storage_id: StorageId,
was_dir_durable: bool,
}
struct SimState {
storage: HashMap<StorageId, SimStorage>,
paths: HashMap<PathBuf, StorageId>,
@@ -117,6 +248,8 @@ struct SimState {
rng_counter: u64,
next_fd_id: u64,
next_storage_id: u64,
pending_syncs: VecDeque<PendingSync>,
pending_deletes: Vec<PendingDelete>,
}
impl SimState {
@@ -137,8 +270,8 @@ impl SimState {
(mixed as usize) % max
}
fn should_fault(&mut self, seed: u64, probability: f64) -> bool {
probability > 0.0 && self.next_random(seed) < probability
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 {
@@ -194,6 +327,7 @@ pub struct SimulatedIO {
state: Mutex<SimState>,
fault_config: FaultConfig,
rng_seed: u64,
latency_counter: AtomicU64,
}
impl SimulatedIO {
@@ -208,12 +342,26 @@ impl SimulatedIO {
rng_counter: 0,
next_fd_id: 1,
next_storage_id: 1,
pending_syncs: VecDeque::new(),
pending_deletes: Vec::new(),
}),
fault_config,
rng_seed: seed,
latency_counter: AtomicU64::new(0),
}
}
fn jitter(&self) {
let max_ns = self.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));
}
pub fn pristine(seed: u64) -> Self {
Self::new(seed, FaultConfig::none())
}
@@ -222,6 +370,14 @@ impl SimulatedIO {
let mut state = self.state.lock().unwrap();
state.fds.clear();
state.pending_syncs.clear();
let pending = std::mem::take(&mut state.pending_deletes);
pending.into_iter().for_each(|pd| {
if pd.was_dir_durable && state.storage.contains_key(&pd.storage_id) {
state.paths.insert(pd.path, pd.storage_id);
}
});
let orphaned: Vec<StorageId> = state
.storage
@@ -237,10 +393,10 @@ impl SimulatedIO {
let live_sids: HashSet<StorageId> = state.storage.keys().copied().collect();
state.paths.retain(|_, sid| live_sids.contains(sid));
state
.storage
.values_mut()
.for_each(|s| s.buffered = s.durable.clone());
state.storage.values_mut().for_each(|s| {
s.buffered = s.durable.clone();
s.io_poisoned = false;
});
}
pub fn op_log(&self) -> Vec<OpRecord> {
@@ -314,6 +470,7 @@ impl StorageIO for SimulatedIO {
buffered: Vec::new(),
durable: Vec::new(),
dir_entry_durable: false,
io_poisoned: false,
},
);
state.paths.insert(path_buf.clone(), sid);
@@ -345,8 +502,9 @@ impl StorageIO for SimulatedIO {
let sid = fd_info.storage_id;
let unlinked = !state.paths.values().any(|s| *s == sid);
let no_remaining_fds = !state.fds.values().any(|f| f.storage_id == sid);
let pending_deleted = state.pending_deletes.iter().any(|pd| pd.storage_id == sid);
if unlinked && no_remaining_fds {
if unlinked && no_remaining_fds && !pending_deleted {
state.storage.remove(&sid);
}
@@ -355,17 +513,35 @@ impl StorageIO for SimulatedIO {
}
fn read_at(&self, id: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
self.jitter();
let mut state = self.state.lock().unwrap();
let sid = state.require_readable(id)?;
let seed = self.rng_seed;
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, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on read"));
}
let read_offset =
if state.should_fault(seed, self.fault_config.misdirected_read_probability) {
let drift_sectors = state.next_random_usize(seed, 8) + 1;
let drift = (drift_sectors * SECTOR_BYTES) as u64;
if state.next_random(seed) < 0.5 {
offset.saturating_sub(drift)
} else {
offset.saturating_add(drift)
}
} else {
offset
};
let storage = state.storage.get(&sid).unwrap();
let off = usize::try_from(offset)
let off = usize::try_from(read_offset)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset exceeds usize"))?;
if off >= storage.buffered.len() {
state.op_log.push(OpRecord::ReadAt {
@@ -395,26 +571,48 @@ impl StorageIO for SimulatedIO {
}
fn write_at(&self, id: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> {
self.jitter();
let mut state = self.state.lock().unwrap();
let sid = state.require_writable(id)?;
let seed = self.rng_seed;
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, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on write"));
}
let actual_len = if buf.len() > 1
&& state.should_fault(seed, self.fault_config.partial_write_probability)
{
let partial = state.next_random_usize(seed, buf.len());
partial.max(1)
} else {
buf.len()
let torn_len =
if buf.len() > 1 && state.should_fault(seed, self.fault_config.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;
n * SECTOR_BYTES
})
} else {
None
};
let actual_len = match torn_len {
Some(n) => n,
None if buf.len() > 1
&& state.should_fault(seed, self.fault_config.partial_write_probability) =>
{
let partial = state.next_random_usize(seed, buf.len());
partial.max(1)
}
None => buf.len(),
};
let misdirected = state.should_fault(seed, self.fault_config.misdirected_write_probability);
let write_offset = if misdirected {
let drift = state.next_random_usize(seed, 64) as u64;
let drift_sectors = state.next_random_usize(seed, 8) + 1;
let drift = (drift_sectors * SECTOR_BYTES) as u64;
if state.next_random(seed) < 0.5 {
offset.saturating_sub(drift)
} else {
@@ -444,21 +642,56 @@ impl StorageIO for SimulatedIO {
}
fn sync(&self, id: FileId) -> io::Result<()> {
self.jitter();
let mut state = self.state.lock().unwrap();
let sid = state.require_open(id)?;
let seed = self.rng_seed;
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, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on sync"));
}
let sync_succeeded = !state.should_fault(seed, self.fault_config.sync_failure_probability);
let poison_after = sync_succeeded
&& state.should_fault(seed, self.fault_config.delayed_io_error_probability);
let reorder_window = self.fault_config.sync_reorder_window.0 as usize;
let evicted = if sync_succeeded && reorder_window > 0 {
let snapshot = state.storage.get(&sid).unwrap().buffered.clone();
state.pending_syncs.push_back(PendingSync {
storage_id: sid,
snapshot,
});
if state.pending_syncs.len() > reorder_window {
state.pending_syncs.pop_front()
} else {
None
}
} else {
None
};
if let Some(PendingSync {
storage_id: old_sid,
snapshot,
}) = evicted
&& let Some(old) = state.storage.get_mut(&old_sid)
{
old.durable = snapshot;
}
let storage = state.storage.get_mut(&sid).unwrap();
if sync_succeeded {
if sync_succeeded && reorder_window == 0 {
storage.durable = storage.buffered.clone();
}
if poison_after {
storage.io_poisoned = true;
}
state.op_log.push(OpRecord::Sync {
fd: id,
@@ -517,11 +750,17 @@ impl StorageIO for SimulatedIO {
.remove(&path_buf)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))?;
let has_open_fds = state.fds.values().any(|fd_info| fd_info.storage_id == sid);
let was_dir_durable = state
.storage
.get(&sid)
.map(|s| s.dir_entry_durable)
.unwrap_or(false);
if !has_open_fds {
state.storage.remove(&sid);
}
state.pending_deletes.push(PendingDelete {
path: path_buf.clone(),
storage_id: sid,
was_dir_durable,
});
state.op_log.push(OpRecord::Delete { path: path_buf });
Ok(())
@@ -562,6 +801,18 @@ impl StorageIO for SimulatedIO {
storage.dir_entry_durable = true;
}
});
let drained = std::mem::take(&mut state.pending_deletes);
let (committed, remaining): (Vec<_>, Vec<_>) = drained
.into_iter()
.partition(|pd| pd.path.parent() == Some(path));
state.pending_deletes = remaining;
committed.into_iter().for_each(|pd| {
let has_fds = state.fds.values().any(|f| f.storage_id == pd.storage_id);
if !has_fds {
state.storage.remove(&pd.storage_id);
}
});
}
state.op_log.push(OpRecord::SyncDir { path: dir_path });
@@ -684,6 +935,67 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn delete_without_dir_sync_reverts_on_crash() {
let sim = SimulatedIO::pristine(42);
let dir = Path::new("/test");
sim.mkdir(dir).unwrap();
sim.sync_dir(dir).unwrap();
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
sim.write_at(fd, 0, b"durable data").unwrap();
sim.sync(fd).unwrap();
sim.sync_dir(dir).unwrap();
sim.close(fd).unwrap();
sim.delete(path).unwrap();
sim.crash();
let fd = sim.open(path, OpenOptions::read()).unwrap();
let mut buf = vec![0u8; 12];
sim.read_at(fd, 0, &mut buf).unwrap();
assert_eq!(&buf, b"durable data");
}
#[test]
fn delete_commits_after_dir_sync() {
let sim = SimulatedIO::pristine(42);
let dir = Path::new("/test");
sim.mkdir(dir).unwrap();
sim.sync_dir(dir).unwrap();
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
sim.write_at(fd, 0, b"data").unwrap();
sim.sync(fd).unwrap();
sim.sync_dir(dir).unwrap();
sim.close(fd).unwrap();
sim.delete(path).unwrap();
sim.sync_dir(dir).unwrap();
sim.crash();
let result = sim.open(path, OpenOptions::read());
assert!(result.is_err());
}
#[test]
fn delete_of_never_durable_file_stays_gone_on_crash() {
let sim = SimulatedIO::pristine(42);
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
sim.write_at(fd, 0, b"volatile").unwrap();
sim.sync(fd).unwrap();
sim.close(fd).unwrap();
sim.delete(path).unwrap();
sim.crash();
let result = sim.open(path, OpenOptions::read());
assert!(result.is_err());
}
#[test]
fn dir_sync_makes_file_durable() {
let sim = SimulatedIO::pristine(42);
@@ -850,6 +1162,155 @@ mod tests {
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn torn_page_truncates_within_page() {
let fc = FaultConfig {
torn_page_probability: Probability::new(1.0),
..FaultConfig::none()
};
let sim = SimulatedIO::new(123, fc);
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
let data = vec![0xAAu8; TORN_PAGE_BYTES + 1024];
let written = sim.write_at(fd, 0, &data).unwrap();
assert!(written >= 1);
assert!(written <= TORN_PAGE_BYTES);
}
#[test]
fn delayed_io_error_poisons_storage_after_sync() {
let fc = FaultConfig {
delayed_io_error_probability: Probability::new(1.0),
..FaultConfig::none()
};
let sim = SimulatedIO::new(7, fc);
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
sim.write_at(fd, 0, b"hello").unwrap();
sim.sync(fd).unwrap();
let err = sim.write_at(fd, 5, b"world").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Other);
let err2 = sim.sync(fd).unwrap_err();
assert_eq!(err2.kind(), io::ErrorKind::Other);
let mut buf = [0u8; 5];
let err3 = sim.read_at(fd, 0, &mut buf).unwrap_err();
assert_eq!(err3.kind(), io::ErrorKind::Other);
}
#[test]
fn misdirected_read_reads_wrong_offset() {
let fc = FaultConfig {
misdirected_read_probability: Probability::new(1.0),
..FaultConfig::none()
};
let sim = SimulatedIO::new(1, fc);
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
let data: Vec<u8> = (0..2048u32).flat_map(|n| n.to_le_bytes()).collect();
sim.write_at(fd, 0, &data).unwrap();
sim.sync(fd).unwrap();
let mut drifted_hit = false;
for _ in 0..32 {
let mut buf = [0u8; 16];
let target_off = 4096u64;
let expected = &data[target_off as usize..target_off as usize + 16];
if sim.read_at(fd, target_off, &mut buf).unwrap() == 16 && buf != expected {
drifted_hit = true;
break;
}
}
assert!(
drifted_hit,
"misdirected read never drifted away from target"
);
}
#[test]
fn sync_reorder_window_defers_durability() {
let fc = FaultConfig {
sync_reorder_window: SyncReorderWindow(2),
..FaultConfig::none()
};
let sim = SimulatedIO::new(42, fc);
let dir = Path::new("/test");
sim.mkdir(dir).unwrap();
sim.sync_dir(dir).unwrap();
let a = Path::new("/test/a.dat");
let fd_a = sim.open(a, OpenOptions::read_write()).unwrap();
sim.write_at(fd_a, 0, b"A").unwrap();
sim.sync(fd_a).unwrap();
assert!(sim.durable_contents(fd_a).unwrap().is_empty());
let b = Path::new("/test/b.dat");
let fd_b = sim.open(b, OpenOptions::read_write()).unwrap();
sim.write_at(fd_b, 0, b"B").unwrap();
sim.sync(fd_b).unwrap();
assert!(sim.durable_contents(fd_a).unwrap().is_empty());
assert!(sim.durable_contents(fd_b).unwrap().is_empty());
let c = Path::new("/test/c.dat");
let fd_c = sim.open(c, OpenOptions::read_write()).unwrap();
sim.write_at(fd_c, 0, b"C").unwrap();
sim.sync(fd_c).unwrap();
assert_eq!(sim.durable_contents(fd_a).unwrap(), b"A");
assert!(sim.durable_contents(fd_b).unwrap().is_empty());
assert!(sim.durable_contents(fd_c).unwrap().is_empty());
}
#[test]
fn sync_reorder_commits_at_sync_time_snapshot_not_current_buffer() {
let fc = FaultConfig {
sync_reorder_window: SyncReorderWindow(1),
..FaultConfig::none()
};
let sim = SimulatedIO::new(42, fc);
let dir = Path::new("/test");
sim.mkdir(dir).unwrap();
sim.sync_dir(dir).unwrap();
let a = Path::new("/test/a.dat");
let fd_a = sim.open(a, OpenOptions::read_write()).unwrap();
sim.write_at(fd_a, 0, b"first").unwrap();
sim.sync(fd_a).unwrap();
sim.write_at(fd_a, 0, b"second").unwrap();
let b = Path::new("/test/b.dat");
let fd_b = sim.open(b, OpenOptions::read_write()).unwrap();
sim.write_at(fd_b, 0, b"b").unwrap();
sim.sync(fd_b).unwrap();
assert_eq!(
sim.durable_contents(fd_a).unwrap(),
b"first",
"reordered sync must commit buffered-at-sync-call, not current buffered"
);
}
#[test]
fn crash_drops_pending_reordered_syncs() {
let fc = FaultConfig {
sync_reorder_window: SyncReorderWindow(4),
..FaultConfig::none()
};
let sim = SimulatedIO::new(42, fc);
let dir = Path::new("/test");
sim.mkdir(dir).unwrap();
sim.sync_dir(dir).unwrap();
let path = Path::new("/test/file.dat");
let fd = sim.open(path, OpenOptions::read_write()).unwrap();
sim.sync_dir(dir).unwrap();
sim.write_at(fd, 0, b"pending").unwrap();
sim.sync(fd).unwrap();
sim.crash();
let fd2 = sim.open(path, OpenOptions::read()).unwrap();
assert_eq!(sim.file_size(fd2).unwrap(), 0);
}
#[test]
fn last_sync_persisted_tracks_truth() {
let sim = SimulatedIO::pristine(42);
+3 -1
View File
@@ -484,7 +484,9 @@ fn incremental_backup_and_restore() {
fn incremental_is_smaller_than_full() {
with_runtime(|| {
let store = open_test_store_with_max_file_size(2048);
seed_blocks(&store, 0..100);
(0u16..100).step_by(10).for_each(|start| {
seed_blocks(&store, start..start + 10);
});
seed_events(&store, 50);
store.metastore.persist().unwrap();
+15 -13
View File
@@ -7,7 +7,9 @@ use tranquil_store::eventlog::{
SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, SegmentWriter, TimestampMicros,
ValidEvent, rebuild_from_segment,
};
use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO, sim_seed_range};
use tranquil_store::{
FaultConfig, OpenOptions, Probability, SimulatedIO, StorageIO, sim_seed_range,
};
fn setup_manager(sim: SimulatedIO, max_segment_size: u64) -> Arc<SegmentManager<SimulatedIO>> {
Arc::new(SegmentManager::new(sim, PathBuf::from("/segments"), max_segment_size).unwrap())
@@ -50,7 +52,7 @@ fn synced_events_survive_crash() {
"seed {seed}: expected all synced events to survive"
);
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -179,7 +181,7 @@ fn partial_event_truncated_on_recovery() {
mgr.io().sync_dir(Path::new("/segments")).unwrap();
}
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let file_size = mgr.io().file_size(fd).unwrap();
let partial_bytes = ((seed % 20) + 1) as usize;
let junk: Vec<u8> = (0..partial_bytes)
@@ -250,7 +252,7 @@ fn cross_segment_recovery() {
);
sealed_segments[..sealed_count].iter().for_each(|&seg_id| {
let fd = mgr.open_for_read(seg_id).unwrap();
let fd = mgr.open_for_read(seg_id).unwrap().fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -340,7 +342,7 @@ fn large_sealed_segment_index_rebuild_latency() {
let index_path = mgr.index_path(SegmentId::new(1));
let _ = mgr.io().delete(&index_path);
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap();
let fd = mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let start = std::time::Instant::now();
let (index, last_seq) = rebuild_from_segment(mgr.io(), fd, 256, MAX_EVENT_PAYLOAD).unwrap();
@@ -415,7 +417,7 @@ fn pristine_comparison_under_faults() {
}
pristine_mgr.shutdown();
let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap();
let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let pristine_events =
SegmentReader::open(pristine_mgr.io(), pristine_fd, MAX_EVENT_PAYLOAD)
.unwrap()
@@ -463,7 +465,7 @@ fn pristine_comparison_under_faults() {
return Ok(None);
}
let fd = faulty_clone.open_for_read(SegmentId::new(1))?;
let fd = faulty_clone.open_for_read(SegmentId::new(1))?.fd();
let events = SegmentReader::open(faulty_clone.io(), fd, MAX_EVENT_PAYLOAD)?
.valid_prefix()?;
Ok(Some(events))
@@ -540,15 +542,15 @@ fn fault_configs() -> Vec<(&'static str, FaultConfig)> {
(
"partial_writes_only",
FaultConfig {
partial_write_probability: 0.15,
partial_write_probability: Probability::new(0.15),
..FaultConfig::none()
},
),
(
"sync_failures_only",
FaultConfig {
sync_failure_probability: 0.10,
dir_sync_failure_probability: 0.05,
sync_failure_probability: Probability::new(0.10),
dir_sync_failure_probability: Probability::new(0.05),
..FaultConfig::none()
},
),
@@ -556,7 +558,7 @@ fn fault_configs() -> Vec<(&'static str, FaultConfig)> {
(
"bit_flips_only",
FaultConfig {
bit_flip_on_read_probability: 0.05,
bit_flip_on_read_probability: Probability::new(0.05),
..FaultConfig::none()
},
),
@@ -590,7 +592,7 @@ fn pristine_comparison_parameterized_faults() {
}
pristine_mgr.shutdown();
let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap();
let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap().fd();
let pristine_events = SegmentReader::open(pristine_mgr.io(), pristine_fd, MAX_EVENT_PAYLOAD)
.unwrap()
.valid_prefix()
@@ -635,7 +637,7 @@ fn pristine_comparison_parameterized_faults() {
return Ok(None);
}
let fd = faulty_clone.open_for_read(SegmentId::new(1))?;
let fd = faulty_clone.open_for_read(SegmentId::new(1))?.fd();
let events = SegmentReader::open(faulty_clone.io(), fd, MAX_EVENT_PAYLOAD)?
.valid_prefix()?;
Ok(Some(events))
@@ -0,0 +1,56 @@
use std::path::PathBuf;
use std::sync::Arc;
use tranquil_store::SimulatedIO;
use tranquil_store::StorageIO;
use tranquil_store::eventlog::{SegmentId, SegmentManager};
#[test]
fn concurrent_reader_survives_evict_on_segment_delete() {
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0x1eed7a11));
let segments_dir = PathBuf::from("/segments");
let manager =
Arc::new(SegmentManager::new(Arc::clone(&sim), segments_dir.clone(), 1 << 20).unwrap());
let seg_id = SegmentId::new(1);
let write_handle = manager.open_for_append(seg_id).unwrap();
sim.write_at(
write_handle.fd(),
0,
b"arbitrary seed bytes for the segment",
)
.unwrap();
sim.sync(write_handle.fd()).unwrap();
sim.sync_dir(&segments_dir).unwrap();
drop(write_handle);
let ready_to_evict = Arc::new(std::sync::Barrier::new(2));
let evict_done = Arc::new(std::sync::Barrier::new(2));
let reader_manager = Arc::clone(&manager);
let reader_io = Arc::clone(&sim);
let reader_ready = Arc::clone(&ready_to_evict);
let reader_done = Arc::clone(&evict_done);
let reader = std::thread::spawn(move || {
let read_handle = reader_manager.open_for_read(seg_id).unwrap();
reader_ready.wait();
reader_done.wait();
reader_io.file_size(read_handle.fd())
});
ready_to_evict.wait();
manager.delete_segment(seg_id).unwrap();
evict_done.wait();
let read_result = reader.join().unwrap();
assert!(
read_result.is_ok(),
"read against a FileId obtained before delete_segment must still succeed; \
SegmentManager's delete_segment / rollback_rotation close the fd while a reader holds it. \
error: {:?}",
read_result.err()
);
}
@@ -611,9 +611,9 @@ fn fsync_ordering_blocks_before_events() {
SegmentManager::new(Arc::clone(&sim), PathBuf::from("/segments"), 64 * 1024).unwrap(),
);
let block_fd = block_mgr.open_for_append(DataFileId::new(0)).unwrap();
let block_handle = block_mgr.open_for_append(DataFileId::new(0)).unwrap();
let mut block_writer =
DataFileWriter::new(block_mgr.io(), block_fd, DataFileId::new(0)).unwrap();
DataFileWriter::new(block_mgr.io(), block_handle.fd(), DataFileId::new(0)).unwrap();
let cid = test_cid(1);
let _ = block_writer.append_block(&cid, &[0xAA; 128]).unwrap();
block_writer.sync().unwrap();
+109
View File
@@ -0,0 +1,109 @@
use std::io::BufRead;
use std::path::Path;
use jacquard_repo::storage::BlockStore;
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const POST_DROP_FD_TOLERANCE: i64 = 2;
fn fd_count() -> usize {
std::fs::read_dir("/proc/self/fd")
.map(|it| it.count())
.unwrap_or(0)
}
fn log_rlimit(label: &str) {
if let Ok(f) = std::fs::File::open("/proc/self/limits") {
let reader = std::io::BufReader::new(f);
reader
.lines()
.map_while(Result::ok)
.find(|l| l.contains("open files"))
.into_iter()
.for_each(|l| eprintln!("[{label}] {l}"));
}
}
fn config_for(dir: &Path, max_file_size: u64) -> BlockStoreConfig {
BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size,
group_commit: GroupCommitConfig {
checkpoint_interval_ms: 100,
checkpoint_write_threshold: 10,
..GroupCommitConfig::default()
},
shard_count: 1,
}
}
fn tiny_block(seed: u64) -> Vec<u8> {
let bytes = seed.to_le_bytes();
(0..64)
.map(|i| bytes[i % 8] ^ (i as u8).wrapping_mul(31))
.collect()
}
#[tokio::test]
async fn fds_stable_within_store_lifetime() {
log_rlimit("start");
let dir = tempfile::TempDir::new().unwrap();
let cfg = config_for(dir.path(), 4096);
let base = fd_count() as i64;
eprintln!("baseline fds: {base}");
let store = TranquilBlockStore::open(cfg.clone()).expect("open");
let after_open = fd_count() as i64;
eprintln!("after open: {after_open} fds, delta {}", after_open - base);
for i in 0..20_000u64 {
let data = tiny_block(i);
store.put(&data).await.expect("put");
if i.is_multiple_of(2_000) {
let fds = fd_count() as i64;
eprintln!("after {i} puts: {fds} fds, delta {}", fds - base);
}
}
let final_fds = fd_count() as i64;
eprintln!("final: {final_fds} fds, delta {}", final_fds - base);
drop(store);
let after_drop = fd_count() as i64;
let delta = after_drop - base;
eprintln!("after drop: {after_drop} fds, delta {delta}");
assert!(
delta <= POST_DROP_FD_TOLERANCE,
"fd leak after store drop: baseline {base}, after_drop {after_drop}, delta {delta}"
);
}
#[tokio::test]
async fn fds_stable_across_reopens() {
log_rlimit("start");
let dir = tempfile::TempDir::new().unwrap();
let cfg = config_for(dir.path(), 4096);
let base = fd_count() as i64;
eprintln!("baseline: {base}");
for cycle in 0..20usize {
let store = TranquilBlockStore::open(cfg.clone()).expect("open");
for i in 0..2_000u64 {
let data = tiny_block((cycle as u64) * 10_000 + i);
store.put(&data).await.expect("put");
}
let before_drop = fd_count() as i64;
drop(store);
let after_drop = fd_count() as i64;
let delta = after_drop - base;
eprintln!(
"cycle {cycle}: before_drop {before_drop}, after_drop {after_drop}, delta {delta}"
);
assert!(
delta <= POST_DROP_FD_TOLERANCE,
"fd leak across reopens at cycle {cycle}: baseline {base}, after_drop {after_drop}, delta {delta}"
);
}
}
@@ -0,0 +1,70 @@
use std::num::NonZeroU32;
use tranquil_store::gauntlet::{
BackingMegabytes, DownIntervalSecs, FlakyConfig, FlakyMount, Gauntlet, Scenario, Seed,
UpIntervalSecs, config_for,
};
#[tokio::test]
#[ignore = "requires root + dm-flakey; run under a privileged container"]
async fn flaky_device_scenario_sanity() {
let cfg = config_for(Scenario::FlakyDevice, Seed(1));
let op_count = cfg.op_count.0;
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
let env_skip = report
.violations
.iter()
.any(|v| v.invariant == "FlakyEnvironment");
if env_skip {
eprintln!(
"flaky environment unavailable: {}",
report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect::<Vec<_>>()
.join(", ")
);
return;
}
let failures: Vec<String> = report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect();
assert!(failures.is_empty(), "violations: {failures:?}");
let floor = op_count / 2;
assert!(
report.ops_executed.0 >= floor,
"flaky ops_executed {} below floor {} of {op_count}: {} op_errors, {} restarts",
report.ops_executed.0,
floor,
report.op_errors.0,
report.restarts.0,
);
}
#[test]
#[ignore = "requires root + dm-flakey; exercises mount/teardown without running Gauntlet"]
fn flaky_mount_setup_teardown() {
let cfg = FlakyConfig {
up_interval: UpIntervalSecs(NonZeroU32::new(5).unwrap()),
down_interval: DownIntervalSecs(NonZeroU32::new(1).unwrap()),
backing_mb: BackingMegabytes(64),
};
let mount = match FlakyMount::try_new(&cfg) {
Ok(m) => m,
Err(e) if e.is_env_absent() => {
eprintln!("flaky environment unavailable: {e}");
return;
}
Err(e) => panic!("flaky mount setup failed: {e}"),
};
assert!(mount.path().exists(), "mount path should exist");
assert!(mount.mapper_path().exists(), "mapper device should exist");
let marker = mount.path().join("marker");
std::fs::write(&marker, b"ok").expect("write through flaky mount");
let back = std::fs::read(&marker).expect("read back");
assert_eq!(back, b"ok");
drop(mount);
}
+371 -41
View File
@@ -1,10 +1,23 @@
use tranquil_store::FaultConfig;
use tranquil_store::blockstore::GroupCommitConfig;
use tranquil_store::gauntlet::{
CollectionName, Gauntlet, GauntletConfig, InvariantSet, IoBackend, KeySpaceSize, MaxFileSize,
OpCount, OpInterval, OpWeights, RestartPolicy, RunLimits, Scenario, Seed, ShardCount,
SizeDistribution, StoreConfig, ValueBytes, WallMs, WorkloadModel, config_for, farm,
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,
};
#[track_caller]
fn assert_clean(report: &GauntletReport) {
let violations: Vec<String> = report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect();
assert!(report.is_clean(), "violations: {violations:?}");
}
#[test]
#[ignore = "long running, 30 seeds of 10k ops each"]
fn smoke_pr_30_seeds() {
@@ -38,13 +51,15 @@ fn fast_sanity_config(seed: Seed) -> GauntletConfig {
workload: WorkloadModel {
weights: OpWeights {
add: 80,
delete: 0,
compact: 10,
checkpoint: 10,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(100),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(200),
invariants: InvariantSet::REFCOUNT_CONSERVATION
@@ -65,6 +80,8 @@ fn fast_sanity_config(seed: Seed) -> GauntletConfig {
},
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
@@ -74,20 +91,8 @@ async fn gauntlet_fast_sanity() {
.expect("build gauntlet")
.run()
.await;
assert!(
report.is_clean(),
"violations: {:?}",
report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect::<Vec<_>>()
);
assert!(
report.restarts.0 >= 2,
"expected at least 2 restarts, got {}",
report.restarts.0
);
assert_clean(&report);
assert!(report.restarts.0 >= 2);
assert_eq!(report.ops_executed.0, 200);
}
@@ -95,38 +100,363 @@ async fn gauntlet_fast_sanity() {
async fn full_stack_restart_port() {
let cfg = config_for(Scenario::FullStackRestart, Seed(1));
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert!(
report.is_clean(),
"violations: {:?}",
report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect::<Vec<_>>()
);
assert_clean(&report);
assert_eq!(
report.restarts.0, 10,
"FullStackRestart with EveryNOps(500) over 5000 ops must restart exactly 10 times",
);
}
#[tokio::test]
async fn compaction_idempotent_sanity() {
let cfg = GauntletConfig {
seed: Seed(3),
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 70,
delete: 10,
compact: 15,
checkpoint: 5,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(50),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(300),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::COMPACTION_IDEMPOTENT,
limits: RunLimits {
max_wall_ms: Some(WallMs(30_000)),
},
restart_policy: RestartPolicy::Never,
store: StoreConfig {
max_file_size: MaxFileSize(4096),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
}
#[tokio::test]
async fn no_orphan_files_sanity() {
let cfg = GauntletConfig {
seed: Seed(11),
io: IoBackend::Real,
workload: WorkloadModel {
weights: OpWeights {
add: 90,
checkpoint: 10,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(80),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(200),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::COMPACTION_IDEMPOTENT
| InvariantSet::NO_ORPHAN_FILES,
limits: RunLimits {
max_wall_ms: Some(WallMs(30_000)),
},
restart_policy: RestartPolicy::Never,
store: StoreConfig {
max_file_size: MaxFileSize(64 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
}
#[tokio::test]
async fn simulated_pristine_roundtrip() {
let cfg = GauntletConfig {
seed: Seed(21),
io: IoBackend::Simulated {
fault: FaultConfig::none(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 80,
delete: 10,
compact: 5,
checkpoint: 5,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(96)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(80),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(300),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT
| InvariantSet::CHECKSUM_COVERAGE,
limits: RunLimits {
max_wall_ms: Some(WallMs(60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(100)),
store: StoreConfig {
max_file_size: MaxFileSize(8 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
assert_eq!(report.ops_executed.0, 300);
assert!(report.restarts.0 >= 2);
}
#[tokio::test]
async fn firehose_fanout_pristine_smoke() {
use tranquil_store::gauntlet::{EventLogConfig, MaxSegmentSize};
let cfg = GauntletConfig {
seed: Seed(1),
io: IoBackend::Simulated {
fault: FaultConfig::none(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 20,
compact: 2,
checkpoint: 3,
append_event: 60,
sync_event_log: 10,
run_retention: 5,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(100),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(60),
},
op_count: OpCount(2_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT
| InvariantSet::MONOTONIC_SEQ
| InvariantSet::FSYNC_ORDERING
| InvariantSet::TOMBSTONE_BOUND,
limits: RunLimits {
max_wall_ms: Some(WallMs(60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(500)),
store: StoreConfig {
max_file_size: MaxFileSize(16 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: Some(EventLogConfig {
max_segment_size: MaxSegmentSize(32 * 1024),
}),
writer_concurrency: WriterConcurrency(1),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
assert_eq!(report.ops_executed.0, 2_000);
assert!(report.restarts.0 >= 2);
}
#[tokio::test]
async fn contended_readers_pristine_smoke() {
let cfg = GauntletConfig {
seed: Seed(1),
io: IoBackend::Simulated {
fault: FaultConfig::none(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 20,
compact: 2,
checkpoint: 3,
read_record: 60,
read_block: 15,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(200),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(1_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT,
limits: RunLimits {
max_wall_ms: Some(WallMs(60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(250)),
store: StoreConfig {
max_file_size: MaxFileSize(16 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(16),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
assert_eq!(report.ops_executed.0, 1_000);
assert!(report.restarts.0 >= 2);
}
#[tokio::test]
async fn contended_writers_pristine_smoke() {
let cfg = GauntletConfig {
seed: Seed(2),
io: IoBackend::Simulated {
fault: FaultConfig::none(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 85,
delete: 5,
compact: 3,
checkpoint: 2,
read_record: 4,
read_block: 1,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
key_space: KeySpaceSize(500),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
op_count: OpCount(1_000),
invariants: InvariantSet::REFCOUNT_CONSERVATION
| InvariantSet::REACHABILITY
| InvariantSet::ACKED_WRITE_PERSISTENCE
| InvariantSet::READ_AFTER_WRITE
| InvariantSet::RESTART_IDEMPOTENT,
limits: RunLimits {
max_wall_ms: Some(WallMs(60_000)),
},
restart_policy: RestartPolicy::EveryNOps(OpInterval(250)),
store: StoreConfig {
max_file_size: MaxFileSize(16 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(8),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
assert_eq!(report.ops_executed.0, 1_000);
assert!(report.restarts.0 >= 2);
}
#[tokio::test]
async fn report_carries_generated_ops_when_clean() {
let cfg = fast_sanity_config(Seed(5));
let expected_len = cfg.op_count.0;
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert_clean(&report);
assert_eq!(
report.ops.len(),
expected_len,
"clean report missing op stream"
);
}
#[tokio::test]
async fn regression_round_trip_replays_injected_ops() {
let overrides = ConfigOverrides {
op_count: Some(25),
store: StoreOverrides {
max_file_size: Some(8192),
..StoreOverrides::default()
},
..ConfigOverrides::default()
};
let mut cfg = config_for(Scenario::SmokePR, Seed(99));
overrides.apply_to(&mut cfg);
let original_report = Gauntlet::new(cfg.clone())
.expect("build gauntlet")
.run()
.await;
let captured_ops = original_report.ops.clone();
assert_eq!(
captured_ops.len(),
25,
"captured op stream must match op_count override"
);
let dir = tempfile::TempDir::new().unwrap();
let record = RegressionRecord::from_report(
Scenario::SmokePR,
overrides.clone(),
&original_report,
captured_ops.len(),
captured_ops.clone(),
);
let written = record.write_to(dir.path()).expect("write regression");
let loaded = RegressionRecord::load(&written).expect("load regression");
assert_eq!(loaded.overrides, overrides);
assert_eq!(loaded.ops.len(), captured_ops.len());
let rebuilt = loaded.build_config().expect("rebuild config");
assert_eq!(rebuilt.op_count.0, 25);
assert_eq!(rebuilt.store.max_file_size.0, 8192);
let replay = Gauntlet::new(rebuilt)
.expect("build gauntlet")
.run_with_ops(loaded.op_stream())
.await;
assert_eq!(
replay.violations.len(),
original_report.violations.len(),
"replay from regression must produce same violation count",
);
let original_inv: Vec<&'static str> = original_report
.violations
.iter()
.map(|v| v.invariant)
.collect();
let replay_inv: Vec<&'static str> = replay.violations.iter().map(|v| v.invariant).collect();
assert_eq!(original_inv, replay_inv);
assert_eq!(replay.ops.len(), captured_ops.len());
}
#[tokio::test]
#[ignore = "long running, 100k ops with around 20 restarts"]
async fn mst_restart_churn_single_seed() {
let cfg = config_for(Scenario::MstRestartChurn, Seed(42));
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
assert!(
report.is_clean(),
"violations: {:?}",
report
.violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect::<Vec<_>>()
);
assert!(
report.restarts.0 >= 1,
"PoissonByOps(5000) over 100k ops should fire at least 1 restart, got {}",
report.restarts.0
);
assert_clean(&report);
assert!(report.restarts.0 >= 1);
}
@@ -0,0 +1,123 @@
use std::io::{self, BufWriter, Write};
use std::time::Duration;
use tranquil_store::gauntlet::{
LeakGateConfig, Scenario, Seed, SoakConfig, SoakReport, config_for, run_soak,
};
#[cfg(feature = "gauntlet-jemalloc-prof")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
fn soak_hours() -> Option<f64> {
std::env::var("GAUNTLET_SOAK_HOURS")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.filter(|h| h.is_finite() && *h > 0.0)
}
fn soak_sample_interval() -> Duration {
std::env::var("GAUNTLET_SOAK_SAMPLE_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|s| *s > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(60))
}
fn emitter_stream() -> Box<dyn Write + Send> {
match std::env::var("GAUNTLET_SOAK_OUTPUT").ok().as_deref() {
Some(path) if !path.is_empty() => {
let f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.expect("open GAUNTLET_SOAK_OUTPUT target");
Box::new(BufWriter::new(f))
}
_ => Box::new(BufWriter::new(io::stderr())),
}
}
fn report_summary(report: &SoakReport) -> String {
let leaks: Vec<String> = report
.leak_violations
.iter()
.map(|v| {
format!(
"{}: {} -> {} ({}% over {}ms window, limit {}%)",
v.metric,
v.start_value,
v.end_value,
v.growth_pct.round() as i64,
v.end_ms - v.start_ms,
v.limit_pct
)
})
.collect();
let invariants: Vec<String> = report
.invariant_violations
.iter()
.map(|v| format!("{}: {}", v.invariant, v.detail))
.collect();
format!(
"seed={:016x} ops={} chunks={} errors={} wall_ms={} leaks=[{}] invariants=[{}]",
report.seed.0,
report.ops_executed,
report.chunks,
report.op_errors,
report.total_wall_ms,
leaks.join(" ; "),
invariants.join(" ; "),
)
}
#[tokio::test]
async fn soak_short_smoke() {
let cfg = SoakConfig {
gauntlet: config_for(Scenario::SmokePR, Seed(7)),
total_duration: Duration::from_secs(10),
sample_interval: Duration::from_secs(2),
chunk_ops: 200,
leak_gate: LeakGateConfig::try_new(0, 60_000, 1000.0).expect("valid leak gate"),
};
let mut buf: Vec<u8> = Vec::new();
let report = run_soak(cfg, &mut buf).await.expect("soak run");
assert!(
report.samples.len() >= 3,
"expected at least initial + periodic + final samples, got {}",
report.samples.len()
);
assert!(
report.ops_executed > 0,
"expected ops executed, got {}",
report.ops_executed
);
let text = String::from_utf8(buf).expect("utf8 ndjson");
assert!(
text.contains("\"type\":\"summary\""),
"ndjson must include summary line; got {text}"
);
}
#[tokio::test]
#[ignore = "configurable via GAUNTLET_SOAK_HOURS; default 24h leak gate (1h warmup, 4h window, 5% limit)"]
async fn soak_long_leak_gate() {
let hours = soak_hours().unwrap_or(24.0);
let total = Duration::from_secs_f64(hours * 3600.0);
let cfg = SoakConfig {
gauntlet: config_for(Scenario::MstChurn, Seed(0)),
total_duration: total,
sample_interval: soak_sample_interval(),
chunk_ops: 10_000,
leak_gate: LeakGateConfig::standard(),
};
let mut emitter = emitter_stream();
let report = run_soak(cfg, &mut emitter).await.expect("soak run");
let _ = emitter.flush();
assert!(
report.is_clean(),
"soak failed: {}",
report_summary(&report)
);
}
+4
View File
@@ -227,6 +227,10 @@ fn compact_data_file_crash_safe_old_file_survives() {
.collect();
store.put_blocks_blocking(padding).unwrap();
store
.put_blocks_blocking(vec![(test_cid(220), vec![220u8; 64])])
.unwrap();
let files = store.list_data_files().unwrap();
let first_file = files[0];
@@ -41,8 +41,8 @@ async fn compute_obsolete_from_diff<S: BlockStore + Sync + Send + 'static>(
) -> Vec<Cid> {
let diff = old_mst.diff(new_mst).await.unwrap();
std::iter::once(old_commit_cid)
.chain(diff.removed_mst_blocks.into_iter())
.chain(diff.removed_cids.into_iter())
.chain(diff.removed_mst_blocks)
.chain(diff.removed_cids)
.collect()
}
+3 -3
View File
@@ -2,8 +2,8 @@ use proptest::prelude::*;
use std::path::Path;
use tranquil_store::{
FaultConfig, HEADER_SIZE, OpenOptions, ReadRecord, RecordReader, RecordWriter, SimulatedIO,
StorageIO, run_crash_test, run_pristine_comparison, sim_proptest_cases,
FaultConfig, HEADER_SIZE, OpenOptions, Probability, ReadRecord, RecordReader, RecordWriter,
SimulatedIO, StorageIO, run_crash_test, run_pristine_comparison, sim_proptest_cases,
};
fn arb_payloads(max_count: usize, max_size: usize) -> BoxedStrategy<Vec<Vec<u8>>> {
@@ -151,7 +151,7 @@ proptest! {
data in proptest::collection::vec(any::<u8>(), 64..4096),
) {
let config = FaultConfig {
partial_write_probability: 0.5,
partial_write_probability: Probability::new(0.5),
..FaultConfig::none()
};
let dir = Path::new("/test");
@@ -0,0 +1,237 @@
mod common;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tranquil_store::blockstore::{
BlockStoreConfig, DataFileId, DataFileManager, DataFileWriter, GroupCommitConfig,
TranquilBlockStore,
};
use tranquil_store::{FileId, MappedFile, OpenOptions, RealIO, SimulatedIO, StorageIO};
use common::{test_cid, with_runtime};
struct FailSpec {
target_path: Mutex<Option<PathBuf>>,
armed: AtomicBool,
tripped: AtomicBool,
}
impl FailSpec {
fn new() -> Self {
Self {
target_path: Mutex::new(None),
armed: AtomicBool::new(false),
tripped: AtomicBool::new(false),
}
}
fn arm_fail_first_sync_on(&self, path: &Path) {
*self.target_path.lock().unwrap() = Some(path.to_path_buf());
self.tripped.store(false, Ordering::SeqCst);
self.armed.store(true, Ordering::SeqCst);
}
fn fired(&self) -> bool {
self.tripped.load(Ordering::SeqCst)
}
}
struct FailingIO {
inner: RealIO,
spec: Arc<FailSpec>,
fd_to_path: Mutex<HashMap<FileId, PathBuf>>,
}
impl FailingIO {
fn new(spec: Arc<FailSpec>) -> Self {
Self {
inner: RealIO::new(),
spec,
fd_to_path: Mutex::new(HashMap::new()),
}
}
}
impl StorageIO for FailingIO {
fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> {
let fd = self.inner.open(path, opts)?;
self.fd_to_path
.lock()
.unwrap()
.insert(fd, path.to_path_buf());
Ok(fd)
}
fn close(&self, fd: FileId) -> io::Result<()> {
self.fd_to_path.lock().unwrap().remove(&fd);
self.inner.close(fd)
}
fn read_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
self.inner.read_at(fd, offset, buf)
}
fn write_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> {
self.inner.write_at(fd, offset, buf)
}
fn sync(&self, fd: FileId) -> io::Result<()> {
let should_fail = self.spec.armed.load(Ordering::SeqCst)
&& !self.spec.tripped.load(Ordering::SeqCst)
&& match (
self.fd_to_path.lock().unwrap().get(&fd).cloned(),
self.spec.target_path.lock().unwrap().clone(),
) {
(Some(fd_path), Some(target)) => fd_path == target,
_ => false,
};
match should_fail {
true => {
self.spec.tripped.store(true, Ordering::SeqCst);
Err(io::Error::other("injected sync failure on target path"))
}
false => self.inner.sync(fd),
}
}
fn file_size(&self, fd: FileId) -> io::Result<u64> {
self.inner.file_size(fd)
}
fn truncate(&self, fd: FileId, size: u64) -> io::Result<()> {
self.inner.truncate(fd, size)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.inner.rename(from, to)
}
fn delete(&self, path: &Path) -> io::Result<()> {
self.inner.delete(path)
}
fn mkdir(&self, path: &Path) -> io::Result<()> {
self.inner.mkdir(path)
}
fn sync_dir(&self, path: &Path) -> io::Result<()> {
self.inner.sync_dir(path)
}
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
self.inner.list_dir(path)
}
fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> {
self.inner.mmap_file(fd)
}
}
#[test]
fn post_rotation_sync_failure_deletes_new_rotation_files() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let data_dir = dir.path().join("data");
let index_dir = dir.path().join("index");
let spec = Arc::new(FailSpec::new());
let spec_for_factory = Arc::clone(&spec);
let config = BlockStoreConfig {
data_dir: data_dir.clone(),
index_dir,
max_file_size: 256,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let store = TranquilBlockStore::<FailingIO>::open_with_io(config, move || {
FailingIO::new(Arc::clone(&spec_for_factory))
})
.unwrap();
store
.put_blocks_blocking(vec![(test_cid(1), vec![0xAA; 300])])
.expect("priming put succeeds");
let rotated_data_path = data_dir.join("000002.tqb");
let rotated_hint_path = data_dir.join("000002.tqh");
spec.arm_fail_first_sync_on(&rotated_data_path);
let result = store.put_blocks_blocking(vec![(test_cid(2), vec![0xBB; 300])]);
assert!(
result.is_err(),
"put_blocks_blocking should surface the injected post-rotation sync failure"
);
assert!(
spec.fired(),
"injector never observed a sync on the rotated data file; timing changed"
);
assert!(
!rotated_data_path.exists(),
"rotation rollback must delete the new data file after a post-write sync failure; \
leaked file at {rotated_data_path:?}"
);
assert!(
!rotated_hint_path.exists(),
"rotation rollback must delete the new hint file after a post-write sync failure; \
leaked file at {rotated_hint_path:?}"
);
});
}
#[test]
fn concurrent_reader_survives_evict_handle() {
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0x13579bdf));
let data_dir = Path::new("/data");
sim.mkdir(data_dir).unwrap();
sim.sync_dir(data_dir).unwrap();
let manager = Arc::new(DataFileManager::new(
Arc::clone(&sim),
data_dir.to_path_buf(),
1 << 20,
));
let file_id = DataFileId::new(0);
let write_handle = manager.open_for_append(file_id).unwrap();
{
let mut writer = DataFileWriter::new(&*sim, write_handle.fd(), file_id).unwrap();
let _ = writer.append_block(&test_cid(1), &[0x11; 128]).unwrap();
writer.sync().unwrap();
}
drop(write_handle);
let ready_to_evict = Arc::new(std::sync::Barrier::new(2));
let evict_done = Arc::new(std::sync::Barrier::new(2));
let reader_manager = Arc::clone(&manager);
let reader_io = Arc::clone(&sim);
let reader_ready = Arc::clone(&ready_to_evict);
let reader_done = Arc::clone(&evict_done);
let reader = std::thread::spawn(move || {
let read_handle = reader_manager.open_for_read(file_id).unwrap();
reader_ready.wait();
reader_done.wait();
reader_io.file_size(read_handle.fd())
});
ready_to_evict.wait();
manager.evict_handle(file_id);
evict_done.wait();
let read_result = reader.join().unwrap();
assert!(
read_result.is_ok(),
"read against a FileId obtained before evict_handle must still succeed; \
evict_handle closed the underlying fd while the reader held it. error: {:?}",
read_result.err()
);
}
+10 -8
View File
@@ -35,7 +35,8 @@ impl SimHarness {
Arc::clone(&self.sim),
self.data_dir.to_path_buf(),
);
let fd = manager.open_for_append(file_id).unwrap();
let handle = manager.open_for_append(file_id).unwrap();
let fd = handle.fd();
let file_size = self.sim.file_size(fd).unwrap();
match file_size {
0 => {
@@ -504,9 +505,10 @@ fn sim_aggressive_faults_data_integrity() {
let manager =
DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf());
let Ok(fd) = manager.open_for_append(file_id) else {
let Ok(handle) = manager.open_for_append(file_id) else {
return;
};
let fd = handle.fd();
let writer_result = DataFileWriter::new(&*sim, fd, file_id);
let Ok(writer) = writer_result else { return };
@@ -515,7 +517,8 @@ fn sim_aggressive_faults_data_integrity() {
return;
};
let start_pos = writer.position();
let _ = sim.close(fd);
let _ = writer;
let _ = handle;
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(15) + 5) as u16;
@@ -654,14 +657,13 @@ fn sim_multi_file_rotation_crash_recovery() {
};
let block_count = ((seed % 25) + 10) as u32;
let blocks: Vec<(CidBytes, Vec<u8>)> = (0..block_count)
.map(|i| (test_cid(i), block_data(i)))
.collect();
let all_cids: Vec<CidBytes> = blocks.iter().map(|(c, _)| *c).collect();
let all_cids: Vec<CidBytes> = (0..block_count).map(test_cid).collect();
{
let store = TranquilBlockStore::open(config.clone()).unwrap();
store.put_blocks_blocking(blocks).unwrap();
(0..block_count)
.try_for_each(|i| store.put_blocks_blocking(vec![(test_cid(i), block_data(i))]))
.unwrap();
let files = store.list_data_files().unwrap();
assert!(
+29 -21
View File
@@ -8,7 +8,7 @@ use tranquil_store::eventlog::{
DidHash, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD,
SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, ValidEvent,
};
use tranquil_store::{FaultConfig, SimulatedIO, StorageIO, sim_seed_range};
use tranquil_store::{FaultConfig, Probability, SimulatedIO, StorageIO, sim_seed_range};
use common::Rng;
@@ -39,7 +39,8 @@ fn read_all_events(mgr: &SegmentManager<SimulatedIO>, seed: u64) -> Vec<ValidEve
.flat_map(|&seg_id| {
let fd = mgr
.open_for_read(seg_id)
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read({seg_id}) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read({seg_id}) failed: {e}"))
.fd();
SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!(
@@ -256,7 +257,8 @@ fn segment_deletion_does_not_corrupt_neighbors() {
let seg2_fd = mgr
.open_for_read(SegmentId::new(2))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(2) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(2) failed: {e}"))
.fd();
let seg2_events = SegmentReader::open(mgr.io(), seg2_fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(2, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -271,7 +273,8 @@ fn segment_deletion_does_not_corrupt_neighbors() {
let seg3_fd = mgr
.open_for_read(SegmentId::new(3))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(3) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(3) failed: {e}"))
.fd();
let seg3_events = SegmentReader::open(mgr.io(), seg3_fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(3, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -388,7 +391,8 @@ fn fsync_ordering_unsynced_events_never_durable() {
let fd = mgr
.open_for_read(SegmentId::new(1))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"))
.fd();
let recovered = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -458,7 +462,8 @@ fn fsync_ordering_proof_sync_before_blockstore_ack() {
let fd = mgr
.open_for_read(SegmentId::new(1))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"))
.fd();
let recovered = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -529,8 +534,8 @@ fn group_sync_crash_after_append_before_sync() {
fn group_sync_crash_mid_sync_partial_fsync() {
sim_seed_range().into_par_iter().for_each(|seed| {
let fault_config = FaultConfig {
sync_failure_probability: 0.3,
partial_write_probability: 0.1,
sync_failure_probability: Probability::new(0.3),
partial_write_probability: Probability::new(0.1),
..FaultConfig::none()
};
let sim = SimulatedIO::new(seed, fault_config);
@@ -579,7 +584,8 @@ fn group_sync_crash_mid_sync_partial_fsync() {
let fd = mgr
.open_for_read(SegmentId::new(1))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"))
.fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -652,7 +658,8 @@ fn group_sync_no_double_sync_no_skipped_events() {
let fd = mgr
.open_for_read(SegmentId::new(1))
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}"))
.fd();
let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD)
.unwrap_or_else(|e| {
panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}")
@@ -682,9 +689,9 @@ fn group_sync_no_double_sync_no_skipped_events() {
fn group_sync_contention_under_faults() {
sim_seed_range().into_par_iter().for_each(|seed| {
let fault_config = FaultConfig {
partial_write_probability: 0.05,
sync_failure_probability: 0.10,
dir_sync_failure_probability: 0.05,
partial_write_probability: Probability::new(0.05),
sync_failure_probability: Probability::new(0.10),
dir_sync_failure_probability: Probability::new(0.05),
..FaultConfig::none()
};
let sim = SimulatedIO::new(seed, fault_config);
@@ -897,12 +904,11 @@ fn multi_rotation_crash_at_each_phase() {
#[test]
fn aggressive_faults_group_sync_recovery() {
let fault_config = FaultConfig {
partial_write_probability: 0.15,
sync_failure_probability: 0.10,
dir_sync_failure_probability: 0.05,
misdirected_write_probability: 0.05,
bit_flip_on_read_probability: 0.0,
io_error_probability: 0.0,
partial_write_probability: Probability::new(0.15),
sync_failure_probability: Probability::new(0.10),
dir_sync_failure_probability: Probability::new(0.05),
misdirected_write_probability: Probability::new(0.05),
..FaultConfig::none()
};
sim_seed_range().into_par_iter().for_each(|seed| {
@@ -939,7 +945,8 @@ fn aggressive_faults_group_sync_recovery() {
let pristine_fd = pristine_mgr
.open_for_read(SegmentId::new(1))
.unwrap_or_else(|e| panic!("seed {seed}: pristine open_for_read(1) failed: {e}"));
.unwrap_or_else(|e| panic!("seed {seed}: pristine open_for_read(1) failed: {e}"))
.fd();
let pristine_events = SegmentReader::open(
pristine_mgr.io(),
pristine_fd,
@@ -986,9 +993,10 @@ fn aggressive_faults_group_sync_recovery() {
return;
}
let Ok(fd) = mgr.open_for_read(SegmentId::new(1)) else {
let Ok(handle) = mgr.open_for_read(SegmentId::new(1)) else {
return;
};
let fd = handle.fd();
let Ok(reader) = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) else {
return;
};
@@ -0,0 +1,130 @@
mod common;
use std::sync::Arc;
use tranquil_store::OpenOptions;
use tranquil_store::RealIO;
use tranquil_store::StorageIO;
use tranquil_store::blockstore::BlockLength;
use tranquil_store::blockstore::{
BlockLocation, BlockOffset, BlockStoreConfig, DataFileId, DataFileManager, DataFileWriter,
GroupCommitConfig, HintFileWriter, HintOffset, TranquilBlockStore, hint_file_path,
};
use common::{test_cid, with_runtime};
fn fresh_store_dir() -> (tempfile::TempDir, BlockStoreConfig) {
let dir = tempfile::TempDir::new().unwrap();
let data_dir = dir.path().join("data");
let index_dir = dir.path().join("index");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::create_dir_all(&index_dir).unwrap();
let config = BlockStoreConfig {
data_dir,
index_dir,
max_file_size: 8192,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
(dir, config)
}
fn hint_file_size(path: &std::path::Path) -> u64 {
let io = RealIO::new();
let fd = io.open(path, OpenOptions::read_write()).unwrap();
let size = io.file_size(fd).unwrap();
let _ = io.close(fd);
size
}
#[test]
fn rollback_rotation_does_not_leave_orphan_data_file() {
with_runtime(|| {
let (_dir, config) = fresh_store_dir();
let data_dir = config.data_dir.clone();
{
let store = TranquilBlockStore::open(config.clone()).unwrap();
store
.put_blocks_blocking(vec![(test_cid(1), vec![0x11; 64])])
.unwrap();
drop(store);
}
let orphan_cid = test_cid(99_999);
{
let io: Arc<RealIO> = Arc::new(RealIO::new());
let manager = DataFileManager::new(Arc::clone(&io), data_dir.clone(), 4096);
let (next_id, next_handle) = manager.prepare_rotation(DataFileId::new(0)).unwrap();
manager.commit_rotation(next_id, &next_handle);
let mut writer = DataFileWriter::new(&*io, next_handle.fd(), next_id).unwrap();
let _ = writer.append_block(&orphan_cid, &vec![0xAB; 256]).unwrap();
writer.sync().unwrap();
io.sync_dir(&data_dir).unwrap();
let _ = io.delete(&hint_file_path(&data_dir, next_id));
let _ = writer;
let _ = next_handle;
manager.rollback_rotation(next_id);
}
let store = TranquilBlockStore::open(config).unwrap();
assert!(
store.get_block_sync(&orphan_cid).unwrap().is_none(),
"rollback_rotation must delete the uncommitted data file; otherwise recovery's \
backup-restore branch resurrects rejected blocks"
);
});
}
#[test]
fn truncated_old_hint_drops_rejected_entry_on_reopen() {
with_runtime(|| {
let (_dir, config) = fresh_store_dir();
let data_dir = config.data_dir.clone();
let old_file_id = DataFileId::new(0);
let old_hint_path = hint_file_path(&data_dir, old_file_id);
let keep_cid = test_cid(1);
{
let store = TranquilBlockStore::open(config.clone()).unwrap();
store
.put_blocks_blocking(vec![(keep_cid, vec![0x11; 64])])
.unwrap();
drop(store);
}
let hint_len_before = hint_file_size(&old_hint_path);
let rejected_cid = test_cid(42_424);
{
let io: Arc<RealIO> = Arc::new(RealIO::new());
let fd = io.open(&old_hint_path, OpenOptions::read_write()).unwrap();
let mut writer = HintFileWriter::resume(&*io, fd, HintOffset::new(hint_len_before));
writer
.append_hint(
&rejected_cid,
&BlockLocation {
file_id: old_file_id,
offset: BlockOffset::new(4096),
length: BlockLength::new(64),
},
)
.unwrap();
writer.sync().unwrap();
io.truncate(fd, hint_len_before).unwrap();
io.sync(fd).unwrap();
let _ = io.close(fd);
}
let store = TranquilBlockStore::open(config).unwrap();
assert!(
store.get_block_sync(&rejected_cid).unwrap().is_none(),
"after rollback_batch truncates state.hint_fd, the rejected hint is gone and reopen is clean"
);
assert!(
store.get_block_sync(&keep_cid).unwrap().is_some(),
"legitimate pre-batch block remains readable after rollback"
);
});
}
+1 -1
View File
@@ -238,7 +238,7 @@ async fn get_repo_since(state: &AppState, did: &Did, head_cid: &Cid, since: &str
chunk
.iter()
.zip(blocks.into_iter())
.zip(blocks)
.filter_map(|(cid, block_opt)| block_opt.map(|block| (*cid, block)))
.for_each(|(cid, block)| car_bytes.extend_from_slice(&encode_car_block(&cid, &block)));
}
+70
View File
@@ -0,0 +1,70 @@
# Tranquil PDS production installation on NixOS
This guide covers installing Tranquil PDS on NixOS via the flake and the bundled NixOS module.
## Prerequisites
- A server :p
- Disk space enough for blobs (depends on usage; plan for ~1GB per active user as a baseline)
- A domain name pointing to your server's IP
- A wildcard TLS certificate for `*.pds.example.com` (user handles are served as subdomains)
- Flakes enabled (`experimental-features = nix-command flakes` in `nix.conf`)
## Add the flake as an input
In your system flake:
```nix
{
inputs.tranquil.url = "git+https://tangled.org/tranquil.farm/tranquil-pds";
outputs = { self, nixpkgs, tranquil, ... }: {
nixosConfigurations.pds = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
tranquil.nixosModules.default
./configuration.nix
];
};
};
}
```
## Enable the service
In `configuration.nix`:
```nix
{
services.tranquil-pds = {
enable = true;
database.createLocally = true;
settings = {
server.hostname = "pds.example.com";
# see example.toml for all options
};
};
}
```
This will set up the local postgres database for you automatically. If you prefer to manage postgres yourself, leave `database.createLocally` at its default (`false`) and set `settings.database.url` manually.
See [example.toml](../example.toml) for the full set of configuration options.
## Binary cache
Pre-built artifacts from the flake — the package, frontend, and devshell — are published to [tranquil.cachix.org](https://tranquil.cachix.org). To pull from it instead of building locally, add to your NixOS config:
```nix
nix.settings = {
substituters = [ "https://tranquil.cachix.org" ];
trusted-public-keys = [ "tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg=" ];
};
```
> [!NOTE]
> Due to a current spindle limitation, the aarch64 package is cross-compiled on an x86_64 builder and published under a separate attribute. If you're running on aarch64, set the package manually:
>
> ```nix
> services.tranquil-pds.package = inputs.tranquil.packages.x86_64-linux.tranquil-pds-aarch64;
> ```
+1 -1
View File
@@ -1,5 +1,5 @@
[server]
# Public hostname of the PDS (e.g. `pds.example.com`).
# Public hostname of the PDS, such as `pds.example.com`.
#
# Can also be specified via environment variable `PDS_HOSTNAME`.
#
+63
View File
@@ -25,6 +25,69 @@ test-store:
test-store-sim-nightly:
SQLX_OFFLINE=true TRANQUIL_SIM_SEEDS=10000 cargo nextest run -p tranquil-store --features tranquil-store/test-harness --profile sim-nightly
gauntlet-pr:
SQLX_OFFLINE=true cargo nextest run -p tranquil-store --features tranquil-store/test-harness --profile gauntlet-pr --test gauntlet_smoke
gauntlet-nightly HOURS="6":
SQLX_OFFLINE=true GAUNTLET_DURATION_HOURS={{HOURS}} cargo nextest run -p tranquil-store --features tranquil-store/test-harness --profile gauntlet-nightly --test gauntlet_smoke --run-ignored all
gauntlet-farm SCENARIO HOURS="6" DUMP="proptest-regressions":
SQLX_OFFLINE=true cargo run --release -p tranquil-store --bin tranquil-gauntlet --features tranquil-store/gauntlet-cli -- farm --scenario {{SCENARIO}} --hours {{HOURS}} --dump-regressions {{DUMP}}
gauntlet-repro SEED SCENARIO="smoke-pr":
SQLX_OFFLINE=true cargo run --release -p tranquil-store --bin tranquil-gauntlet --features tranquil-store/gauntlet-cli -- repro --scenario {{SCENARIO}} --seed {{SEED}}
gauntlet-repro-config CONFIG SEED:
SQLX_OFFLINE=true cargo run --release -p tranquil-store --bin tranquil-gauntlet --features tranquil-store/gauntlet-cli -- repro --config {{CONFIG}} --seed {{SEED}}
gauntlet-repro-from FILE:
SQLX_OFFLINE=true cargo run --release -p tranquil-store --bin tranquil-gauntlet --features tranquil-store/gauntlet-cli -- repro --from {{FILE}}
gauntlet-sweep CONFIG SEEDS="8" DUMP="proptest-regressions":
SQLX_OFFLINE=true cargo run --release -p tranquil-store --bin tranquil-gauntlet --features tranquil-store/gauntlet-cli -- sweep --config {{CONFIG}} --seeds {{SEEDS}} --dump-regressions {{DUMP}}
gauntlet-soak HOURS="24" OUTPUT="":
SQLX_OFFLINE=true GAUNTLET_SOAK_HOURS={{HOURS}} GAUNTLET_SOAK_OUTPUT={{OUTPUT}} cargo nextest run -p tranquil-store --features tranquil-store/test-harness --profile gauntlet-soak --test gauntlet_soak --run-ignored all -- soak_long_leak_gate
gauntlet-soak-heapprof HOURS="24" OUTPUT="" PREFIX="jeprof.gauntlet":
SQLX_OFFLINE=true \
GAUNTLET_SOAK_HOURS={{HOURS}} \
GAUNTLET_SOAK_OUTPUT={{OUTPUT}} \
MALLOC_CONF="prof:true,prof_active:true,prof_final:true,lg_prof_sample:19,prof_prefix:{{PREFIX}}" \
cargo nextest run -p tranquil-store \
--features tranquil-store/test-harness,tranquil-store/gauntlet-jemalloc-prof \
--profile gauntlet-soak --test gauntlet_soak --run-ignored all -- soak_long_leak_gate
gauntlet-flaky SEED="1":
SQLX_OFFLINE=true cargo nextest run -p tranquil-store --features tranquil-store/test-harness --test gauntlet_flaky --run-ignored all
fuzz-target TARGET SECONDS="60" SANITIZER="address":
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer {{SANITIZER}} {{TARGET}} -- -max_total_time={{SECONDS}}
fuzz-pr SECONDS="60":
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address decode_block_record -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address decode_hint_record -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address segment_scan -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address metastore_key_codec -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address gauntlet_micro -- -max_total_time={{SECONDS}}
fuzz-nightly SECONDS="21600":
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address decode_block_record -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address decode_hint_record -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address segment_scan -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address metastore_key_codec -- -max_total_time={{SECONDS}}
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer address gauntlet_micro -- -max_total_time={{SECONDS}}
fuzz-ubsan TARGET SECONDS="60":
cd crates/tranquil-store/fuzz && cargo +nightly fuzz run --sanitizer undefined {{TARGET}} -- -max_total_time={{SECONDS}}
test-store-asan:
SQLX_OFFLINE=true \
ASAN_OPTIONS="halt_on_error=1:abort_on_error=1:detect_leaks=1" \
RUSTFLAGS="-Zsanitizer=address" \
RUSTDOCFLAGS="-Zsanitizer=address" \
cargo +nightly nextest run -p tranquil-store --features tranquil-store/test-harness --target x86_64-unknown-linux-gnu
test-unit:
SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases