Compare commits

..
Author SHA1 Message Date
nelind 28f2735023 feat(test): test that websockets get closed 2026-04-28 22:15:08 +02:00
26 changed files with 932 additions and 2506 deletions
-8
View File
@@ -68,10 +68,6 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -122,10 +118,6 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
Generated
+734 -537
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.0"
version = "0.5.7"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -93,7 +93,6 @@ ipld-core = "0.4"
iroh-car = "0.5"
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
jacquard-repo = "0.9"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
metrics = "0.24"
@@ -106,8 +105,6 @@ p384 = { version = "0.13", features = ["ecdsa"] }
rand = "0.8"
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
secrecy = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
-11
View File
@@ -10,14 +10,7 @@ tranquil-signal = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hickory-resolver = { workspace = true }
lettre = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
rsa = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
@@ -25,7 +18,3 @@ tokio = { workspace = true }
tracing = { workspace = true }
tranquil-db-traits = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
chrono = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
-291
View File
@@ -1,291 +0,0 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("empty value")]
Empty,
#[error("invalid character {0:?}")]
InvalidChar(char),
#[error("zero {0}")]
Zero(&'static str),
#[error("invalid TLS mode {0:?}")]
InvalidTlsMode(String),
}
fn parse_token(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result<String, ParseError> {
let mut s = raw.trim();
if strip_trailing_dot {
s = s.trim_end_matches('.');
}
match s {
"" => Err(ParseError::Empty),
_ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')),
_ => Ok(match lowercase {
true => s.to_lowercase(),
false => s.to_string(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SmtpHost(String);
impl SmtpHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SmtpPort(u16);
impl SmtpPort {
pub fn parse(raw: u16) -> Result<Self, ParseError> {
match raw {
0 => Err(ParseError::Zero("smtp port")),
n => Ok(Self(n)),
}
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HeloName(String);
impl HeloName {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, false, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EmailDomain(String);
impl EmailDomain {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MxHost(String);
impl MxHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MxPriority(u16);
impl MxPriority {
pub fn new(value: u16) -> Self {
Self(value)
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct MxRecord {
pub priority: MxPriority,
pub host: MxHost,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DkimSelector(String);
impl DkimSelector {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain);
match valid {
true => Ok(Self(trimmed.to_string())),
false => Err(ParseError::InvalidChar('?')),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
fn valid_subdomain(seg: &str) -> bool {
let starts_alnum = seg
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
let ends_alnum = seg
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
starts_alnum && ends_alnum && body_ok
}
#[derive(Debug, Clone)]
pub struct DkimKeyPath(PathBuf);
impl DkimKeyPath {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
match trimmed.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(PathBuf::from(trimmed))),
}
}
pub fn as_path(&self) -> &std::path::Path {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmtpUsername(String);
impl SmtpUsername {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(raw.to_string())),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct SmtpPassword(secrecy::SecretString);
impl SmtpPassword {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(secrecy::SecretString::from(raw.to_string()))),
}
}
pub fn expose(&self) -> &str {
use secrecy::ExposeSecret;
self.0.expose_secret()
}
}
impl std::fmt::Debug for SmtpPassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SmtpPassword(***)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsMode {
Implicit,
Starttls,
None,
}
impl TlsMode {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.to_ascii_lowercase().as_str() {
"implicit" => Ok(Self::Implicit),
"starttls" => Ok(Self::Starttls),
"none" => Ok(Self::None),
other => Err(ParseError::InvalidTlsMode(other.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smtp_host_lowercases_and_trims() {
let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap();
assert_eq!(h.as_str(), "smtp.nel.pet");
}
#[test]
fn smtp_host_rejects_whitespace() {
assert!(SmtpHost::parse("a b").is_err());
}
#[test]
fn smtp_host_rejects_empty() {
assert!(SmtpHost::parse("").is_err());
assert!(SmtpHost::parse(" ").is_err());
}
#[test]
fn smtp_port_rejects_zero() {
assert!(SmtpPort::parse(0).is_err());
assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587);
}
#[test]
fn email_domain_strips_trailing_dot() {
assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet");
}
#[test]
fn dkim_selector_validates() {
assert!(DkimSelector::parse("default").is_ok());
assert!(DkimSelector::parse("s1.nel.pet").is_ok());
assert!(DkimSelector::parse("s2024-q1").is_ok());
assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok());
assert!(DkimSelector::parse("a-b").is_ok());
assert!(DkimSelector::parse("").is_err());
assert!(DkimSelector::parse("a..b").is_err());
assert!(DkimSelector::parse("-leading").is_err());
assert!(DkimSelector::parse("trailing-").is_err());
assert!(DkimSelector::parse("s_under").is_err());
}
#[test]
fn tls_mode_parses_known_modes() {
assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls);
assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit);
assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None);
assert!(TlsMode::parse("garbage").is_err());
}
#[test]
fn smtp_password_redacts_in_debug() {
let p = SmtpPassword::parse("hunter2").unwrap();
let dbg = format!("{:?}", p);
assert_eq!(dbg, "SmtpPassword(***)");
assert!(!dbg.contains("hunter2"));
}
}
+3 -248
View File
@@ -5,14 +5,6 @@ use std::sync::OnceLock;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
const REMOVED_ENV_VARS: &[(&str, &str)] = &[(
"SENDMAIL_PATH",
"the sendmail-binary transport was replaced with native SMTP. \
Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \
deliver directly via recipient MX records. See example.toml for the full \
MAIL_* surface.",
)];
/// Errors discovered during configuration validation.
#[derive(Debug)]
pub struct ConfigError {
@@ -170,14 +162,6 @@ impl TranquilConfig {
pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> {
let mut errors = Vec::new();
// -- removed config ---------------------------------------------------
errors.extend(
REMOVED_ENV_VARS
.iter()
.filter(|(var, _)| std::env::var_os(var).is_some())
.map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")),
);
// -- secrets ----------------------------------------------------------
if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) {
if let Some(ref s) = self.secrets.jwt_secret {
@@ -226,85 +210,6 @@ impl TranquilConfig {
}
}
// -- email smarthost --------------------------------------------------
match self.email.smarthost.tls.to_ascii_lowercase().as_str() {
"implicit" | "starttls" => {}
"none" => {
if self.email.smarthost.password.is_some() {
errors.push(
"email.smarthost.tls = \"none\" with email.smarthost.password set \
would transmit credentials in plaintext; use \"starttls\" or \"implicit\""
.to_string(),
);
}
}
other => errors.push(format!(
"email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\""
)),
}
let smarthost_host_set = self
.email
.smarthost
.host
.as_deref()
.is_some_and(|h| !h.is_empty());
let username_set = self.email.smarthost.username.is_some();
let password_set = self.email.smarthost.password.is_some();
if !smarthost_host_set && (username_set || password_set) {
errors.push(
"email.smarthost.username or email.smarthost.password is set but \
email.smarthost.host is empty; credentials would be silently ignored"
.to_string(),
);
}
if smarthost_host_set && username_set != password_set {
errors.push(
"email.smarthost.username and email.smarthost.password must both be set or \
both unset; otherwise authentication would silently degrade to anonymous"
.to_string(),
);
}
if self.email.smarthost.command_timeout_secs == 0 {
errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string());
}
if self.email.smarthost.total_timeout_secs == 0 {
errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string());
}
if self.email.smarthost.pool_size == 0 {
errors.push("email.smarthost.pool_size must be at least 1".to_string());
}
if self.email.direct_mx.max_concurrent_sends == 0 {
errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string());
}
if self.email.direct_mx.command_timeout_secs == 0 {
errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string());
}
if self.email.direct_mx.total_timeout_secs == 0 {
errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string());
}
let dkim_set = self.email.dkim.selector.is_some()
|| self.email.dkim.domain.is_some()
|| self.email.dkim.private_key_path.is_some();
if dkim_set {
if self.email.dkim.selector.is_none() {
errors
.push("email.dkim.selector is required when any DKIM field is set".to_string());
}
if self.email.dkim.domain.is_none() {
errors.push("email.dkim.domain is required when any DKIM field is set".to_string());
}
if self.email.dkim.private_key_path.is_none() {
errors.push(
"email.dkim.private_key_path is required when any DKIM field is set"
.to_string(),
);
}
}
// -- telegram ---------------------------------------------------------
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
errors.push(
@@ -849,98 +754,9 @@ pub struct EmailConfig {
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
pub from_name: String,
/// HELO/EHLO name announced to remote SMTP servers. Applies to both
/// smarthost and direct-MX modes. Defaults to the server hostname.
#[config(env = "MAIL_HELO_NAME")]
pub helo_name: Option<String>,
#[config(nested)]
pub smarthost: SmarthostConfig,
#[config(nested)]
pub direct_mx: DirectMxConfig,
#[config(nested)]
pub dkim: DkimConfig,
}
#[derive(Debug, Config)]
pub struct SmarthostConfig {
/// SMTP relay host. When set, mail is delivered through this host
/// instead of resolving recipient MX records directly.
#[config(env = "MAIL_SMARTHOST_HOST")]
pub host: Option<String>,
/// SMTP relay port.
#[config(env = "MAIL_SMARTHOST_PORT", default = 587)]
pub port: u16,
/// SMTP authentication username.
#[config(env = "MAIL_SMARTHOST_USERNAME")]
pub username: Option<String>,
/// SMTP authentication password.
#[config(env = "MAIL_SMARTHOST_PASSWORD")]
pub password: Option<String>,
/// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
/// alongside a password is rejected at startup to prevent transmitting
/// credentials in plaintext.
#[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")]
pub tls: String,
/// Max size of the connection pool.
#[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)]
pub pool_size: u32,
/// Per-command SMTP timeout in seconds. Bounds the security handshake.
#[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)]
pub command_timeout_secs: u64,
/// Total per-message timeout in seconds. Wraps the entire send so a
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
}
#[derive(Debug, Config)]
pub struct DirectMxConfig {
/// Per-command SMTP timeout in seconds.
#[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)]
pub command_timeout_secs: u64,
/// Total per-message timeout across all MX attempts in seconds.
#[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Max number of concurrent direct-MX sends. Limits the load placed
/// on any single recipient MX during a backlog drain.
#[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)]
pub max_concurrent_sends: usize,
/// Require STARTTLS on every MX hop. When false, TLS is
/// attempted opportunistically and the session falls back to plaintext
/// if the remote does not advertise STARTTLS. Set true to refuse
/// plaintext delivery, at the cost of failing sends to MX hosts that
/// do not support TLS.
#[config(env = "MAIL_REQUIRE_TLS", default = false)]
pub require_tls: bool,
}
#[derive(Debug, Config)]
pub struct DkimConfig {
/// DKIM selector. When unset, outgoing mail is not signed.
#[config(env = "MAIL_DKIM_SELECTOR")]
pub selector: Option<String>,
/// DKIM signing domain.
#[config(env = "MAIL_DKIM_DOMAIN")]
pub domain: Option<String>,
/// Path to the DKIM private key in PEM format. Supports RSA and
/// Ed25519 keys.
#[config(env = "MAIL_DKIM_KEY_PATH")]
pub private_key_path: Option<String>,
/// Path to the `sendmail` binary.
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
pub sendmail_path: String,
}
#[derive(Debug, Config)]
@@ -1380,64 +1196,3 @@ pub struct TranquilStoreConfig {
pub fn template() -> String {
confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())
}
#[cfg(test)]
mod tests {
use super::*;
fn seed_required_env() {
let required = [
("PDS_HOSTNAME", "test.local"),
("DATABASE_URL", "postgres://localhost/test"),
("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"),
("INVITE_CODE_REQUIRED", "false"),
("ENABLE_PDS_HOSTED_DID_WEB", "true"),
("TRANQUIL_LEXICON_OFFLINE", "1"),
];
required
.iter()
.filter(|(k, _)| std::env::var_os(k).is_none())
.for_each(|(k, v)| unsafe { std::env::set_var(k, v) });
}
#[test]
fn serial_validate_rejects_legacy_sendmail_path() {
seed_required_env();
unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") };
let config = TranquilConfig::builder()
.env()
.load()
.expect("load fresh config");
let result = config.validate(true);
unsafe { std::env::remove_var("SENDMAIL_PATH") };
let err = result.expect_err("validate must reject SENDMAIL_PATH");
let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH"));
assert!(
mentions_sendmail,
"errors did not mention SENDMAIL_PATH: {:?}",
err.errors
);
}
#[test]
fn serial_validate_passes_when_no_legacy_env_set() {
seed_required_env();
unsafe { std::env::remove_var("SENDMAIL_PATH") };
let config = TranquilConfig::builder()
.env()
.load()
.expect("load fresh config");
let result = config.validate(true);
let leaked_legacy = result
.as_ref()
.err()
.map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH")))
.unwrap_or(false);
assert!(
!leaked_legacy,
"validate spuriously flagged SENDMAIL_PATH when unset: {:?}",
result
);
}
}
+7 -10
View File
@@ -523,16 +523,13 @@ fn wire_tranquil_store(
let metastore =
Metastore::open(&metastore_dir, metastore_config).expect("failed to open metastore");
let blockstore = TranquilBlockStore::open_with_retry(
BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
},
tranquil_store::blockstore::OpenRetryPolicy::default(),
)
let blockstore = TranquilBlockStore::open(BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
})
.expect("failed to open blockstore");
let event_log = EventLog::open(
@@ -64,16 +64,6 @@ enum Cmd {
#[arg(long)]
config: Option<PathBuf>,
/// Tempdir parent for `IoBackend::Real` seeds only - ignored for
/// flaky-mount and simulated backends. Repeatable; each rayon worker
/// thread is pinned to one root so concurrent seeds on different
/// threads land on different mounts. Default `/tmp`. Also reads
/// colon-separated paths from `GAUNTLET_SCRATCH_ROOTS`. Set
/// `RAYON_NUM_THREADS=N` to cap workers; for full distribution pass
/// one root per worker.
#[arg(long)]
scratch_root: Vec<PathBuf>,
/// Skip shrinking when dumping regressions.
#[arg(long)]
no_shrink: bool,
@@ -105,13 +95,6 @@ enum Cmd {
#[arg(long)]
dump_regressions: Option<PathBuf>,
/// Same as `farm --scratch-root`: tempdir parent for
/// `IoBackend::Real` seeds only, pinned per worker thread. Ignored
/// for flaky-mount and simulated backends. Repeatable; reads
/// colon-separated paths from `GAUNTLET_SCRATCH_ROOTS`.
#[arg(long)]
scratch_root: Vec<PathBuf>,
/// Skip shrinking when dumping regressions.
#[arg(long)]
no_shrink: bool,
@@ -176,8 +159,6 @@ struct ConfigFile {
#[serde(default)]
dump_regressions: Option<PathBuf>,
#[serde(default)]
scratch_roots: Vec<PathBuf>,
#[serde(default)]
overrides: ConfigOverrides,
}
@@ -197,8 +178,6 @@ struct SweepConfigFile {
#[serde(default)]
dump_regressions: Option<PathBuf>,
#[serde(default)]
scratch_roots: Vec<PathBuf>,
#[serde(default)]
base_overrides: ConfigOverrides,
#[serde(default)]
axes: SweepAxes,
@@ -426,7 +405,6 @@ struct FarmPlan {
seeds: u64,
hours: Option<f64>,
dump_regressions: Option<PathBuf>,
scratch_roots: Vec<PathBuf>,
overrides: ConfigOverrides,
shrink: bool,
shrink_budget: usize,
@@ -440,7 +418,6 @@ fn resolve_farm(
hours: Option<f64>,
dump_regressions: Option<PathBuf>,
config: Option<PathBuf>,
scratch_root: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
) -> Result<FarmPlan, String> {
@@ -466,11 +443,6 @@ fn resolve_farm(
}
let dump_regressions =
dump_regressions.or_else(|| file.as_ref().and_then(|f| f.dump_regressions.clone()));
let file_scratch_roots = file
.as_ref()
.map(|f| f.scratch_roots.clone())
.unwrap_or_default();
let scratch_roots = resolve_scratch_roots(scratch_root, file_scratch_roots)?;
let overrides = file.map(|f| f.overrides).unwrap_or_default();
Ok(FarmPlan {
scenario,
@@ -478,50 +450,12 @@ fn resolve_farm(
seeds,
hours,
dump_regressions,
scratch_roots,
overrides,
shrink,
shrink_budget,
})
}
const SCRATCH_ROOTS_ENV: &str = "GAUNTLET_SCRATCH_ROOTS";
fn resolve_scratch_roots(
cli: Vec<PathBuf>,
config_file: Vec<PathBuf>,
) -> Result<Vec<PathBuf>, String> {
let env_roots: Vec<PathBuf> = std::env::var(SCRATCH_ROOTS_ENV)
.ok()
.filter(|s| !s.is_empty())
.map(|s| s.split(':').map(PathBuf::from).collect())
.unwrap_or_default();
let candidate: Vec<PathBuf> = if !cli.is_empty() {
cli
} else if !config_file.is_empty() {
config_file
} else {
env_roots
};
candidate
.into_iter()
.map(|p| validate_scratch_root(&p).map(|_| p))
.collect()
}
fn validate_scratch_root(path: &Path) -> Result<(), String> {
match path.metadata() {
Ok(m) if m.is_dir() => {}
Ok(_) => return Err(format!("scratch root not a directory: {}", path.display())),
Err(e) => return Err(format!("scratch root {}: {e}", path.display())),
}
tempfile::Builder::new()
.prefix(".tranquil-gauntlet-probe-")
.tempfile_in(path)
.map(|_| ())
.map_err(|e| format!("scratch root {} not writable: {e}", path.display()))
}
fn validate_hours(h: f64) -> Result<(), String> {
if !h.is_finite() || h <= 0.0 {
return Err(format!("invalid --hours={h}: must be positive and finite"));
@@ -630,7 +564,6 @@ fn main() -> ExitCode {
hours,
dump_regressions,
config,
scratch_root,
no_shrink,
shrink_budget,
} => {
@@ -641,7 +574,6 @@ fn main() -> ExitCode {
hours,
dump_regressions,
config,
scratch_root,
!no_shrink,
shrink_budget,
) {
@@ -693,7 +625,6 @@ fn main() -> ExitCode {
seed_start,
seeds,
dump_regressions,
scratch_root,
no_shrink,
shrink_budget,
max_runs,
@@ -703,7 +634,6 @@ fn main() -> ExitCode {
seed_start,
seeds,
dump_regressions,
scratch_root,
!no_shrink,
shrink_budget,
max_runs,
@@ -729,20 +659,17 @@ struct SweepPlan {
seed_start: u64,
seeds: u64,
dump_regressions: Option<PathBuf>,
scratch_roots: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
base_overrides: ConfigOverrides,
axes: Vec<SweepAxisValues>,
}
#[allow(clippy::too_many_arguments)]
fn resolve_sweep(
config: PathBuf,
seed_start: Option<u64>,
seeds: Option<u64>,
dump_regressions: Option<PathBuf>,
scratch_root: Vec<PathBuf>,
shrink: bool,
shrink_budget: usize,
max_runs: u64,
@@ -760,7 +687,6 @@ fn resolve_sweep(
return Err("--shrink-budget must be greater than zero".to_string());
}
let dump_regressions = dump_regressions.or(file.dump_regressions.clone());
let scratch_roots = resolve_scratch_roots(scratch_root, file.scratch_roots.clone())?;
let axes = file.axes.axis_values();
if axes.is_empty() {
return Err("sweep produced no combinations".to_string());
@@ -779,7 +705,6 @@ fn resolve_sweep(
seed_start,
seeds,
dump_regressions,
scratch_roots,
shrink,
shrink_budget,
base_overrides: file.base_overrides,
@@ -824,7 +749,6 @@ fn run_sweep(plan: SweepPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitC
seed_start,
seeds,
dump_regressions,
scratch_roots,
shrink,
shrink_budget,
base_overrides,
@@ -858,13 +782,12 @@ fn run_sweep(plan: SweepPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitC
axis_values.apply_to(&mut overrides);
let combo_start = Instant::now();
let overrides_for_farm = overrides.clone();
let reports = farm::run_many_timed_with_scratch_roots(
let reports = farm::run_many_timed(
move |s| {
let mut cfg = config_for(scenario, s);
overrides_for_farm.apply_to(&mut cfg);
cfg
},
&scratch_roots,
(seed_start..end).map(Seed),
);
let combo_wall = combo_start.elapsed();
@@ -917,7 +840,6 @@ fn run_farm(plan: FarmPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitCod
seeds,
hours,
dump_regressions,
scratch_roots,
overrides,
shrink,
shrink_budget,
@@ -949,13 +871,12 @@ fn run_farm(plan: FarmPlan, rt: &Runtime, interrupt: Arc<AtomicBool>) -> ExitCod
};
let overrides_ref = &overrides;
let batch_start = Instant::now();
let reports = farm::run_many_timed_with_scratch_roots(
let reports = farm::run_many_timed(
|s| {
let mut cfg = config_for(scenario, s);
overrides_ref.apply_to(&mut cfg);
cfg
},
&scratch_roots,
(next_seed..end).map(Seed),
);
let batch_wall = batch_start.elapsed();
@@ -222,8 +222,7 @@ fn stream_compact<S: StorageIO>(
.io()
.sync_dir(manager.data_dir())
.map_err(CompactionError::from)
})
.and_then(|()| manager.io().barrier().map_err(CompactionError::from));
});
let _ = manager.io().close(hint_fd);
@@ -1343,10 +1343,6 @@ fn process_batch<S: StorageIO>(
)
.map_err(|e| rollback_on_err(CommitError::from(e)))?;
hint_writer.sync().map_err(|e| rollback_on_err(e.into()))?;
manager
.io()
.barrier()
.map_err(|e| rollback_on_err(e.into()))?;
let sync_nanos = t.elapsed().as_nanos() as u64;
if !rotations.is_empty() {
+1 -1
View File
@@ -27,7 +27,7 @@ pub use hint::{
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, OpenRetryPolicy, TranquilBlockStore};
pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, TranquilBlockStore};
pub use types::{
BlockLength, BlockLocation, BlockOffset, BlockstoreSnapshot, CidBytes, CollectionResult,
CommitEpoch, CompactionResult, DataFileId, EpochCounter, HintOffset, IndexEntry, LivenessInfo,
+15 -291
View File
@@ -1,9 +1,7 @@
use std::collections::HashMap;
use std::io;
use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use cid::Cid;
@@ -152,24 +150,6 @@ impl Drop for WriterHandle {
}
}
#[derive(Clone, Copy, Debug)]
pub struct OpenRetryPolicy {
pub max_attempts: NonZeroU8,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for OpenRetryPolicy {
fn default() -> Self {
const DEFAULT_MAX_ATTEMPTS: NonZeroU8 = NonZeroU8::new(5).unwrap();
Self {
max_attempts: DEFAULT_MAX_ATTEMPTS,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(2),
}
}
}
impl TranquilBlockStore<RealIO> {
pub fn open(config: BlockStoreConfig) -> Result<Self, RepoError> {
Self::open_with_hook(config, None)
@@ -181,50 +161,6 @@ impl TranquilBlockStore<RealIO> {
) -> Result<Self, RepoError> {
Self::open_with_io_hook(config, RealIO::new, post_sync_hook)
}
pub fn open_with_retry(
config: BlockStoreConfig,
policy: OpenRetryPolicy,
) -> Result<Self, RepoError> {
retry_with_backoff(policy, &mut |_| Self::open(config.clone()))
}
}
fn retry_with_backoff<T, F>(policy: OpenRetryPolicy, op: &mut F) -> Result<T, RepoError>
where
F: FnMut(u8) -> Result<T, RepoError>,
{
retry_attempt(policy, op, 0, policy.initial_backoff)
}
fn retry_attempt<T, F>(
policy: OpenRetryPolicy,
op: &mut F,
attempt: u8,
backoff: Duration,
) -> Result<T, RepoError>
where
F: FnMut(u8) -> Result<T, RepoError>,
{
match op(attempt) {
Ok(t) => Ok(t),
Err(e) if attempt + 1 >= policy.max_attempts.get() => Err(e),
Err(e) => {
tracing::warn!(
attempt,
error = %e,
backoff_ms = u64::try_from(backoff.as_millis()).unwrap_or(u64::MAX),
"blockstore open failed, retrying"
);
std::thread::sleep(backoff);
retry_attempt(
policy,
op,
attempt + 1,
(backoff * 2).min(policy.max_backoff),
)
}
}
}
impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
@@ -395,7 +331,15 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
let scan_pos = &mut { start_offset };
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) => Some(Err(e)),
Err(e) => {
tracing::warn!(
file_id = %file_id,
offset = scan_pos.raw(),
error = %e,
"IO error during recovery scan, stopping"
);
None
}
Ok(None) => None,
Ok(Some(ReadBlockRecord::Valid {
offset,
@@ -410,7 +354,7 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
let record_size = BLOCK_RECORD_OVERHEAD as u64 + u64::from(raw_len);
let new_end = offset.advance(record_size);
*scan_pos = new_end;
Some(Ok((
Some((
cid_bytes,
BlockLocation {
file_id,
@@ -418,30 +362,20 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
length,
},
new_end,
)))
))
}
Ok(Some(ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. })) => {
None
}
}
})
.try_fold(
.fold(
(Vec::new(), start_offset),
|(mut entries, _), item: io::Result<_>| {
let (cid, loc, new_end) = item?;
|(mut entries, _), (cid, loc, new_end)| {
entries.push((cid, loc));
Ok::<_, io::Error>((entries, new_end))
(entries, new_end)
},
)
.map_err(|e| {
tracing::warn!(
file_id = %file_id,
offset = scan_pos.raw(),
error = %e,
"IO error during recovery scan, aborting to preserve durable tail"
);
RepoError::storage(e)
})?;
);
if file_size > last_valid_end.raw() {
tracing::info!(
@@ -779,213 +713,3 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
Ok(self.index.get(&cid_bytes).map(|entry| entry.refcount.raw()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::blockstore::data_file::{
BLOCK_FORMAT_VERSION, BLOCK_HEADER_SIZE, BLOCK_MAGIC, encode_block_record,
};
use crate::blockstore::manager::DATA_FILE_EXTENSION;
use crate::io::FileId;
struct EioOnReadAtRange {
inner: RealIO,
target_path: PathBuf,
target_min: u64,
target_max: u64,
fired: AtomicBool,
fd_paths: Mutex<HashMap<FileId, PathBuf>>,
}
impl StorageIO for EioOnReadAtRange {
fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> {
let fd = self.inner.open(path, opts)?;
self.fd_paths
.lock()
.unwrap()
.insert(fd, path.to_path_buf());
Ok(fd)
}
fn close(&self, fd: FileId) -> io::Result<()> {
self.fd_paths.lock().unwrap().remove(&fd);
self.inner.close(fd)
}
fn read_at(&self, fd: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let path_match = self.fd_paths.lock().unwrap().get(&fd).cloned();
let in_target_range = path_match.as_ref() == Some(&self.target_path)
&& offset >= self.target_min
&& offset <= self.target_max;
if in_target_range && !self.fired.swap(true, Ordering::SeqCst) {
return Err(io::Error::other("simulated EIO on read"));
}
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<()> {
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)
}
}
#[test]
fn scan_and_index_does_not_truncate_acked_block_on_transient_eio() {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let index_dir = tmp.path().join("index");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::create_dir_all(&index_dir).unwrap();
let file_id = DataFileId::new(0);
let file_path = data_dir.join(format!("{file_id}.{DATA_FILE_EXTENSION}"));
let setup = RealIO::new();
let fd = setup.open(&file_path, OpenOptions::read_write()).unwrap();
let mut header = [0u8; BLOCK_HEADER_SIZE];
header[..4].copy_from_slice(&BLOCK_MAGIC);
header[4] = BLOCK_FORMAT_VERSION;
setup.write_all_at(fd, 0, &header).unwrap();
let cid_a = [0xAAu8; CID_SIZE];
let data_a = vec![1u8; 64];
let block_a_offset = BlockOffset::new(BLOCK_HEADER_SIZE as u64);
let len_a =
encode_block_record(&setup, fd, block_a_offset, &cid_a, &data_a).unwrap();
let block_b_offset_raw = BLOCK_HEADER_SIZE as u64 + len_a;
let block_b_offset = BlockOffset::new(block_b_offset_raw);
let cid_b = [0xBBu8; CID_SIZE];
let data_b = vec![2u8; 64];
let len_b = encode_block_record(&setup, fd, block_b_offset, &cid_b, &data_b).unwrap();
setup.sync(fd).unwrap();
setup.close(fd).unwrap();
drop(setup);
let total_size = block_b_offset_raw + len_b;
assert_eq!(std::fs::metadata(&file_path).unwrap().len(), total_size);
let wrapper = EioOnReadAtRange {
inner: RealIO::new(),
target_path: file_path.clone(),
target_min: block_b_offset_raw,
target_max: block_b_offset_raw + (BLOCK_RECORD_OVERHEAD as u64) - 1,
fired: AtomicBool::new(false),
fd_paths: Mutex::new(HashMap::new()),
};
let index = BlockIndex::open(&index_dir).unwrap();
let result = TranquilBlockStore::<EioOnReadAtRange>::replay_single_file(
&wrapper,
&data_dir,
&index,
file_id,
BlockOffset::new(BLOCK_HEADER_SIZE as u64),
);
assert!(
result.is_err(),
"replay must surface transient EIO instead of silently truncating"
);
let post_size = std::fs::metadata(&file_path).unwrap().len();
assert_eq!(
post_size, total_size,
"scan truncated durable acked block past EIO point: expected {total_size} bytes, got {post_size}"
);
}
fn instant_policy(max_attempts: u8) -> OpenRetryPolicy {
OpenRetryPolicy {
max_attempts: NonZeroU8::new(max_attempts).expect("max_attempts must be nonzero"),
initial_backoff: Duration::ZERO,
max_backoff: Duration::ZERO,
}
}
#[test]
fn retry_with_backoff_succeeds_on_first_attempt() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result = retry_with_backoff(instant_policy(5), &mut |_| {
calls.fetch_add(1, Ordering::Relaxed);
Ok::<u8, RepoError>(42)
});
assert_eq!(result.expect("ok"), 42);
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[test]
fn retry_with_backoff_recovers_after_transient_failures() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result = retry_with_backoff(instant_policy(5), &mut |_| {
let n = calls.fetch_add(1, Ordering::Relaxed);
if n >= 2 {
Ok::<u8, RepoError>(7)
} else {
Err(RepoError::storage(io::Error::other("transient EIO")))
}
});
assert_eq!(result.expect("ok"), 7);
assert_eq!(calls.load(Ordering::Relaxed), 3);
}
#[test]
fn retry_with_backoff_gives_up_after_max_attempts() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let result: Result<u8, RepoError> = retry_with_backoff(instant_policy(3), &mut |_| {
calls.fetch_add(1, Ordering::Relaxed);
Err(RepoError::storage(io::Error::other("permanent EIO")))
});
assert!(result.is_err(), "expected exhaustion error");
assert_eq!(calls.load(Ordering::Relaxed), 3);
}
#[test]
fn retry_with_backoff_passes_attempt_index_to_op() {
let observed = std::sync::Mutex::new(Vec::<u8>::new());
let _result: Result<(), RepoError> = retry_with_backoff(instant_policy(4), &mut |attempt| {
observed.lock().unwrap().push(attempt);
Err(RepoError::storage(io::Error::other("EIO")))
});
assert_eq!(*observed.lock().unwrap(), vec![0, 1, 2, 3]);
}
}
+41 -201
View File
@@ -3,27 +3,16 @@ use std::sync::Arc;
use tracing::warn;
use crate::io::{FileId, StorageIO};
use crate::io::StorageIO;
use super::manager::SegmentManager;
use super::segment_file::{
SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SegmentWriter, ValidEvent, ValidateEventRecord,
validate_event_record,
};
use super::segment_file::{SEGMENT_HEADER_SIZE, SegmentWriter, ValidEvent};
use super::segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment};
use super::sidecar::build_sidecar_from_segment;
use super::types::{
DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros,
};
const VALIDATE_RETRY_ATTEMPTS: u32 = 32;
#[derive(Debug, Clone)]
struct PendingAppend {
event: ValidEvent,
offset: SegmentOffset,
}
#[derive(Debug)]
pub struct SyncResult {
pub synced_through: EventSequence,
@@ -42,8 +31,7 @@ pub struct EventLogWriter<S: StorageIO> {
max_payload: u32,
event_count_in_segment: usize,
last_event_offset: Option<SegmentOffset>,
pending: Vec<PendingAppend>,
poisoned: bool,
pending_events: Vec<ValidEvent>,
}
impl<S: StorageIO> EventLogWriter<S> {
@@ -95,25 +83,10 @@ impl<S: StorageIO> EventLogWriter<S> {
max_payload,
event_count_in_segment: 0,
last_event_offset: None,
pending: Vec::new(),
poisoned: false,
pending_events: Vec::new(),
})
}
fn truncate_and_init_fresh(
manager: Arc<SegmentManager<S>>,
fd: FileId,
active_id: SegmentId,
prev_segments: &[SegmentId],
index_interval: usize,
max_payload: u32,
) -> io::Result<Self> {
manager.io().truncate(fd, 0)?;
let next_seq = find_last_seq_from_segments(&manager, prev_segments, max_payload)?
.map_or(EventSequence::new(1), |s| s.next());
Self::init_fresh(manager, active_id, next_seq, index_interval, max_payload)
}
fn recover_active(
manager: Arc<SegmentManager<S>>,
segments: &[SegmentId],
@@ -124,19 +97,6 @@ impl<S: StorageIO> EventLogWriter<S> {
let handle = manager.open_for_append(active_id)?;
let fd = handle.fd();
let prev_segments = &segments[..segments.len().saturating_sub(1)];
if highest_segment_has_torn_header(manager.io(), fd)? {
return Self::truncate_and_init_fresh(
Arc::clone(&manager),
fd,
active_id,
prev_segments,
index_interval,
max_payload,
);
}
let (index, last_seq_in_active) = match rebuild_from_segment(
manager.io(),
fd,
@@ -147,11 +107,15 @@ impl<S: StorageIO> EventLogWriter<S> {
Err(rebuild_err) => {
let file_size = manager.io().file_size(fd)?;
if file_size <= SEGMENT_HEADER_SIZE as u64 {
return Self::truncate_and_init_fresh(
manager.io().truncate(fd, 0)?;
let prev_segments = &segments[..segments.len().saturating_sub(1)];
let next_seq =
find_last_seq_from_segments(&manager, prev_segments, max_payload)?
.map_or(EventSequence::new(1), |s| s.next());
return Self::init_fresh(
Arc::clone(&manager),
fd,
active_id,
prev_segments,
next_seq,
index_interval,
max_payload,
);
@@ -167,6 +131,8 @@ impl<S: StorageIO> EventLogWriter<S> {
let position = SegmentOffset::new(manager.io().file_size(fd)?);
let prev_segments = &segments[..segments.len().saturating_sub(1)];
let next_seq = match last_seq_in_active {
Some(seq) => {
if let Some(sealed_last) =
@@ -230,8 +196,7 @@ impl<S: StorageIO> EventLogWriter<S> {
max_payload,
event_count_in_segment,
last_event_offset,
pending: Vec::new(),
poisoned: false,
pending_events: Vec::new(),
})
}
@@ -262,20 +227,28 @@ impl<S: StorageIO> EventLogWriter<S> {
payload,
};
self.append_inner(event).map(|_| seq)
let offset = self.active_writer.append_event(self.manager.io(), &event)?;
let should_index = self.event_count_in_segment == 0
|| self
.event_count_in_segment
.is_multiple_of(self.index_interval);
if should_index {
self.active_index.record(seq, offset);
}
self.event_count_in_segment = self
.event_count_in_segment
.checked_add(1)
.expect("event_count_in_segment overflow");
self.last_event_offset = Some(offset);
self.next_seq = seq.next();
self.pending_events.push(event);
Ok(seq)
}
pub fn append_valid_event(&mut self, event: ValidEvent) -> io::Result<()> {
self.append_inner(event)
}
fn append_inner(&mut self, event: ValidEvent) -> io::Result<()> {
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
let offset = self.active_writer.append_event(self.manager.io(), &event)?;
let should_index = self.event_count_in_segment == 0
@@ -292,52 +265,21 @@ impl<S: StorageIO> EventLogWriter<S> {
.expect("event_count_in_segment overflow");
self.last_event_offset = Some(offset);
self.next_seq = event.seq.next();
self.pending.push(PendingAppend { event, offset });
self.pending_events.push(event);
Ok(())
}
pub fn peek_pending_event(&self, seq: EventSequence) -> Option<&ValidEvent> {
self.pending_events.iter().find(|e| e.seq == seq)
}
pub fn sync(&mut self) -> io::Result<SyncResult> {
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
if !self.pending.is_empty() {
if !self.pending_events.is_empty() {
self.active_writer.sync(self.manager.io())?;
self.manager.io().barrier()?;
}
let pending = std::mem::take(&mut self.pending);
let fd = self.active_writer.fd();
let file_size = self.manager.io().file_size(fd)?;
let valid_count = pending
.iter()
.take_while(|p| {
validate_with_retry(
self.manager.io(),
fd,
p.offset,
file_size,
self.max_payload,
p.event.seq,
)
})
.count();
if valid_count < pending.len() {
self.poisoned = true;
}
let flushed: Vec<ValidEvent> = pending
.into_iter()
.take(valid_count)
.map(|p| p.event)
.collect();
let flushed = std::mem::take(&mut self.pending_events);
self.synced_seq = flushed.last().map(|e| e.seq).unwrap_or(self.synced_seq);
Ok(SyncResult {
@@ -348,22 +290,12 @@ impl<S: StorageIO> EventLogWriter<S> {
})
}
pub fn is_poisoned(&self) -> bool {
self.poisoned
}
pub fn rotate_if_needed(&mut self) -> io::Result<Option<SegmentId>> {
if self.poisoned {
return Err(io::Error::other(
"writer poisoned by partial-valid sync; reopen required",
));
}
if !self.manager.should_rotate(self.active_writer.position()) {
return Ok(None);
}
if !self.pending.is_empty() {
if !self.pending_events.is_empty() {
return Ok(None);
}
@@ -454,40 +386,6 @@ impl<S: StorageIO> EventLogWriter<S> {
}
}
fn validate_with_retry<S: StorageIO>(
io: &S,
fd: FileId,
offset: SegmentOffset,
file_size: u64,
max_payload: u32,
expected_seq: EventSequence,
) -> bool {
(0..VALIDATE_RETRY_ATTEMPTS).any(|_| {
matches!(
validate_event_record(io, fd, offset, file_size, max_payload),
Ok(Some(ValidateEventRecord::Valid { seq, .. })) if seq == expected_seq
)
})
}
fn highest_segment_has_torn_header<S: StorageIO>(io: &S, fd: FileId) -> io::Result<bool> {
let file_size = io.file_size(fd)?;
if file_size < SEGMENT_HEADER_SIZE as u64 {
return Ok(true);
}
let outcomes: Vec<bool> = (0..VALIDATE_RETRY_ATTEMPTS)
.filter_map(|_| {
let mut header = [0u8; SEGMENT_MAGIC.len()];
io.read_exact_at(fd, 0, &mut header)
.ok()
.map(|()| header == SEGMENT_MAGIC)
})
.collect();
let saw_match = outcomes.iter().any(|&ok| ok);
let saw_mismatch = outcomes.iter().any(|&ok| !ok);
Ok(!saw_match && saw_mismatch)
}
fn find_last_seq_from_segments<S: StorageIO>(
manager: &SegmentManager<S>,
segments: &[SegmentId],
@@ -1196,62 +1094,4 @@ mod tests {
assert!(writer.rotate_if_needed().unwrap().is_none());
}
#[test]
fn sync_must_not_certify_durability_when_io_sync_silently_drops() {
use crate::sim::{FaultConfig, Probability};
let sim = Arc::new(SimulatedIO::new(
0,
FaultConfig {
sync_failure_probability: Probability::new(1.0),
..FaultConfig::none()
},
));
sim.set_pristine_mode(true);
let mgr = Arc::new(
SegmentManager::new(Arc::clone(&sim), PathBuf::from("/segments"), 64 * 1024).unwrap(),
);
let mut writer =
EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD)
.unwrap();
sim.set_pristine_mode(false);
writer
.append(
DidHash::from_did("did:plc:bug2"),
EventTypeTag::COMMIT,
b"bug2-payload".to_vec(),
)
.unwrap();
assert!(
writer.sync().is_err(),
"sync must surface dropped fsync as an error"
);
let claimed_synced = writer.synced_seq();
assert_eq!(
claimed_synced.raw(),
0,
"synced_seq must not advance past a failed sync"
);
drop(writer);
mgr.shutdown();
sim.crash();
sim.set_pristine_mode(true);
let reopened =
EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD)
.unwrap();
let actually_durable = reopened.current_seq();
assert!(
actually_durable >= claimed_synced,
"writer claimed sync through {claimed_synced} but post-crash recovery only reaches {actually_durable}"
);
}
}
+1 -84
View File
@@ -1,6 +1,5 @@
use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use rayon::prelude::*;
@@ -45,17 +44,6 @@ pub fn run_many_timed<F>(
make_config: F,
seeds: impl IntoIterator<Item = Seed>,
) -> Vec<(GauntletReport, Duration)>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
run_many_timed_with_scratch_roots(make_config, &[], seeds)
}
pub fn run_many_timed_with_scratch_roots<F>(
make_config: F,
scratch_roots: &[PathBuf],
seeds: impl IntoIterator<Item = Seed>,
) -> Vec<(GauntletReport, Duration)>
where
F: Fn(Seed) -> GauntletConfig + Sync + Send,
{
@@ -63,14 +51,10 @@ where
seeds
.into_par_iter()
.map(|s| {
let scratch = scratch_for_thread(scratch_roots, rayon::current_thread_index());
let start = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(|| {
let cfg = make_config(s);
let mut gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
if let Some(root) = scratch {
gauntlet = gauntlet.with_scratch_root(root);
}
let gauntlet = Gauntlet::new(cfg).expect("build gauntlet");
with_runtime(|rt| rt.block_on(gauntlet.run()))
}));
let report = outcome.unwrap_or_else(|payload| {
@@ -82,14 +66,6 @@ where
.collect()
}
fn scratch_for_thread(roots: &[PathBuf], thread_idx: Option<usize>) -> Option<PathBuf> {
if roots.is_empty() {
None
} else {
Some(roots[thread_idx.unwrap_or(0) % roots.len()].clone())
}
}
fn panic_report(seed: Seed, payload: Box<dyn std::any::Any + Send>) -> GauntletReport {
let msg = payload
.downcast_ref::<&'static str>()
@@ -108,62 +84,3 @@ fn panic_report(seed: Seed, payload: Box<dyn std::any::Any + Send>) -> GauntletR
ops: OpStream::empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scratch_for_thread_returns_none_when_roots_empty() {
assert!(scratch_for_thread(&[], Some(0)).is_none());
assert!(scratch_for_thread(&[], Some(7)).is_none());
assert!(scratch_for_thread(&[], None).is_none());
}
#[test]
fn scratch_for_thread_round_robins_across_roots() {
let roots = vec![
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
];
let assigned: Vec<PathBuf> = (0..7)
.map(|i| scratch_for_thread(&roots, Some(i)).expect("scratch path"))
.collect();
assert_eq!(
assigned,
vec![
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
PathBuf::from("/scratch/c"),
PathBuf::from("/scratch/a"),
]
);
}
#[test]
fn scratch_for_thread_with_single_root_returns_same_path() {
let roots = vec![PathBuf::from("/scratch/only")];
(0..5).for_each(|i| {
assert_eq!(
scratch_for_thread(&roots, Some(i)),
Some(PathBuf::from("/scratch/only"))
);
});
}
#[test]
fn scratch_for_thread_falls_back_to_root_zero_outside_pool() {
let roots = vec![
PathBuf::from("/scratch/a"),
PathBuf::from("/scratch/b"),
];
assert_eq!(
scratch_for_thread(&roots, None),
Some(PathBuf::from("/scratch/a"))
);
}
}
@@ -405,8 +405,6 @@ fn mount_ext4(device: &Path, target: &Path) -> Result<(), FlakyError> {
let out = Command::new("mount")
.arg("-t")
.arg("ext4")
.arg("-o")
.arg("errors=continue")
.arg(device)
.arg(target)
.output()?;
+41 -187
View File
@@ -23,7 +23,7 @@ use crate::eventlog::{
SegmentManager, SegmentReader, TimestampMicros, ValidEvent,
};
use crate::io::{RealIO, StorageIO};
use crate::sim::{FaultConfig, PristineGuard, SimulatedIO};
use crate::sim::{FaultConfig, SimulatedIO};
#[derive(Debug, Clone, Copy)]
pub enum IoBackend {
@@ -178,7 +178,6 @@ pub struct SharedState<S: StorageIO + Send + Sync + 'static> {
pub struct Gauntlet {
config: GauntletConfig,
scratch_root: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
@@ -186,15 +185,7 @@ pub enum GauntletBuildError {}
impl Gauntlet {
pub fn new(config: GauntletConfig) -> Result<Self, GauntletBuildError> {
Ok(Self {
config,
scratch_root: None,
})
}
pub fn with_scratch_root(mut self, root: PathBuf) -> Self {
self.scratch_root = Some(root);
self
Ok(Self { config })
}
pub fn generate_ops(&self) -> OpStream {
@@ -220,7 +211,6 @@ impl Gauntlet {
let ops_counter = Arc::new(AtomicUsize::new(0));
let op_errors_counter = Arc::new(AtomicUsize::new(0));
let restarts_counter = Arc::new(AtomicUsize::new(0));
let scratch_root = self.scratch_root;
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = GauntletReport> + Send>> =
match self.config.io {
IoBackend::Real => Box::pin(run_inner_real(
@@ -229,7 +219,6 @@ impl Gauntlet {
ops_counter.clone(),
op_errors_counter.clone(),
restarts_counter.clone(),
scratch_root,
)),
IoBackend::RealWithFlaky { flaky } => Box::pin(run_inner_real_with_flaky(
self.config,
@@ -280,12 +269,8 @@ async fn run_inner_real(
ops_counter: Arc<AtomicUsize>,
op_errors_counter: Arc<AtomicUsize>,
restarts_counter: Arc<AtomicUsize>,
scratch_root: Option<PathBuf>,
) -> GauntletReport {
let dir = match scratch_root.as_deref() {
Some(parent) => tempfile::TempDir::new_in(parent).expect("tempdir in scratch root"),
None => tempfile::TempDir::new().expect("tempdir"),
};
let dir = tempfile::TempDir::new().expect("tempdir");
let root = dir.path().to_path_buf();
let report = run_inner_real_on_root(
config,
@@ -379,7 +364,7 @@ async fn run_inner_real_on_root(
let segments_dir = segments_subdir(&root);
let open = {
let segments_dir = segments_dir.clone();
move |_attempt: usize| -> Result<Harness<RealIO>, String> {
move || -> Result<Harness<RealIO>, String> {
let store = TranquilBlockStore::open(cfg.clone())
.map(Arc::new)
.map_err(|e| e.to_string())?;
@@ -439,8 +424,7 @@ async fn run_inner_simulated(
let sim_for_open = Arc::clone(&sim);
let open = {
let segments_dir = segments_dir.clone();
move |attempt: usize| -> Result<Harness<Arc<SimulatedIO>>, String> {
let _pristine = PristineGuard::new(Arc::clone(&sim_for_open), attempt > 0);
move || -> Result<Harness<Arc<SimulatedIO>>, String> {
let factory_sim = Arc::clone(&sim_for_open);
let make_io = move || Arc::clone(&factory_sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(cfg.clone(), make_io)
@@ -528,30 +512,28 @@ async fn run_inner_generic<S, Open, Crash>(
) -> GauntletReport
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Open: FnMut() -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let mut oracle = Oracle::new();
let mut violations: Vec<InvariantViolation> = Vec::new();
let mut harness: Option<Harness<S>> =
match reopen_with_recovery(&mut open, &mut crash, tolerate_op_errors, reopen_backoff).await
{
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut harness: Option<Harness<S>> = match open() {
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut root: Option<Cid> = None;
let mut restart_rng = Lcg::new(Seed(config.seed.0 ^ 0xA5A5_A5A5_A5A5_A5A5));
let mut sample_rng = Lcg::new(Seed(config.seed.0 ^ 0x5A5A_5A5A_5A5A_5A5A));
@@ -768,7 +750,7 @@ async fn reopen_with_recovery<S, Open, Crash>(
) -> Result<Harness<S>, String>
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Open: FnMut() -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let mut errors: Vec<String> = Vec::new();
@@ -776,7 +758,7 @@ where
if attempt > 0 && !backoff.is_zero() {
tokio::time::sleep(backoff).await;
}
match open(attempt) {
match open() {
Ok(h) => return Ok(h),
Err(e) => {
errors.push(format!("attempt {attempt}: {e}"));
@@ -1209,10 +1191,6 @@ fn run_retention<S: StorageIO + Send + Sync + 'static>(
max_age: RetentionSecs,
) -> Result<(), String> {
let sync_result = el.writer.sync().map_err(|e| e.to_string())?;
el.manager
.io()
.sync_dir(el.segments_dir.as_path())
.map_err(|e| e.to_string())?;
let _ = el.writer.rotate_if_needed();
oracle.record_event_sync(sync_result.synced_through);
let active_id = sync_result.segment_id;
@@ -1586,7 +1564,7 @@ async fn run_inner_generic_concurrent<S, Open, Crash>(
) -> GauntletReport
where
S: StorageIO + Send + Sync + 'static,
Open: FnMut(usize) -> Result<Harness<S>, String>,
Open: FnMut() -> Result<Harness<S>, String>,
Crash: FnMut(),
{
let ops: Vec<Op> = op_stream.into_vec();
@@ -1599,24 +1577,22 @@ where
let mut sample_rng = Lcg::new(Seed(config.seed.0 ^ 0x5A5A_5A5A_5A5A_5A5A));
let chunks = compute_chunks(config.restart_policy, total_ops, &mut restart_rng);
let mut harness: Option<Harness<S>> =
match reopen_with_recovery(&mut open, &mut crash, tolerate_op_errors, reopen_backoff).await
{
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut harness: Option<Harness<S>> = match open() {
Ok(h) => Some(h),
Err(e) => {
return GauntletReport {
seed: config.seed,
ops_executed: OpsExecuted(0),
op_errors: OpErrorCount(op_errors_counter.load(Ordering::Relaxed)),
restarts: RestartCount(0),
violations: vec![InvariantViolation {
invariant: "OpenStore",
detail: format!("initial open: {e}"),
}],
ops: OpStream::empty(),
};
}
};
let mut root: Option<Cid> = None;
let mut oracle = Oracle::new();
let mut halt_ops = false;
@@ -1825,125 +1801,3 @@ where
ops: OpStream::empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_config() -> GauntletConfig {
GauntletConfig {
seed: Seed(0),
io: IoBackend::Real,
workload: WorkloadModel::default(),
op_count: OpCount(0),
invariants: InvariantSet::EMPTY,
limits: RunLimits {
max_wall_ms: Some(WallMs(30_000)),
},
restart_policy: RestartPolicy::Never,
store: StoreConfig {
max_file_size: MaxFileSize(8 * 1024),
group_commit: GroupCommitConfig::default(),
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
}
}
fn flaky_open(
attempts: Arc<AtomicUsize>,
sim: Arc<SimulatedIO>,
store_cfg: BlockStoreConfig,
) -> impl FnMut(usize) -> Result<Harness<Arc<SimulatedIO>>, String> + Send + 'static {
move |_attempt: usize| -> Result<Harness<Arc<SimulatedIO>>, String> {
let n = attempts.fetch_add(1, Ordering::Relaxed);
if n == 0 {
return Err("simulated EIO on initial open".to_string());
}
let factory_sim = Arc::clone(&sim);
let make_io = move || Arc::clone(&factory_sim);
TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(store_cfg.clone(), make_io)
.map(|s| Harness {
store: Arc::new(s),
eventlog: None,
})
.map_err(|e| e.to_string())
}
}
#[tokio::test]
async fn run_inner_generic_retries_initial_open_on_transient_io_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let cfg = minimal_config();
let store_cfg = blockstore_config(dir.path(), &cfg.store);
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0));
let attempts = Arc::new(AtomicUsize::new(0));
let report = run_inner_generic::<Arc<SimulatedIO>, _, _>(
cfg,
OpStream::empty(),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
flaky_open(Arc::clone(&attempts), Arc::clone(&sim), store_cfg),
|| {},
true,
Duration::ZERO,
)
.await;
let opens: Vec<&InvariantViolation> = report
.violations
.iter()
.filter(|v| v.invariant == "OpenStore")
.collect();
assert!(
opens.is_empty(),
"expected initial open to retry, got OpenStore violations: {opens:?}"
);
let total = attempts.load(Ordering::Relaxed);
assert!(
total >= 2,
"expected at least one retry after first failure, attempts={total}"
);
}
#[tokio::test]
async fn run_inner_generic_concurrent_retries_initial_open_on_transient_io_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let mut cfg = minimal_config();
cfg.writer_concurrency = WriterConcurrency(2);
let store_cfg = blockstore_config(dir.path(), &cfg.store);
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::pristine(0));
let attempts = Arc::new(AtomicUsize::new(0));
let report = run_inner_generic_concurrent::<Arc<SimulatedIO>, _, _>(
cfg,
OpStream::empty(),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
flaky_open(Arc::clone(&attempts), Arc::clone(&sim), store_cfg),
|| {},
true,
Duration::ZERO,
)
.await;
let opens: Vec<&InvariantViolation> = report
.violations
.iter()
.filter(|v| v.invariant == "OpenStore")
.collect();
assert!(
opens.is_empty(),
"expected initial open to retry, got OpenStore violations: {opens:?}"
);
let total = attempts.load(Ordering::Relaxed);
assert!(
total >= 2,
"expected at least one retry after first failure, attempts={total}"
);
}
}
@@ -9,7 +9,7 @@ use super::workload::{
ByteRange, DidSpaceSize, KeySpaceSize, OpCount, OpWeights, RetentionMaxSecs, SizeDistribution,
ValueBytes, WorkloadModel,
};
use crate::blockstore::{GroupCommitConfig, MAX_BLOCK_SIZE};
use crate::blockstore::GroupCommitConfig;
use crate::sim::FaultConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -402,7 +402,7 @@ fn huge_values(seed: Seed) -> GauntletConfig {
workload: block_workload(
block_weights(85, 5, 8, 2),
SizeDistribution::HeavyTail(
ByteRange::new(ValueBytes(256), ValueBytes(MAX_BLOCK_SIZE))
ByteRange::new(ValueBytes(256), ValueBytes(16 * 1024 * 1024))
.expect("huge_values ByteRange"),
),
KeySpaceSize(64),
-7
View File
@@ -104,10 +104,6 @@ pub trait StorageIO: Send + Sync {
fn sync_dir(&self, path: &Path) -> io::Result<()>;
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn barrier(&self) -> io::Result<()> {
Ok(())
}
fn write_all_at(&self, fd: FileId, offset: u64, buf: &[u8]) -> io::Result<()> {
let written = Cell::new(0usize);
std::iter::from_fn(|| (written.get() < buf.len()).then_some(()))
@@ -194,9 +190,6 @@ impl<S: StorageIO> StorageIO for Arc<S> {
fn list_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
(**self).list_dir(path)
}
fn barrier(&self) -> io::Result<()> {
(**self).barrier()
}
fn mmap_file(&self, fd: FileId) -> io::Result<MappedFile> {
(**self).mmap_file(fd)
}
+1 -1
View File
@@ -28,7 +28,7 @@ pub use record::{
};
#[cfg(any(test, feature = "test-harness"))]
pub use sim::{
FaultConfig, LatencyNs, OpRecord, Probability, PristineGuard, SimulatedIO, SyncReorderWindow,
FaultConfig, LatencyNs, OpRecord, Probability, SimulatedIO, SyncReorderWindow,
sim_proptest_cases, sim_seed_count, sim_seed_range, sim_single_seed,
};
+20 -77
View File
@@ -2,8 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::io::{FileId, OpenOptions, StorageIO};
@@ -227,7 +226,6 @@ pub enum OpRecord {
SyncDir {
path: PathBuf,
},
Barrier,
}
struct PendingSync {
@@ -328,7 +326,6 @@ impl SimState {
pub struct SimulatedIO {
state: Mutex<SimState>,
fault_config: FaultConfig,
pristine_mode: AtomicBool,
rng_seed: u64,
latency_counter: AtomicU64,
}
@@ -349,26 +346,13 @@ impl SimulatedIO {
pending_deletes: Vec::new(),
}),
fault_config,
pristine_mode: AtomicBool::new(false),
rng_seed: seed,
latency_counter: AtomicU64::new(0),
}
}
fn effective_fault_config(&self) -> FaultConfig {
if self.pristine_mode.load(Ordering::Relaxed) {
FaultConfig::none()
} else {
self.fault_config
}
}
pub fn set_pristine_mode(&self, on: bool) {
self.pristine_mode.store(on, Ordering::Relaxed);
}
fn jitter(&self) {
let max_ns = self.effective_fault_config().latency_distribution_ns.0;
let max_ns = self.fault_config.latency_distribution_ns.0;
if max_ns == 0 {
return;
}
@@ -445,30 +429,12 @@ impl SimulatedIO {
}
}
pub struct PristineGuard {
sim: Arc<SimulatedIO>,
}
impl PristineGuard {
pub fn new(sim: Arc<SimulatedIO>, on: bool) -> Self {
sim.set_pristine_mode(on);
Self { sim }
}
}
impl Drop for PristineGuard {
fn drop(&mut self) {
self.sim.set_pristine_mode(false);
}
}
impl StorageIO for SimulatedIO {
fn open(&self, path: &Path, opts: OpenOptions) -> io::Result<FileId> {
let fault = self.effective_fault_config();
let mut state = self.state.lock().unwrap();
let seed = self.rng_seed;
if state.should_fault(seed, fault.io_error_probability) {
if state.should_fault(seed, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on open"));
}
@@ -548,7 +514,6 @@ impl StorageIO for SimulatedIO {
fn read_at(&self, id: FileId, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
self.jitter();
let fault = self.effective_fault_config();
let mut state = self.state.lock().unwrap();
let sid = state.require_readable(id)?;
let seed = self.rng_seed;
@@ -557,12 +522,12 @@ impl StorageIO for SimulatedIO {
return Err(io::Error::other("simulated EIO after delayed sync fault"));
}
if state.should_fault(seed, fault.io_error_probability) {
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, fault.misdirected_read_probability) {
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 {
@@ -591,7 +556,7 @@ impl StorageIO for SimulatedIO {
let to_read = buf.len().min(available);
buf[..to_read].copy_from_slice(&storage.buffered[off..off + to_read]);
if state.should_fault(seed, fault.bit_flip_on_read_probability) && to_read > 0 {
if state.should_fault(seed, self.fault_config.bit_flip_on_read_probability) && to_read > 0 {
let flip_pos = state.next_random_usize(seed, to_read);
let flip_bit = state.next_random_usize(seed, 8);
buf[flip_pos] ^= 1 << flip_bit;
@@ -607,7 +572,6 @@ impl StorageIO for SimulatedIO {
fn write_at(&self, id: FileId, offset: u64, buf: &[u8]) -> io::Result<usize> {
self.jitter();
let fault = self.effective_fault_config();
let mut state = self.state.lock().unwrap();
let sid = state.require_writable(id)?;
let seed = self.rng_seed;
@@ -616,12 +580,12 @@ impl StorageIO for SimulatedIO {
return Err(io::Error::other("simulated EIO after delayed sync fault"));
}
if state.should_fault(seed, fault.io_error_probability) {
if state.should_fault(seed, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on write"));
}
let torn_len =
if buf.len() > 1 && state.should_fault(seed, fault.torn_page_probability) {
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());
@@ -637,7 +601,7 @@ impl StorageIO for SimulatedIO {
let actual_len = match torn_len {
Some(n) => n,
None if buf.len() > 1
&& state.should_fault(seed, fault.partial_write_probability) =>
&& state.should_fault(seed, self.fault_config.partial_write_probability) =>
{
let partial = state.next_random_usize(seed, buf.len());
partial.max(1)
@@ -645,7 +609,7 @@ impl StorageIO for SimulatedIO {
None => buf.len(),
};
let misdirected = state.should_fault(seed, fault.misdirected_write_probability);
let misdirected = state.should_fault(seed, self.fault_config.misdirected_write_probability);
let write_offset = if misdirected {
let drift_sectors = state.next_random_usize(seed, 8) + 1;
let drift = (drift_sectors * SECTOR_BYTES) as u64;
@@ -679,7 +643,6 @@ impl StorageIO for SimulatedIO {
fn sync(&self, id: FileId) -> io::Result<()> {
self.jitter();
let fault = self.effective_fault_config();
let mut state = self.state.lock().unwrap();
let sid = state.require_open(id)?;
let seed = self.rng_seed;
@@ -688,22 +651,16 @@ impl StorageIO for SimulatedIO {
return Err(io::Error::other("simulated EIO after delayed sync fault"));
}
if state.should_fault(seed, fault.io_error_probability) {
if state.should_fault(seed, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on sync"));
}
if state.should_fault(seed, fault.sync_failure_probability) {
state.op_log.push(OpRecord::Sync {
fd: id,
succeeded: false,
});
return Err(io::Error::other("simulated dropped fsync"));
}
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 poison_after = state.should_fault(seed, fault.delayed_io_error_probability);
let reorder_window = fault.sync_reorder_window.0 as usize;
let evicted = if reorder_window > 0 {
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,
@@ -729,7 +686,7 @@ impl StorageIO for SimulatedIO {
let storage = state.storage.get_mut(&sid).unwrap();
if reorder_window == 0 {
if sync_succeeded && reorder_window == 0 {
storage.durable = storage.buffered.clone();
}
if poison_after {
@@ -738,7 +695,7 @@ impl StorageIO for SimulatedIO {
state.op_log.push(OpRecord::Sync {
fd: id,
succeeded: true,
succeeded: sync_succeeded,
});
Ok(())
}
@@ -817,31 +774,17 @@ impl StorageIO for SimulatedIO {
Ok(())
}
fn barrier(&self) -> io::Result<()> {
self.jitter();
let mut state = self.state.lock().unwrap();
let drained: Vec<PendingSync> = state.pending_syncs.drain(..).collect();
drained.into_iter().for_each(|p| {
if let Some(storage) = state.storage.get_mut(&p.storage_id) {
storage.durable = p.snapshot;
}
});
state.op_log.push(OpRecord::Barrier);
Ok(())
}
fn sync_dir(&self, path: &Path) -> io::Result<()> {
let fault = self.effective_fault_config();
let mut state = self.state.lock().unwrap();
let seed = self.rng_seed;
if state.should_fault(seed, fault.io_error_probability) {
if state.should_fault(seed, self.fault_config.io_error_probability) {
return Err(io::Error::other("simulated EIO on sync_dir"));
}
let dir_path = path.to_path_buf();
let actually_persisted =
!state.should_fault(seed, fault.dir_sync_failure_probability);
!state.should_fault(seed, self.fault_config.dir_sync_failure_probability);
if actually_persisted {
state.dirs_durable.insert(dir_path.clone());
@@ -460,115 +460,3 @@ async fn mst_restart_churn_single_seed() {
assert_clean(&report);
assert!(report.restarts.0 >= 1);
}
#[tokio::test]
async fn torn_pages_only_completes_within_budget() {
let cfg = GauntletConfig {
seed: Seed(0),
io: IoBackend::Simulated {
fault: FaultConfig::torn_pages_only(),
},
workload: WorkloadModel {
weights: OpWeights {
add: 80,
delete: 10,
compact: 5,
checkpoint: 5,
..OpWeights::default()
},
size_distribution: SizeDistribution::Fixed(ValueBytes(128)),
collections: vec![
CollectionName("app.bsky.feed.post".to_string()),
CollectionName("app.bsky.feed.like".to_string()),
],
key_space: KeySpaceSize(500),
did_space: DidSpaceSize(32),
retention_max_secs: RetentionMaxSecs(3600),
},
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(60_000)),
},
restart_policy: RestartPolicy::CrashAtSyscall(OpInterval(500)),
store: StoreConfig {
max_file_size: MaxFileSize(16 * 1024),
group_commit: GroupCommitConfig {
verify_persisted_blocks: true,
..GroupCommitConfig::default()
},
shard_count: ShardCount(1),
},
eventlog: None,
writer_concurrency: WriterConcurrency(1),
};
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
let budget_violations: Vec<&str> = report
.violations
.iter()
.filter(|v| v.invariant == "WallClockBudget")
.map(|v| v.detail.as_str())
.collect();
assert!(
budget_violations.is_empty(),
"torn-pages exceeded budget: {budget_violations:?}; ops_executed={}",
report.ops_executed.0
);
assert_eq!(
report.ops_executed.0, 2_000,
"expected all ops to execute under torn-pages-only faults"
);
}
#[tokio::test]
async fn real_io_gauntlet_uses_scratch_root_for_tempdir() {
let scratch = tempfile::TempDir::new().expect("scratch dir");
let scratch_path = scratch.path().to_path_buf();
let cfg = fast_sanity_config(Seed(11));
let report = Gauntlet::new(cfg)
.expect("build gauntlet")
.with_scratch_root(scratch_path.clone())
.run()
.await;
assert_clean(&report);
let entries: Vec<std::path::PathBuf> = std::fs::read_dir(&scratch_path)
.expect("read scratch")
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
assert!(
entries.is_empty(),
"scratch root must be empty after gauntlet drop, found: {entries:?}"
);
}
#[test]
fn farm_run_many_timed_with_scratch_roots_honors_assignment() {
let scratch = tempfile::TempDir::new().expect("scratch dir");
let root_a = scratch.path().join("a");
let root_b = scratch.path().join("b");
std::fs::create_dir_all(&root_a).expect("mkdir a");
std::fs::create_dir_all(&root_b).expect("mkdir b");
let roots = vec![root_a.clone(), root_b.clone()];
let reports = farm::run_many_timed_with_scratch_roots(
|seed| fast_sanity_config(seed),
&roots,
(0..2).map(Seed),
);
assert_eq!(reports.len(), 2);
reports.iter().for_each(|(r, _)| assert_clean(r));
[&root_a, &root_b].iter().for_each(|root| {
let leftover: Vec<std::path::PathBuf> = std::fs::read_dir(root)
.expect("read scratch root")
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
assert!(
leftover.is_empty(),
"scratch root {} must be empty after farm completes, found: {leftover:?}",
root.display()
);
});
}
+1 -60
View File
@@ -12,9 +12,7 @@ use tranquil_store::blockstore::{
GroupCommitConfig, HINT_RECORD_SIZE, HintFileWriter, HintOffset, TranquilBlockStore,
WallClockMs, WriteCursor, hint_file_path,
};
use tranquil_store::{
FaultConfig, OpenOptions, SimulatedIO, StorageIO, SyncReorderWindow, sim_seed_range,
};
use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO, sim_seed_range};
use common::{Rng, advance_epoch, block_data, test_cid, with_runtime};
@@ -693,60 +691,3 @@ fn sim_multi_file_rotation_crash_recovery() {
});
});
}
#[test]
fn sim_sync_reorder_loses_first_commit_durability() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let config = BlockStoreConfig {
data_dir: dir.path().join("data"),
index_dir: dir.path().join("index"),
max_file_size: DEFAULT_MAX_FILE_SIZE,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let fault = FaultConfig {
sync_reorder_window: SyncReorderWindow(4),
..FaultConfig::none()
};
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::new(706, fault));
let cid = test_cid(0);
let data = block_data(0);
{
let s = Arc::clone(&sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(
config.clone(),
move || Arc::clone(&s),
)
.unwrap();
store
.put_blocks_blocking(vec![(cid, data.clone())])
.unwrap();
}
sim.crash();
let s = Arc::clone(&sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>>::open_with_io(config, move || {
Arc::clone(&s)
})
.unwrap();
match store.get_block_sync(&cid) {
Ok(Some(d)) => assert_eq!(
&d[..],
&data[..],
"block content mismatch after crash"
),
Ok(None) => panic!(
"durability bug: put_blocks_blocking returned Ok but block missing after crash"
),
Err(e) => panic!(
"durability bug: block read failed after crash: {e}"
),
}
});
}
+8 -176
View File
@@ -5,12 +5,10 @@ use std::sync::Arc;
use rayon::prelude::*;
use tranquil_store::eventlog::{
DidHash, EVENT_HEADER_SIZE, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag,
MAX_EVENT_PAYLOAD, SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, ValidEvent,
};
use tranquil_store::{
FaultConfig, OpenOptions, Probability, SimulatedIO, StorageIO, sim_seed_range,
DidHash, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD,
SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, ValidEvent,
};
use tranquil_store::{FaultConfig, Probability, SimulatedIO, StorageIO, sim_seed_range};
use common::Rng;
@@ -206,13 +204,13 @@ fn crash_mid_rotation_with_faults() {
EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)
}));
if let Ok(Ok(writer)) = recovery {
let recovered = writer.synced_seq().raw();
if let Ok(Ok(writer)) = recovery
&& let Ok(synced_before) = write_result
{
assert!(
recovered <= events_per_seg as u64,
"seed {seed}: recovered {recovered} > written {events_per_seg}"
writer.synced_seq().raw() <= synced_before,
"seed {seed}: recovered more events than were synced"
);
let _ = write_result;
}
});
}
@@ -1022,169 +1020,3 @@ fn aggressive_faults_group_sync_recovery() {
);
});
}
#[test]
fn sync_synced_seq_must_match_durable_valid_prefix() {
let asserted = std::sync::atomic::AtomicU64::new(0);
let range = sim_seed_range();
let total = range.end - range.start;
range.into_par_iter().for_each(|seed| {
let fault_config = FaultConfig {
partial_write_probability: Probability::new(0.05),
torn_page_probability: Probability::new(0.01),
misdirected_write_probability: Probability::new(0.01),
sync_failure_probability: Probability::new(0.03),
sync_reorder_window: tranquil_store::SyncReorderWindow(4),
..FaultConfig::none()
};
let sim = SimulatedIO::new(seed, fault_config);
let mgr = setup_manager(sim, 64 * 1024);
let Ok(mut writer) = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) else {
return;
};
let event_count = 10u64;
(1..=event_count).for_each(|i| {
let _ = append_test_event(&mut writer, i, seed);
});
let synced_through = match writer.sync() {
Ok(r) => r.synced_through.raw(),
Err(_) => return,
};
let _ = mgr.io().sync_dir(Path::new(SEGMENTS_DIR));
if synced_through == 0 {
return;
}
let Ok(handle) = mgr.open_for_read(SegmentId::new(1)) else {
return;
};
let Ok(reader) = SegmentReader::open(mgr.io(), handle.fd(), MAX_EVENT_PAYLOAD) else {
return;
};
let Ok(valid) = reader.valid_prefix() else {
return;
};
let durable_max = valid.last().map(|e| e.seq.raw()).unwrap_or(0);
asserted.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(
synced_through <= durable_max,
"seed {seed}: sync acked seq {synced_through} but durable valid prefix only reaches {durable_max}, events written: {event_count}, valid_prefix.len()={}",
valid.len()
);
});
let asserted = asserted.load(std::sync::atomic::Ordering::Relaxed);
if total >= 50 {
assert!(
asserted * 2 >= total,
"fewer than half of {total} seeds reached the durability assertion: {asserted}"
);
}
}
#[test]
fn reopen_recovers_from_torn_segment_header() {
let sim = SimulatedIO::pristine(0);
let mgr = setup_manager(sim, 64 * 1024);
{
let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap();
(1..=3).for_each(|i| {
let _ = append_test_event(&mut writer, i, 0);
});
writer.sync().unwrap();
}
mgr.shutdown();
let path = mgr.segment_path(SegmentId::new(1));
let fd = mgr
.io()
.open(&path, OpenOptions::read_write_existing())
.unwrap();
mgr.io().write_all_at(fd, 0, &[0u8; 4]).unwrap();
mgr.io().sync(fd).unwrap();
mgr.io().sync_dir(Path::new(SEGMENTS_DIR)).unwrap();
mgr.io().close(fd).unwrap();
let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)
.expect("reopen with torn header on highest-numbered segment must succeed");
assert_eq!(writer.active_segment_id(), SegmentId::new(1));
}
#[test]
fn partial_valid_sync_poisons_writer_and_acks_only_valid_prefix() {
let sim = SimulatedIO::pristine(0);
let mgr = setup_manager(sim, 64 * 1024);
let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap();
let payload = b"payload-x".to_vec();
let payload_size = payload.len();
let record_size = EVENT_RECORD_OVERHEAD + payload_size;
(1..=5u64).for_each(|i| {
writer
.append(
DidHash::from_did(&format!("did:plc:user{i}")),
EventTypeTag::COMMIT,
payload.clone(),
)
.unwrap();
});
let event_3_start = SEGMENT_HEADER_SIZE + 2 * record_size;
let event_3_checksum_offset = event_3_start + EVENT_HEADER_SIZE + payload_size;
let segment_path = mgr.segment_path(SegmentId::new(1));
let corrupt_fd = mgr
.io()
.open(&segment_path, OpenOptions::read_write_existing())
.unwrap();
mgr.io()
.write_all_at(corrupt_fd, event_3_checksum_offset as u64, &[0xFFu8; 4])
.unwrap();
mgr.io().close(corrupt_fd).unwrap();
let result = writer.sync().unwrap();
assert_eq!(
result.synced_through,
EventSequence::new(2),
"sync must ack only events 1..=2 with corrupt event 3"
);
assert_eq!(result.flushed_events.len(), 2);
assert!(writer.is_poisoned(), "writer must be poisoned after partial sync");
let append_after_poison = writer.append(
DidHash::from_did("did:plc:after"),
EventTypeTag::COMMIT,
payload.clone(),
);
assert!(
append_after_poison.is_err(),
"append must fail on poisoned writer"
);
let sync_after_poison = writer.sync();
assert!(
sync_after_poison.is_err(),
"sync must fail on poisoned writer"
);
drop(writer);
let recovered = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap();
assert_eq!(
recovered.synced_seq(),
EventSequence::new(2),
"reopen must observe synced_seq matching disk's valid prefix"
);
let valid = read_all_events(&mgr, 0);
assert_eq!(valid.len(), 2);
assert_eq!(valid[0].seq, EventSequence::new(1));
assert_eq!(valid[1].seq, EventSequence::new(2));
}
+6
View File
@@ -22,3 +22,9 @@ serde = { workspace = true }
serde_ipld_dagcbor = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
axum-test = { version = "19.1.1", features = [ "ws" ] }
sqlx = { workspace = true }
tokio-util = { workspace = true }
tracing-subscriber.workspace = true
@@ -302,6 +302,8 @@ async fn handle_socket_inner(
break;
};
info!("{msg:?}");
if let Message::Close(_) = msg {
info!("Client closed connection");
break;
@@ -312,3 +314,44 @@ async fn handle_socket_inner(
}
Ok(())
}
#[cfg(test)]
mod test {
use std::net::SocketAddr;
use std::time::Duration;
use super::super::sync_routes;
use super::*;
use axum_test::TestServer;
use tokio_util::sync::CancellationToken;
#[tokio::test]
async fn test_websockets_closing() {
// tracing_subscriber::fmt().init();
tranquil_config::ensure_test_defaults();
let state = AppState::new(CancellationToken::new()).await.unwrap();
let app = sync_routes()
.with_state(state)
.into_make_service_with_connect_info::<SocketAddr>();
let server = TestServer::builder().http_transport().build(app);
const CONNECTIONS: usize = 100;
let mut open_sockets = Vec::with_capacity(CONNECTIONS);
for _ in 0..CONNECTIONS {
let socket = server
.get_websocket("/com.atproto.sync.subscribeRepos")
.await
.into_websocket()
.await;
open_sockets.push(socket);
}
assert_eq!(SUBSCRIBER_COUNT.load(Ordering::SeqCst), CONNECTIONS);
drop(open_sockets);
// disgusting awful hack to give tokio time to poll the server futures enough times to actually drop all the
// websockets on the other end as well
tokio::time::sleep(Duration::from_millis(8)).await;
assert_eq!(SUBSCRIBER_COUNT.load(Ordering::SeqCst), 0);
}
}
+4 -109
View File
@@ -373,117 +373,12 @@
# Default value: "Tranquil PDS"
#from_name = "Tranquil PDS"
# HELO/EHLO name announced to remote SMTP servers. Applies to both
# smarthost and direct-MX modes. Defaults to the server hostname.
# Path to the `sendmail` binary.
#
# Can also be specified via environment variable `MAIL_HELO_NAME`.
#helo_name =
[email.smarthost]
# SMTP relay host. When set, mail is delivered through this host
# instead of resolving recipient MX records directly.
# Can also be specified via environment variable `SENDMAIL_PATH`.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_HOST`.
#host =
# SMTP relay port.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_PORT`.
#
# Default value: 587
#port = 587
# SMTP authentication username.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_USERNAME`.
#username =
# SMTP authentication password.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_PASSWORD`.
#password =
# TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
# alongside a password is rejected at startup to prevent transmitting
# credentials in plaintext.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_TLS`.
#
# Default value: "starttls"
#tls = "starttls"
# Max size of the connection pool.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_POOL_SIZE`.
#
# Default value: 4
#pool_size = 4
# Per-command SMTP timeout in seconds. Bounds the security handshake.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS`.
#
# Default value: 30
#command_timeout_secs = 30
# Total per-message timeout in seconds. Wraps the entire send so a
# stuck relay cannot stall the comms queue.
#
# Can also be specified via environment variable `MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS`.
#
# Default value: 60
#total_timeout_secs = 60
[email.direct_mx]
# Per-command SMTP timeout in seconds.
#
# Can also be specified via environment variable `MAIL_COMMAND_TIMEOUT_SECS`.
#
# Default value: 30
#command_timeout_secs = 30
# Total per-message timeout across all MX attempts in seconds.
#
# Can also be specified via environment variable `MAIL_TOTAL_TIMEOUT_SECS`.
#
# Default value: 60
#total_timeout_secs = 60
# Max number of concurrent direct-MX sends. Limits the load placed
# on any single recipient MX during a backlog drain.
#
# Can also be specified via environment variable `MAIL_MAX_CONCURRENT_SENDS`.
#
# Default value: 8
#max_concurrent_sends = 8
# Require STARTTLS on every MX hop. When false, TLS is
# attempted opportunistically and the session falls back to plaintext
# if the remote does not advertise STARTTLS. Set true to refuse
# plaintext delivery, at the cost of failing sends to MX hosts that
# do not support TLS.
#
# Can also be specified via environment variable `MAIL_REQUIRE_TLS`.
#
# Default value: false
#require_tls = false
[email.dkim]
# DKIM selector. When unset, outgoing mail is not signed.
#
# Can also be specified via environment variable `MAIL_DKIM_SELECTOR`.
#selector =
# DKIM signing domain.
#
# Can also be specified via environment variable `MAIL_DKIM_DOMAIN`.
#domain =
# Path to the DKIM private key in PEM format. Supports RSA and
# Ed25519 keys.
#
# Can also be specified via environment variable `MAIL_DKIM_KEY_PATH`.
#private_key_path =
# Default value: "/usr/sbin/sendmail"
#sendmail_path = "/usr/sbin/sendmail"
[discord]
# Discord bot token. When unset, Discord integration is disabled.