mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 18:56:05 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f2735023 |
@@ -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"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
|
||||
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
|
||||
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
||||
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -154,5 +154,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
|
||||
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
|
||||
}
|
||||
Generated
+734
-538
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.2"
|
||||
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"
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
FROM node:24-alpine AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
@@ -46,7 +46,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cp target/release/tranquil-server /tmp/tranquil-pds
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache msmtp ca-certificates \
|
||||
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
use std::fs;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
|
||||
use lettre::Message;
|
||||
use lettre::message::dkim::{
|
||||
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
|
||||
DkimSigningAlgorithm, DkimSigningKey,
|
||||
};
|
||||
use lettre::message::header::HeaderName;
|
||||
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||
use rsa::pkcs8::LineEnding;
|
||||
|
||||
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
|
||||
use crate::sender::SendError;
|
||||
|
||||
const SIGNED_HEADERS: &[&str] = &[
|
||||
"From",
|
||||
"Sender",
|
||||
"Reply-To",
|
||||
"To",
|
||||
"Cc",
|
||||
"Subject",
|
||||
"Date",
|
||||
"In-Reply-To",
|
||||
"References",
|
||||
"MIME-Version",
|
||||
"Content-Type",
|
||||
"Content-Transfer-Encoding",
|
||||
];
|
||||
|
||||
pub struct DkimSigner {
|
||||
config: LettreDkimConfig,
|
||||
}
|
||||
|
||||
impl DkimSigner {
|
||||
pub fn load(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
path: DkimKeyPath,
|
||||
) -> Result<Self, SendError> {
|
||||
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
|
||||
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
|
||||
})?;
|
||||
Self::from_pem(selector, domain, &pem)
|
||||
}
|
||||
|
||||
pub fn from_pem(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
pem: &str,
|
||||
) -> Result<Self, SendError> {
|
||||
let key = parse_key(pem)?;
|
||||
let canonicalization = DkimCanonicalization {
|
||||
header: DkimCanonicalizationType::Relaxed,
|
||||
body: DkimCanonicalizationType::Relaxed,
|
||||
};
|
||||
let headers = SIGNED_HEADERS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(HeaderName::new_from_ascii_str)
|
||||
.collect();
|
||||
let config = LettreDkimConfig::new(
|
||||
selector.into_inner(),
|
||||
domain.into_inner(),
|
||||
key,
|
||||
headers,
|
||||
canonicalization,
|
||||
);
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
pub fn sign(&self, message: &mut Message) {
|
||||
message.sign(&self.config);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DkimSigner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("DkimSigner")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let trimmed = input.trim_start();
|
||||
match trimmed {
|
||||
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
|
||||
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
|
||||
}
|
||||
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
|
||||
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
|
||||
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
|
||||
)),
|
||||
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
|
||||
SendError::DkimSign(format!(
|
||||
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let seed = BASE64_STANDARD.encode(key.to_bytes());
|
||||
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let pkcs1 = key
|
||||
.to_pkcs1_pem(LineEnding::LF)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
|
||||
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
Err(SendError::DkimSign(format!(
|
||||
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use rsa::pkcs1::DecodeRsaPrivateKey as _;
|
||||
|
||||
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
|
||||
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
|
||||
|
||||
fn ed25519_pkcs8_pem() -> String {
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn rsa_pkcs8_pem() -> String {
|
||||
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn signer(pem: &str) -> DkimSigner {
|
||||
DkimSigner::from_pem(
|
||||
DkimSelector::parse("default").unwrap(),
|
||||
EmailDomain::parse("nel.pet").unwrap(),
|
||||
pem,
|
||||
)
|
||||
.expect("key should load")
|
||||
}
|
||||
|
||||
fn signed_headers(signer: &DkimSigner) -> String {
|
||||
let from: Mailbox = "sender@nel.pet".parse().unwrap();
|
||||
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
|
||||
let mut message = Message::builder()
|
||||
.from(from)
|
||||
.to(to)
|
||||
.subject("Roundtrip")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body("Body".to_string())
|
||||
.unwrap();
|
||||
signer.sign(&mut message);
|
||||
String::from_utf8(message.formatted()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert!(matches!(
|
||||
parse_key("not a key"),
|
||||
Err(SendError::DkimSign(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_pem_type() {
|
||||
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
match parse_key(pem) {
|
||||
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
|
||||
other => panic!("expected unrecognized PEM error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_raw_seed_signs() {
|
||||
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs1_pem_signs() {
|
||||
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
fn assert_signed_with(raw: &str, algorithm: &str) {
|
||||
assert!(
|
||||
raw.contains("DKIM-Signature:"),
|
||||
"no signature header: {raw}"
|
||||
);
|
||||
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
|
||||
assert!(
|
||||
raw.contains("c=relaxed/relaxed"),
|
||||
"expected relaxed/relaxed canonicalization: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use lettre::Message;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::EmailDomain;
|
||||
use crate::sender::SendError;
|
||||
use crate::types::QueuedComms;
|
||||
|
||||
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
|
||||
let to: Mailbox = qc
|
||||
.recipient
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
||||
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
||||
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
||||
Message::builder()
|
||||
.from(from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.message_id(Some(message_id))
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(qc.body.clone())
|
||||
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
|
||||
let envelope = message.envelope();
|
||||
let first = envelope
|
||||
.to()
|
||||
.first()
|
||||
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
|
||||
EmailDomain::parse(first.domain())
|
||||
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{CommsChannel, CommsStatus, CommsType};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn from_mailbox() -> Mailbox {
|
||||
"Test Sender <noreply@nel.pet>".parse().unwrap()
|
||||
}
|
||||
|
||||
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
|
||||
QueuedComms {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: None,
|
||||
channel: CommsChannel::Email,
|
||||
comms_type: CommsType::Welcome,
|
||||
status: CommsStatus::Pending,
|
||||
recipient: recipient.to_string(),
|
||||
subject: subject.map(String::from),
|
||||
body: body.to_string(),
|
||||
metadata: None,
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
last_error: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
scheduled_for: Utc::now(),
|
||||
processed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_basic_message() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
let lower = raw.to_lowercase();
|
||||
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
|
||||
assert!(raw.contains("To: user@nel.pet"));
|
||||
assert!(raw.contains("Subject: Welcome"));
|
||||
assert!(lower.contains("content-type: text/plain"));
|
||||
assert!(raw.contains("Hello world."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_subject_is_encoded() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("=?utf-8?"));
|
||||
assert!(!raw.contains("héllo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injection_rejected() {
|
||||
let result = build(
|
||||
&from_mailbox(),
|
||||
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
|
||||
);
|
||||
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_crlf_does_not_inject_headers() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
||||
)
|
||||
.expect("subject CRLF should be encoded, not rejected");
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(
|
||||
!raw.contains("Bcc:"),
|
||||
"CRLF in subject must not produce a Bcc header: {raw}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("Subject: ="),
|
||||
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_id_uses_from_domain() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
let line = raw
|
||||
.lines()
|
||||
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
|
||||
.expect("message-id header present");
|
||||
assert!(
|
||||
line.contains("@nel.pet>"),
|
||||
"message-id should use From domain: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_subject_uses_default() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("Subject: Notification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_domain_extracted() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
|
||||
let d = recipient_domain(&msg).unwrap();
|
||||
assert_eq!(d.as_str(), "nel.pet");
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
pub mod dkim;
|
||||
pub mod message;
|
||||
mod mx;
|
||||
pub mod transport;
|
||||
pub mod types;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::PoolConfig;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::extension::ClientId;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub use self::dkim::DkimSigner;
|
||||
pub use self::transport::SendMode;
|
||||
use self::types::{
|
||||
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
|
||||
SmtpUsername, TlsMode,
|
||||
};
|
||||
use crate::sender::{CommsSender, SendError};
|
||||
use crate::types::{CommsChannel, QueuedComms};
|
||||
|
||||
pub struct EmailSender {
|
||||
from: Mailbox,
|
||||
mode: SendMode,
|
||||
dkim: Option<DkimSigner>,
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
|
||||
Self { from, mode, dkim }
|
||||
}
|
||||
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
|
||||
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
|
||||
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
|
||||
return Ok(None);
|
||||
};
|
||||
let from = build_from(&cfg.email.from_name, from_address)?;
|
||||
let dkim = build_dkim(&cfg.email.dkim)?;
|
||||
let mode = match cfg
|
||||
.email
|
||||
.smarthost
|
||||
.host
|
||||
.as_deref()
|
||||
.filter(|h| !h.is_empty())
|
||||
{
|
||||
Some(host) => build_smarthost(cfg, host)?,
|
||||
None => build_direct_mx(cfg)?,
|
||||
};
|
||||
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
|
||||
Ok(Some(Self { from, mode, dkim }))
|
||||
}
|
||||
}
|
||||
|
||||
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
|
||||
SendError::ConfigInvalid(format!("{field}: {error}"))
|
||||
}
|
||||
|
||||
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
|
||||
let raw = match from_name.is_empty() {
|
||||
true => from_address.to_string(),
|
||||
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
|
||||
};
|
||||
raw.parse::<Mailbox>()
|
||||
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
|
||||
}
|
||||
|
||||
fn build_smarthost(
|
||||
cfg: &tranquil_config::TranquilConfig,
|
||||
host_raw: &str,
|
||||
) -> Result<SendMode, SendError> {
|
||||
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
|
||||
let port = SmtpPort::parse(cfg.email.smarthost.port)
|
||||
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
|
||||
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
|
||||
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
|
||||
let helo = resolve_helo(cfg)?;
|
||||
let pool = PoolConfig::new()
|
||||
.max_size(cfg.email.smarthost.pool_size)
|
||||
.idle_timeout(Duration::from_secs(60));
|
||||
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
|
||||
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
|
||||
|
||||
let builder = match tls {
|
||||
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
|
||||
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
|
||||
TlsMode::Starttls => {
|
||||
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
|
||||
.map_err(|e| config_invalid("smarthost TLS setup", e))?
|
||||
}
|
||||
TlsMode::None => {
|
||||
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
|
||||
}
|
||||
};
|
||||
let builder = builder
|
||||
.port(port.as_u16())
|
||||
.hello_name(ClientId::Domain(helo.into_inner()))
|
||||
.timeout(Some(command_timeout))
|
||||
.pool_config(pool);
|
||||
let builder = match (
|
||||
cfg.email.smarthost.username.as_deref(),
|
||||
cfg.email.smarthost.password.as_deref(),
|
||||
) {
|
||||
(Some(u), Some(p)) => {
|
||||
let username =
|
||||
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
|
||||
let password =
|
||||
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
|
||||
builder.credentials(Credentials::new(
|
||||
username.into_inner(),
|
||||
password.expose().to_string(),
|
||||
))
|
||||
}
|
||||
_ => builder,
|
||||
};
|
||||
Ok(SendMode::Smarthost {
|
||||
transport: Box::new(builder.build()),
|
||||
total_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
|
||||
let helo = resolve_helo(cfg)?;
|
||||
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||||
.map(Arc::new)
|
||||
.map_err(|e| config_invalid("system DNS configuration", e))?;
|
||||
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
|
||||
Ok(SendMode::DirectMx {
|
||||
resolver,
|
||||
helo,
|
||||
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
|
||||
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
|
||||
require_tls: cfg.email.direct_mx.require_tls,
|
||||
inflight: Arc::new(Semaphore::new(max_concurrent)),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
|
||||
let raw = cfg
|
||||
.email
|
||||
.helo_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
|
||||
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
|
||||
}
|
||||
|
||||
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
|
||||
let selector = match cfg.selector.as_deref() {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let domain = cfg
|
||||
.domain
|
||||
.as_deref()
|
||||
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
|
||||
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
|
||||
})?;
|
||||
let selector = DkimSelector::parse(selector)
|
||||
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
|
||||
let domain = EmailDomain::parse(domain)
|
||||
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
|
||||
let path = DkimKeyPath::parse(key_path)
|
||||
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
|
||||
DkimSigner::load(selector, domain, path).map(Some)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommsSender for EmailSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Email
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let mut message = message::build(&self.from, notification)?;
|
||||
if let Some(signer) = &self.dkim {
|
||||
signer.sign(&mut message);
|
||||
}
|
||||
match transport::dispatch(&self.mode, message).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use hickory_resolver::error::{ResolveError, ResolveErrorKind};
|
||||
use hickory_resolver::proto::op::ResponseCode;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
use super::types::{EmailDomain, MxHost, MxPriority, MxRecord};
|
||||
use crate::sender::SendError;
|
||||
|
||||
pub async fn resolve(
|
||||
resolver: &TokioAsyncResolver,
|
||||
domain: &EmailDomain,
|
||||
) -> Result<Vec<MxRecord>, SendError> {
|
||||
match resolver.mx_lookup(domain.as_str()).await {
|
||||
Ok(lookup) => interpret_lookup(
|
||||
lookup
|
||||
.iter()
|
||||
.map(|mx| (mx.preference(), mx.exchange().clone())),
|
||||
domain,
|
||||
),
|
||||
Err(e) => classify_lookup_error(e, domain),
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret_lookup(
|
||||
items: impl IntoIterator<Item = (u16, hickory_resolver::Name)>,
|
||||
domain: &EmailDomain,
|
||||
) -> Result<Vec<MxRecord>, SendError> {
|
||||
let entries: Vec<_> = items.into_iter().collect();
|
||||
match entries.iter().any(|(_, name)| name.is_root()) {
|
||||
true => Err(SendError::DnsPermanent(format!(
|
||||
"null MX record at {}: domain refuses mail",
|
||||
domain.as_str()
|
||||
))),
|
||||
false => {
|
||||
let records: Vec<MxRecord> = entries
|
||||
.into_iter()
|
||||
.filter_map(|(prio, name)| {
|
||||
MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord {
|
||||
priority: MxPriority::new(prio),
|
||||
host,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
match records.is_empty() {
|
||||
true => implicit_mx(domain),
|
||||
false => Ok(prioritize(records)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prioritize(mut records: Vec<MxRecord>) -> Vec<MxRecord> {
|
||||
records.shuffle(&mut rand::thread_rng());
|
||||
records.sort_by_key(|r| r.priority);
|
||||
records
|
||||
}
|
||||
|
||||
fn classify_lookup_error(
|
||||
e: ResolveError,
|
||||
domain: &EmailDomain,
|
||||
) -> Result<Vec<MxRecord>, SendError> {
|
||||
match e.kind() {
|
||||
ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code {
|
||||
ResponseCode::NoError => implicit_mx(domain),
|
||||
ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!(
|
||||
"domain {} does not exist",
|
||||
domain.as_str()
|
||||
))),
|
||||
other => Err(SendError::DnsTransient(format!(
|
||||
"MX lookup for {} failed with {other}",
|
||||
domain.as_str()
|
||||
))),
|
||||
},
|
||||
_ => Err(SendError::DnsTransient(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn implicit_mx(domain: &EmailDomain) -> Result<Vec<MxRecord>, SendError> {
|
||||
MxHost::parse(domain.as_str())
|
||||
.map(|host| {
|
||||
vec![MxRecord {
|
||||
priority: MxPriority::new(0),
|
||||
host,
|
||||
}]
|
||||
})
|
||||
.map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(prio: u16, host: &str) -> MxRecord {
|
||||
MxRecord {
|
||||
priority: MxPriority::new(prio),
|
||||
host: MxHost::parse(host).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prioritize_sorts_by_priority_ascending() {
|
||||
let result = prioritize(vec![
|
||||
record(20, "mx2.nel.pet"),
|
||||
record(10, "mx1.nel.pet"),
|
||||
record(10, "mx1b.nel.pet"),
|
||||
]);
|
||||
assert_eq!(result[0].priority.as_u16(), 10);
|
||||
assert_eq!(result[1].priority.as_u16(), 10);
|
||||
assert_eq!(result[2].priority.as_u16(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prioritize_randomizes_equal_priority_order() {
|
||||
let attempts: Vec<Vec<String>> = (0..200)
|
||||
.map(|_| {
|
||||
prioritize(vec![
|
||||
record(10, "a.nel.pet"),
|
||||
record(10, "b.nel.pet"),
|
||||
record(10, "c.nel.pet"),
|
||||
record(10, "d.nel.pet"),
|
||||
])
|
||||
.into_iter()
|
||||
.map(|r| r.host.as_str().to_string())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect();
|
||||
assert!(
|
||||
distinct.len() > 1,
|
||||
"equal-priority MX order should vary across calls; got only {}",
|
||||
distinct.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_mx_uses_domain_as_host() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let result = implicit_mx(&d).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].priority.as_u16(), 0);
|
||||
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_error_response_yields_implicit_mx() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||
soa: None,
|
||||
negative_ttl: None,
|
||||
response_code: ResponseCode::NoError,
|
||||
trusted: false,
|
||||
});
|
||||
let result = classify_lookup_error(err, &d).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nxdomain_response_is_permanent() {
|
||||
let d = EmailDomain::parse("does-not-exist.invalid").unwrap();
|
||||
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||
soa: None,
|
||||
negative_ttl: None,
|
||||
response_code: ResponseCode::NXDomain,
|
||||
trusted: true,
|
||||
});
|
||||
match classify_lookup_error(err, &d) {
|
||||
Err(SendError::DnsPermanent(_)) => {}
|
||||
other => panic!("expected DnsPermanent, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn servfail_response_is_transient() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||
soa: None,
|
||||
negative_ttl: None,
|
||||
response_code: ResponseCode::ServFail,
|
||||
trusted: false,
|
||||
});
|
||||
match classify_lookup_error(err, &d) {
|
||||
Err(SendError::DnsTransient(_)) => {}
|
||||
other => panic!("expected DnsTransient, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_is_transient() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let err = ResolveError::from(ResolveErrorKind::Timeout);
|
||||
match classify_lookup_error(err, &d) {
|
||||
Err(SendError::DnsTransient(_)) => {}
|
||||
other => panic!("expected DnsTransient, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_variant_is_transient() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch"));
|
||||
match classify_lookup_error(err, &d) {
|
||||
Err(SendError::DnsTransient(_)) => {}
|
||||
other => panic!("expected DnsTransient default, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_mx_is_permanent() {
|
||||
let d = EmailDomain::parse("nomail.nel.pet").unwrap();
|
||||
let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d);
|
||||
match result {
|
||||
Err(SendError::DnsPermanent(msg)) => {
|
||||
assert!(msg.contains("null MX"), "msg: {msg}")
|
||||
}
|
||||
other => panic!("expected DnsPermanent, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_mx_alongside_real_records_still_permanent() {
|
||||
let d = EmailDomain::parse("mixed.nel.pet").unwrap();
|
||||
let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
|
||||
let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d);
|
||||
assert!(matches!(result, Err(SendError::DnsPermanent(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_lookup_uses_implicit_mx() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_records_pass_through_with_priority_sort() {
|
||||
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||
let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
|
||||
let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap();
|
||||
let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].priority.as_u16(), 10);
|
||||
assert_eq!(result[0].host.as_str(), "mx1.nel.pet");
|
||||
assert_eq!(result[1].priority.as_u16(), 20);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
|
||||
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
|
||||
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
|
||||
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
|
||||
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
|
||||
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
|
||||
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
|
||||
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
|
||||
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
|
||||
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
|
||||
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
|
||||
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
|
||||
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
|
||||
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
|
||||
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
|
||||
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
|
||||
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
|
||||
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
|
||||
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
|
||||
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
|
||||
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
|
||||
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
|
||||
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
|
||||
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
|
||||
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -1,164 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::Error as SmtpError;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::transport::smtp::extension::ClientId;
|
||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::message::recipient_domain;
|
||||
use super::mx;
|
||||
use super::types::{HeloName, MxRecord};
|
||||
use crate::sender::SendError;
|
||||
|
||||
pub enum SendMode {
|
||||
Smarthost {
|
||||
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
||||
total_timeout: Duration,
|
||||
},
|
||||
DirectMx {
|
||||
resolver: Arc<TokioAsyncResolver>,
|
||||
helo: HeloName,
|
||||
command_timeout: Duration,
|
||||
total_timeout: Duration,
|
||||
require_tls: bool,
|
||||
inflight: Arc<Semaphore>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SendMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Smarthost { total_timeout, .. } => {
|
||||
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
|
||||
}
|
||||
Self::DirectMx {
|
||||
helo, require_tls, ..
|
||||
} => write!(
|
||||
f,
|
||||
"SendMode::DirectMx({}, require_tls={require_tls})",
|
||||
helo.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
|
||||
match mode {
|
||||
SendMode::Smarthost {
|
||||
transport,
|
||||
total_timeout,
|
||||
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
||||
SendMode::DirectMx {
|
||||
resolver,
|
||||
helo,
|
||||
command_timeout,
|
||||
total_timeout,
|
||||
require_tls,
|
||||
inflight,
|
||||
} => {
|
||||
with_total_timeout(*total_timeout, async {
|
||||
let _permit =
|
||||
inflight.clone().acquire_owned().await.map_err(|_| {
|
||||
SendError::SmtpTransient("send semaphore closed".to_string())
|
||||
})?;
|
||||
send_direct(
|
||||
resolver.as_ref(),
|
||||
helo,
|
||||
*command_timeout,
|
||||
*require_tls,
|
||||
message,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
|
||||
total: Duration,
|
||||
fut: F,
|
||||
) -> Result<(), SendError> {
|
||||
tokio::time::timeout(total, fut)
|
||||
.await
|
||||
.unwrap_or(Err(SendError::Timeout))
|
||||
}
|
||||
|
||||
async fn run_send(
|
||||
transport: &AsyncSmtpTransport<Tokio1Executor>,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
transport
|
||||
.send(message)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(classify_smtp_error)
|
||||
}
|
||||
|
||||
async fn send_direct(
|
||||
resolver: &TokioAsyncResolver,
|
||||
helo: &HeloName,
|
||||
command_timeout: Duration,
|
||||
require_tls: bool,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
let domain = recipient_domain(&message)?;
|
||||
let mxs = mx::resolve(resolver, &domain).await?;
|
||||
let outcome = futures::stream::iter(mxs)
|
||||
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
match &acc {
|
||||
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
|
||||
_ => Some(
|
||||
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
|
||||
.await,
|
||||
),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
outcome.unwrap_or_else(|| {
|
||||
Err(SendError::SmtpTransient(format!(
|
||||
"no MX records returned for {}",
|
||||
domain.as_str()
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
async fn attempt_one_host(
|
||||
mx_record: MxRecord,
|
||||
helo: &HeloName,
|
||||
command_timeout: Duration,
|
||||
require_tls: bool,
|
||||
message: Message,
|
||||
) -> Result<(), SendError> {
|
||||
let host = mx_record.host.as_str().to_string();
|
||||
let tls_params = TlsParameters::new(host.clone())
|
||||
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
|
||||
let tls = match require_tls {
|
||||
true => Tls::Required(tls_params),
|
||||
false => Tls::Opportunistic(tls_params),
|
||||
};
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
|
||||
.port(25)
|
||||
.tls(tls)
|
||||
.hello_name(ClientId::Domain(helo.as_str().to_string()))
|
||||
.timeout(Some(command_timeout))
|
||||
.build();
|
||||
run_send(&transport, message).await
|
||||
}
|
||||
|
||||
fn classify_smtp_error(e: SmtpError) -> SendError {
|
||||
match () {
|
||||
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
|
||||
_ if e.is_timeout() => SendError::Timeout,
|
||||
_ => SendError::SmtpTransient(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
pub mod email;
|
||||
mod locale;
|
||||
mod sender;
|
||||
mod types;
|
||||
|
||||
pub use email::EmailSender;
|
||||
pub use locale::{
|
||||
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
|
||||
validate_locale,
|
||||
};
|
||||
pub use sender::{
|
||||
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
|
||||
is_valid_signal_username,
|
||||
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
|
||||
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
|
||||
};
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::types::{CommsChannel, QueuedComms};
|
||||
|
||||
@@ -17,51 +21,25 @@ pub trait CommsSender: Send + Sync {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SendError {
|
||||
#[error("Failed to spawn {command}: {source}")]
|
||||
ProcessSpawn {
|
||||
command: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("{command} exited with non-zero status: {detail}")]
|
||||
ProcessFailed { command: String, detail: String },
|
||||
#[error("Channel not configured: {0:?}")]
|
||||
NotConfigured(CommsChannel),
|
||||
#[error("Email configuration invalid: {0}")]
|
||||
ConfigInvalid(String),
|
||||
#[error("Invalid recipient format: {0}")]
|
||||
InvalidRecipient(String),
|
||||
#[error("Message construction failed: {0}")]
|
||||
MessageBuild(String),
|
||||
#[error("transient DNS lookup failure: {0}")]
|
||||
DnsTransient(String),
|
||||
#[error("permanent DNS lookup failure: {0}")]
|
||||
DnsPermanent(String),
|
||||
#[error("SMTP transient error: {0}")]
|
||||
SmtpTransient(String),
|
||||
#[error("SMTP permanent error: {0}")]
|
||||
SmtpPermanent(String),
|
||||
#[error("DKIM signing failed: {0}")]
|
||||
DkimSign(String),
|
||||
#[error("External service error: {0}")]
|
||||
ExternalService(String),
|
||||
#[error("Invalid recipient format: {0}")]
|
||||
InvalidRecipient(String),
|
||||
#[error("Request timeout")]
|
||||
Timeout,
|
||||
#[error("Max retries exceeded: {0}")]
|
||||
MaxRetriesExceeded(String),
|
||||
}
|
||||
|
||||
impl SendError {
|
||||
pub fn is_permanent(&self) -> bool {
|
||||
match self {
|
||||
Self::SmtpPermanent(_)
|
||||
| Self::DnsPermanent(_)
|
||||
| Self::InvalidRecipient(_)
|
||||
| Self::MessageBuild(_)
|
||||
| Self::DkimSign(_)
|
||||
| Self::ConfigInvalid(_) => true,
|
||||
Self::SmtpTransient(_)
|
||||
| Self::DnsTransient(_)
|
||||
| Self::Timeout
|
||||
| Self::ExternalService(_)
|
||||
| Self::MaxRetriesExceeded(_)
|
||||
| Self::NotConfigured(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
@@ -122,6 +100,19 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
pub fn sanitize_header_value(value: &str) -> String {
|
||||
value.replace(['\r', '\n'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
pub fn mime_encode_header(value: &str) -> String {
|
||||
if value.is_ascii() {
|
||||
sanitize_header_value(value)
|
||||
} else {
|
||||
let sanitized = sanitize_header_value(value);
|
||||
format!("=?UTF-8?B?{}?=", BASE64.encode(sanitized.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
@@ -144,6 +135,93 @@ pub fn is_valid_signal_username(username: &str) -> bool {
|
||||
tranquil_signal::SignalUsername::parse(username).is_ok()
|
||||
}
|
||||
|
||||
pub struct EmailSender {
|
||||
from_address: String,
|
||||
from_name: String,
|
||||
sendmail_path: String,
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
|
||||
Self {
|
||||
from_address,
|
||||
from_name,
|
||||
sendmail_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
||||
let from_address = cfg.email.from_address.clone()?;
|
||||
let from_name = cfg.email.from_name.clone();
|
||||
let sendmail_path = cfg.email.sendmail_path.clone();
|
||||
Some(Self::new(from_address, from_name, sendmail_path))
|
||||
}
|
||||
|
||||
pub fn format_email(&self, notification: &QueuedComms) -> String {
|
||||
let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let recipient = sanitize_header_value(¬ification.recipient);
|
||||
let from_header = if self.from_name.is_empty() {
|
||||
self.from_address.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{} <{}>",
|
||||
sanitize_header_value(&self.from_name),
|
||||
self.from_address
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
|
||||
from_header, recipient, subject, notification.body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommsSender for EmailSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Email
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let email_content = self.format_email(notification);
|
||||
let mut child = Command::new(&self.sendmail_path)
|
||||
.arg("-t")
|
||||
.arg("-oi")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(email_content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(SendError::ProcessFailed {
|
||||
command: self.sendmail_path.clone(),
|
||||
detail: stderr.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -532,28 +610,3 @@ impl CommsSender for SignalSender {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod is_permanent_matrix {
|
||||
use super::{CommsChannel, SendError};
|
||||
|
||||
#[test]
|
||||
fn permanent_variants_are_permanent() {
|
||||
assert!(SendError::SmtpPermanent("x".into()).is_permanent());
|
||||
assert!(SendError::DnsPermanent("x".into()).is_permanent());
|
||||
assert!(SendError::InvalidRecipient("x".into()).is_permanent());
|
||||
assert!(SendError::MessageBuild("x".into()).is_permanent());
|
||||
assert!(SendError::DkimSign("x".into()).is_permanent());
|
||||
assert!(SendError::ConfigInvalid("x".into()).is_permanent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_variants_are_not_permanent() {
|
||||
assert!(!SendError::SmtpTransient("x".into()).is_permanent());
|
||||
assert!(!SendError::DnsTransient("x".into()).is_permanent());
|
||||
assert!(!SendError::Timeout.is_permanent());
|
||||
assert!(!SendError::ExternalService("x".into()).is_permanent());
|
||||
assert!(!SendError::MaxRetriesExceeded("x".into()).is_permanent());
|
||||
assert!(!SendError::NotConfigured(CommsChannel::Email).is_permanent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::extension::ClientId;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tranquil_comms::email::transport::SendMode;
|
||||
use tranquil_comms::email::{EmailSender, types::HeloName};
|
||||
use tranquil_comms::{CommsChannel, CommsSender, CommsStatus, CommsType, QueuedComms, SendError};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms {
|
||||
QueuedComms {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: None,
|
||||
channel: CommsChannel::Email,
|
||||
comms_type: CommsType::Welcome,
|
||||
status: CommsStatus::Pending,
|
||||
recipient: recipient.to_string(),
|
||||
subject: Some(subject.to_string()),
|
||||
body: body.to_string(),
|
||||
metadata: None,
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
last_error: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
scheduled_for: Utc::now(),
|
||||
processed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
|
||||
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn build_smarthost_sender_with_total_timeout(
|
||||
host: &str,
|
||||
port: u16,
|
||||
total_timeout: Duration,
|
||||
) -> EmailSender {
|
||||
let from: Mailbox = "Tranquil Test <noreply@nel.pet>".parse().unwrap();
|
||||
let helo = HeloName::parse("mta.nel.pet").unwrap();
|
||||
let transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host)
|
||||
.port(port)
|
||||
.hello_name(ClientId::Domain(helo.into_inner()))
|
||||
.timeout(Some(Duration::from_secs(5)))
|
||||
.build();
|
||||
EmailSender::new(
|
||||
from,
|
||||
SendMode::Smarthost {
|
||||
transport: Box::new(transport),
|
||||
total_timeout,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
async fn drive_stub(stream: TcpStream, rcpt_response: &'static [u8]) -> std::io::Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
write.write_all(b"220 stub ESMTP\r\n").await?;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let upper = line.to_ascii_uppercase();
|
||||
let response: &[u8] = match upper.split_whitespace().next() {
|
||||
Some("EHLO") | Some("HELO") => b"250-stub\r\n250 SIZE 10240000\r\n",
|
||||
Some("MAIL") => b"250 OK\r\n",
|
||||
Some("RCPT") => rcpt_response,
|
||||
Some("DATA") => b"354 end with .\r\n",
|
||||
Some("RSET") => b"250 OK\r\n",
|
||||
Some("QUIT") => b"221 bye\r\n",
|
||||
_ => b"500 unknown\r\n",
|
||||
};
|
||||
write.write_all(response).await?;
|
||||
if upper.starts_with("QUIT") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let _ = drive_stub(stream, rcpt_response).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rcpt_550_classifies_as_smtp_permanent() {
|
||||
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await;
|
||||
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||
match result {
|
||||
Err(SendError::SmtpPermanent(_)) => {}
|
||||
other => panic!("expected SmtpPermanent, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rcpt_421_classifies_as_smtp_transient() {
|
||||
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await;
|
||||
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||
match result {
|
||||
Err(SendError::SmtpTransient(_)) => {}
|
||||
other => panic!("expected SmtpTransient, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_recipient_classifies_as_invalid_recipient() {
|
||||
let port = spawn_stub(b"250 OK\r\n").await;
|
||||
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||
let result = sender.send(&fixture("not-an-address", "x", "x")).await;
|
||||
match result {
|
||||
Err(SendError::InvalidRecipient(_)) => {}
|
||||
other => panic!("expected InvalidRecipient, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_silent_stub() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let (_stream, _) = listener.accept().await.unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smarthost_silent_relay_hits_total_timeout() {
|
||||
let port = spawn_silent_stub().await;
|
||||
let sender =
|
||||
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
|
||||
let start = std::time::Instant::now();
|
||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||
let elapsed = start.elapsed();
|
||||
match result {
|
||||
Err(SendError::Timeout) => {}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"send returned in {elapsed:?}, expected close to 500ms total_timeout"
|
||||
);
|
||||
}
|
||||
@@ -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,10 +210,6 @@ impl TranquilConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// -- email -----------------------------------------------------------
|
||||
self.email
|
||||
.validate(self.server.hostname_without_port(), &mut errors);
|
||||
|
||||
// -- telegram ---------------------------------------------------------
|
||||
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
|
||||
errors.push(
|
||||
@@ -774,278 +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,
|
||||
}
|
||||
|
||||
impl EmailConfig {
|
||||
pub fn validate(&self, server_hostname: &str, errors: &mut Vec<String>) {
|
||||
match self.smarthost.tls.to_ascii_lowercase().as_str() {
|
||||
"implicit" | "starttls" => {}
|
||||
"none" => {
|
||||
if self.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
|
||||
.smarthost
|
||||
.host
|
||||
.as_deref()
|
||||
.is_some_and(|h| !h.is_empty());
|
||||
let username_set = self.smarthost.username.is_some();
|
||||
let password_set = self.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.smarthost.command_timeout_secs == 0 {
|
||||
errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string());
|
||||
}
|
||||
if self.smarthost.total_timeout_secs == 0 {
|
||||
errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string());
|
||||
}
|
||||
if self.smarthost.pool_size == 0 {
|
||||
errors.push("email.smarthost.pool_size must be at least 1".to_string());
|
||||
}
|
||||
|
||||
if self.direct_mx.max_concurrent_sends == 0 {
|
||||
errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string());
|
||||
}
|
||||
if self.direct_mx.command_timeout_secs == 0 {
|
||||
errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string());
|
||||
}
|
||||
if self.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.dkim.selector.is_some()
|
||||
|| self.dkim.domain.is_some()
|
||||
|| self.dkim.private_key_path.is_some();
|
||||
if dkim_set {
|
||||
if self.dkim.selector.is_none() {
|
||||
errors
|
||||
.push("email.dkim.selector is required when any DKIM field is set".to_string());
|
||||
}
|
||||
if self.dkim.domain.is_none() {
|
||||
errors.push("email.dkim.domain is required when any DKIM field is set".to_string());
|
||||
}
|
||||
if self.dkim.private_key_path.is_none() {
|
||||
errors.push(
|
||||
"email.dkim.private_key_path is required when any DKIM field is set"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(from_address) = self.from_address.as_deref().filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !looks_like_email_address(from_address) {
|
||||
errors.push(format!(
|
||||
"email.from_address {from_address:?} is not a valid email address"
|
||||
));
|
||||
}
|
||||
if self.from_name.chars().any(|c| c.is_control()) {
|
||||
errors.push("email.from_name must not contain control characters".to_string());
|
||||
}
|
||||
|
||||
let helo_raw = self
|
||||
.helo_name
|
||||
.as_deref()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| server_hostname.to_string());
|
||||
if !is_non_whitespace_token(&helo_raw) {
|
||||
errors.push(format!(
|
||||
"email HELO name {helo_raw:?} must be non-empty and contain no whitespace"
|
||||
));
|
||||
}
|
||||
|
||||
if smarthost_host_set {
|
||||
let host = self.smarthost.host.as_deref().unwrap_or("");
|
||||
if !is_non_whitespace_token(host) {
|
||||
errors.push(format!(
|
||||
"email.smarthost.host {host:?} must contain no whitespace"
|
||||
));
|
||||
}
|
||||
if self.smarthost.port == 0 {
|
||||
errors.push("email.smarthost.port must be non-zero".to_string());
|
||||
}
|
||||
if let Some(u) = self.smarthost.username.as_deref()
|
||||
&& u.is_empty()
|
||||
{
|
||||
errors.push("email.smarthost.username must be non-empty".to_string());
|
||||
}
|
||||
if let Some(p) = self.smarthost.password.as_deref()
|
||||
&& p.is_empty()
|
||||
{
|
||||
errors.push("email.smarthost.password must be non-empty".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(selector) = self.dkim.selector.as_deref()
|
||||
&& !is_valid_dkim_selector(selector)
|
||||
{
|
||||
errors.push(format!(
|
||||
"email.dkim.selector {selector:?} must be valid subdomain syntax"
|
||||
));
|
||||
}
|
||||
if let Some(domain) = self.dkim.domain.as_deref()
|
||||
&& !is_non_whitespace_token(domain)
|
||||
{
|
||||
errors.push(format!(
|
||||
"email.dkim.domain {domain:?} must be non-empty and contain no whitespace"
|
||||
));
|
||||
}
|
||||
if let Some(key_path) = self.dkim.private_key_path.as_deref()
|
||||
&& key_path.trim().is_empty()
|
||||
{
|
||||
errors.push("email.dkim.private_key_path must be non-empty".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_email_address(s: &str) -> bool {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) {
|
||||
return false;
|
||||
}
|
||||
let mut parts = trimmed.split('@');
|
||||
let local = parts.next().unwrap_or("");
|
||||
let domain = parts.next().unwrap_or("");
|
||||
parts.next().is_none() && !local.is_empty() && !domain.is_empty() && domain.contains('.')
|
||||
}
|
||||
|
||||
fn is_non_whitespace_token(s: &str) -> bool {
|
||||
let trimmed = s.trim();
|
||||
!trimmed.is_empty() && !trimmed.chars().any(char::is_whitespace)
|
||||
}
|
||||
|
||||
fn is_valid_dkim_selector(s: &str) -> bool {
|
||||
let trimmed = s.trim();
|
||||
!trimmed.is_empty()
|
||||
&& trimmed.split('.').all(|seg| {
|
||||
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, 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)]
|
||||
@@ -1485,189 +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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_address_predicate_accepts_typical_addresses() {
|
||||
assert!(looks_like_email_address("alice@nel.pet"));
|
||||
assert!(looks_like_email_address("a.b+tag@example.co.uk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_address_predicate_rejects_malformed() {
|
||||
assert!(!looks_like_email_address(""));
|
||||
assert!(!looks_like_email_address("no-at-sign"));
|
||||
assert!(!looks_like_email_address("@nel.pet"));
|
||||
assert!(!looks_like_email_address("alice@"));
|
||||
assert!(!looks_like_email_address("alice@nel"));
|
||||
assert!(!looks_like_email_address("a@b@c.com"));
|
||||
assert!(!looks_like_email_address("alice @nel.pet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dkim_selector_predicate_matches_subdomain_syntax() {
|
||||
assert!(is_valid_dkim_selector("default"));
|
||||
assert!(is_valid_dkim_selector("s2024-q1"));
|
||||
assert!(is_valid_dkim_selector("mailo-2024.nel.pet"));
|
||||
assert!(!is_valid_dkim_selector(""));
|
||||
assert!(!is_valid_dkim_selector("a..b"));
|
||||
assert!(!is_valid_dkim_selector("-leading"));
|
||||
assert!(!is_valid_dkim_selector("trailing-"));
|
||||
assert!(!is_valid_dkim_selector("s_under"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validate_disabled_when_from_address_unset() {
|
||||
let cfg = email_config_for_test(EmailOverrides::default());
|
||||
let mut errors = Vec::new();
|
||||
cfg.validate("test.local", &mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validate_rejects_bad_from_address() {
|
||||
let cfg = email_config_for_test(EmailOverrides {
|
||||
from_address: Some("not-an-email"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut errors = Vec::new();
|
||||
cfg.validate("test.local", &mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("from_address")),
|
||||
"expected from_address error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validate_rejects_smarthost_with_bad_credentials() {
|
||||
let cfg = email_config_for_test(EmailOverrides {
|
||||
from_address: Some("alice@nel.pet"),
|
||||
smarthost_host: Some("smtp.nel.pet"),
|
||||
smarthost_username: Some(""),
|
||||
smarthost_password: Some("hunter2"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut errors = Vec::new();
|
||||
cfg.validate("test.local", &mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("smarthost.username")),
|
||||
"expected smarthost.username error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validate_rejects_bad_dkim_selector() {
|
||||
let cfg = email_config_for_test(EmailOverrides {
|
||||
from_address: Some("alice@nel.pet"),
|
||||
dkim_selector: Some("-bad"),
|
||||
dkim_domain: Some("nel.pet"),
|
||||
dkim_key_path: Some("/etc/dkim.key"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut errors = Vec::new();
|
||||
cfg.validate("test.local", &mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("dkim.selector")),
|
||||
"expected dkim.selector error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmailOverrides {
|
||||
from_address: Option<&'static str>,
|
||||
smarthost_host: Option<&'static str>,
|
||||
smarthost_username: Option<&'static str>,
|
||||
smarthost_password: Option<&'static str>,
|
||||
dkim_selector: Option<&'static str>,
|
||||
dkim_domain: Option<&'static str>,
|
||||
dkim_key_path: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn email_config_for_test(o: EmailOverrides) -> EmailConfig {
|
||||
EmailConfig {
|
||||
from_address: o.from_address.map(str::to_string),
|
||||
from_name: "Tranquil PDS".to_string(),
|
||||
helo_name: None,
|
||||
smarthost: SmarthostConfig {
|
||||
host: o.smarthost_host.map(str::to_string),
|
||||
port: 587,
|
||||
username: o.smarthost_username.map(str::to_string),
|
||||
password: o.smarthost_password.map(str::to_string),
|
||||
tls: "starttls".to_string(),
|
||||
pool_size: 4,
|
||||
command_timeout_secs: 30,
|
||||
total_timeout_secs: 60,
|
||||
},
|
||||
direct_mx: DirectMxConfig {
|
||||
command_timeout_secs: 30,
|
||||
total_timeout_secs: 60,
|
||||
max_concurrent_sends: 8,
|
||||
require_tls: false,
|
||||
},
|
||||
dkim: DkimConfig {
|
||||
selector: o.dkim_selector.map(str::to_string),
|
||||
domain: o.dkim_domain.map(str::to_string),
|
||||
private_key_path: o.dkim_key_path.map(str::to_string),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,8 +244,6 @@ pub trait InfraRepository: Send + Sync {
|
||||
|
||||
async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>;
|
||||
|
||||
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError>;
|
||||
|
||||
async fn create_invite_code(
|
||||
&self,
|
||||
code: &str,
|
||||
|
||||
@@ -65,13 +65,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
SET status = 'processing', updated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM comms_queue
|
||||
WHERE attempts < max_attempts
|
||||
WHERE status = 'pending'
|
||||
AND scheduled_for <= $1
|
||||
AND (
|
||||
status = 'pending'
|
||||
OR (status = 'processing'
|
||||
AND updated_at < $1 - INTERVAL '10 minutes')
|
||||
)
|
||||
AND attempts < max_attempts
|
||||
ORDER BY scheduled_for ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@@ -131,24 +127,6 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"UPDATE comms_queue
|
||||
SET status = 'failed'::comms_status,
|
||||
attempts = max_attempts,
|
||||
last_error = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1"#,
|
||||
id,
|
||||
error
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_invite_code(
|
||||
&self,
|
||||
code: &str,
|
||||
|
||||
@@ -374,7 +374,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
WHERE id IN (
|
||||
SELECT id FROM oauth_token
|
||||
WHERE did = $1
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY updated_at ASC
|
||||
OFFSET $2
|
||||
)
|
||||
"#,
|
||||
|
||||
@@ -55,7 +55,6 @@ metrics-exporter-prometheus = { workspace = true }
|
||||
multibase = { workspace = true }
|
||||
multihash = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
regex = { workspace = true }
|
||||
|
||||
@@ -21,8 +21,6 @@ pub enum ApiError {
|
||||
InvalidToken(Option<String>),
|
||||
ExpiredToken(Option<String>),
|
||||
OAuthExpiredToken(Option<String>),
|
||||
UseDpopNonce(String),
|
||||
InvalidDpopProof(String),
|
||||
TokenRequired,
|
||||
AccountDeactivated,
|
||||
AccountTakedown,
|
||||
@@ -139,8 +137,6 @@ impl ApiError {
|
||||
| Self::InvalidToken(_)
|
||||
| Self::PasskeyCounterAnomaly
|
||||
| Self::OAuthExpiredToken(_)
|
||||
| Self::UseDpopNonce(_)
|
||||
| Self::InvalidDpopProof(_)
|
||||
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
|
||||
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
|
||||
@@ -240,8 +236,6 @@ impl ApiError {
|
||||
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
|
||||
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
|
||||
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
|
||||
Self::UseDpopNonce(_) => Cow::Borrowed("use_dpop_nonce"),
|
||||
Self::InvalidDpopProof(_) => Cow::Borrowed("invalid_dpop_proof"),
|
||||
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
|
||||
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
||||
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
||||
@@ -341,8 +335,6 @@ impl ApiError {
|
||||
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
|
||||
msg.clone().unwrap_or_else(|| "Token has expired".into())
|
||||
}
|
||||
Self::UseDpopNonce(_) => "DPoP nonce required".into(),
|
||||
Self::InvalidDpopProof(msg) => msg.clone(),
|
||||
Self::RepoNotFound(msg) => msg
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Repository not found".into()),
|
||||
@@ -568,36 +560,6 @@ impl IntoResponse for ApiError {
|
||||
),
|
||||
);
|
||||
}
|
||||
Self::UseDpopNonce(nonce) => {
|
||||
match HeaderValue::from_str(nonce) {
|
||||
Ok(val) => {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(crate::util::HEADER_DPOP_NONCE, val);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
?err,
|
||||
nonce_len = nonce.len(),
|
||||
"generated DPoP nonce is not a valid header value"
|
||||
);
|
||||
}
|
||||
}
|
||||
response.headers_mut().insert(
|
||||
http::header::WWW_AUTHENTICATE,
|
||||
HeaderValue::from_static(
|
||||
"DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\"",
|
||||
),
|
||||
);
|
||||
}
|
||||
Self::InvalidDpopProof(_) => {
|
||||
response.headers_mut().insert(
|
||||
http::header::WWW_AUTHENTICATE,
|
||||
HeaderValue::from_static(
|
||||
"DPoP error=\"invalid_dpop_proof\", error_description=\"Invalid DPoP proof\"",
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
response
|
||||
@@ -634,8 +596,6 @@ impl From<crate::auth::TokenValidationError> for ApiError {
|
||||
crate::auth::TokenValidationError::InvalidToken => {
|
||||
Self::AuthenticationFailed(Some("Invalid token format".to_string()))
|
||||
}
|
||||
crate::auth::TokenValidationError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
|
||||
crate::auth::TokenValidationError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,8 +625,10 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
|
||||
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
|
||||
Self::OAuthExpiredToken(Some(msg))
|
||||
}
|
||||
crate::auth::extractor::AuthError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
|
||||
crate::auth::extractor::AuthError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
|
||||
crate::auth::extractor::AuthError::UseDpopNonce(_)
|
||||
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
|
||||
Self::AuthenticationFailed(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::marker::PhantomData;
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri},
|
||||
http::{header::AUTHORIZATION, request::Parts},
|
||||
http::{StatusCode, header::AUTHORIZATION, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use tracing::{debug, error, info};
|
||||
@@ -35,7 +35,32 @@ pub enum AuthError {
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
ApiError::from(self).into_response()
|
||||
match self {
|
||||
Self::UseDpopNonce(nonce) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[
|
||||
("DPoP-Nonce", nonce.as_str()),
|
||||
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
|
||||
],
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "use_dpop_nonce",
|
||||
"message": "DPoP nonce required"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
|
||||
Self::InvalidDpopProof(msg) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "invalid_dpop_proof",
|
||||
"message": msg
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(),
|
||||
other => ApiError::from(other).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ struct CachedUserStatus {
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenValidationError {
|
||||
AccountDeactivated,
|
||||
AccountTakedown,
|
||||
@@ -115,8 +115,6 @@ pub enum TokenValidationError {
|
||||
TokenExpired,
|
||||
OAuthTokenExpired,
|
||||
InvalidToken,
|
||||
UseDpopNonce(String),
|
||||
InvalidDpopProof(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenValidationError {
|
||||
@@ -128,8 +126,6 @@ impl fmt::Display for TokenValidationError {
|
||||
Self::AuthenticationFailed => write!(f, "AuthenticationFailed"),
|
||||
Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"),
|
||||
Self::InvalidToken => write!(f, "InvalidToken"),
|
||||
Self::UseDpopNonce(_) => write!(f, "use_dpop_nonce"),
|
||||
Self::InvalidDpopProof(_) => write!(f, "invalid_dpop_proof"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -617,12 +613,6 @@ pub async fn validate_token_with_dpop(
|
||||
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
|
||||
Err(TokenValidationError::OAuthTokenExpired)
|
||||
}
|
||||
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
|
||||
Err(TokenValidationError::UseDpopNonce(nonce))
|
||||
}
|
||||
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
|
||||
Err(TokenValidationError::InvalidDpopProof(msg))
|
||||
}
|
||||
Err(_) => Err(TokenValidationError::AuthenticationFailed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ pub use tranquil_comms::{
|
||||
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
|
||||
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
|
||||
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
|
||||
validate_locale,
|
||||
mime_encode_header, sanitize_header_value, validate_locale,
|
||||
};
|
||||
|
||||
pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
|
||||
|
||||
@@ -149,19 +149,13 @@ impl CommsService {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let permanent = e.is_permanent();
|
||||
let error_msg = e.to_string();
|
||||
warn!(
|
||||
comms_id = %comms_id,
|
||||
error = %error_msg,
|
||||
permanent,
|
||||
"Failed to send comms"
|
||||
);
|
||||
let db_result = match permanent {
|
||||
true => self.mark_failed_permanent(comms_id, &error_msg).await,
|
||||
false => self.mark_failed(comms_id, &error_msg).await,
|
||||
};
|
||||
if let Err(db_err) = db_result {
|
||||
if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await {
|
||||
error!(
|
||||
comms_id = %comms_id,
|
||||
error = %db_err,
|
||||
@@ -179,14 +173,6 @@ impl CommsService {
|
||||
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), tranquil_db_traits::DbError> {
|
||||
self.infra_repo.mark_comms_failed(id, error).await
|
||||
}
|
||||
|
||||
async fn mark_failed_permanent(
|
||||
&self,
|
||||
id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<(), tranquil_db_traits::DbError> {
|
||||
self.infra_repo.mark_comms_failed_permanent(id, error).await
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedRecipient {
|
||||
|
||||
@@ -526,8 +526,10 @@ pub async fn commit_and_log(
|
||||
|
||||
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
|
||||
|
||||
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> =
|
||||
ops.iter().map(|op| (op.collection_rkey(), op)).collect();
|
||||
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> = ops
|
||||
.iter()
|
||||
.map(|op| (op.collection_rkey(), op))
|
||||
.collect();
|
||||
|
||||
let final_record_uris: HashSet<AtUri> = final_ops
|
||||
.iter()
|
||||
|
||||
@@ -399,10 +399,6 @@ pub async fn start_scheduled_tasks(
|
||||
let mut compaction_ticker = interval(compaction_interval);
|
||||
compaction_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let compaction_blocklist = Arc::new(parking_lot::Mutex::new(CompactionBlocklist::new(
|
||||
Duration::from_secs(300),
|
||||
)));
|
||||
|
||||
let mut reachability_ticker = interval(reachability_interval);
|
||||
reachability_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
@@ -468,9 +464,8 @@ pub async fn start_scheduled_tasks(
|
||||
let store = store.clone();
|
||||
let threshold = cfg.scheduled.compaction_liveness_threshold;
|
||||
let grace_ms = cfg.scheduled.compaction_grace_period_ms;
|
||||
let blocklist = Arc::clone(&compaction_blocklist);
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
run_compaction_pass(&store, threshold, grace_ms, &blocklist)
|
||||
run_compaction_pass(&store, threshold, grace_ms)
|
||||
}).await.unwrap_or_else(|e| Err(anyhow::anyhow!("compaction task panicked: {e}"))) {
|
||||
error!("Compaction error: {e}");
|
||||
}
|
||||
@@ -490,8 +485,6 @@ pub async fn start_scheduled_tasks(
|
||||
live_refcounted = result.live_refcounted,
|
||||
leaked_blocks = result.leaked_blocks,
|
||||
repaired_blocks = result.repaired_blocks,
|
||||
phantom_files_purged = result.phantom_files_purged,
|
||||
phantom_blocks_purged = result.phantom_blocks_purged,
|
||||
bloom_heap_mb = result.bloom_heap_bytes / (1024 * 1024),
|
||||
"reachability walk complete"
|
||||
);
|
||||
@@ -543,44 +536,11 @@ pub async fn start_scheduled_tasks(
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CompactionBlocklist {
|
||||
entries: std::collections::HashMap<tranquil_store::blockstore::DataFileId, std::time::Instant>,
|
||||
cool_off: Duration,
|
||||
}
|
||||
|
||||
impl CompactionBlocklist {
|
||||
pub fn new(cool_off: Duration) -> Self {
|
||||
Self {
|
||||
entries: std::collections::HashMap::new(),
|
||||
cool_off,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(&mut self, file_id: tranquil_store::blockstore::DataFileId) {
|
||||
self.entries.insert(file_id, std::time::Instant::now());
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, file_id: tranquil_store::blockstore::DataFileId) -> bool {
|
||||
self.entries
|
||||
.get(&file_id)
|
||||
.is_some_and(|recorded| recorded.elapsed() < self.cool_off)
|
||||
}
|
||||
|
||||
pub fn prune_expired(&mut self) {
|
||||
let cool_off = self.cool_off;
|
||||
self.entries
|
||||
.retain(|_, recorded| recorded.elapsed() < cool_off);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_compaction_pass(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
liveness_threshold: f64,
|
||||
grace_period_ms: u64,
|
||||
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
|
||||
) -> anyhow::Result<()> {
|
||||
blocklist.lock().prune_expired();
|
||||
|
||||
match store.cleanup_gc_meta() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(count = n, "cleaned up stale gc_meta entries"),
|
||||
@@ -593,11 +553,7 @@ fn run_compaction_pass(
|
||||
|
||||
let candidate = liveness_map
|
||||
.iter()
|
||||
.filter(|(fid, info)| {
|
||||
info.total_blocks > 0
|
||||
&& info.ratio() < liveness_threshold
|
||||
&& !blocklist.lock().is_blocked(**fid)
|
||||
})
|
||||
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < liveness_threshold)
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
a.ratio()
|
||||
.partial_cmp(&b.ratio())
|
||||
@@ -618,35 +574,21 @@ fn run_compaction_pass(
|
||||
"compacting data file"
|
||||
);
|
||||
match store.compact_file(file_id, grace_period_ms) {
|
||||
Ok(tranquil_store::blockstore::CompactionResult::Compacted(stats)) => {
|
||||
Ok(result) => {
|
||||
info!(
|
||||
file_id = %stats.file_id,
|
||||
reclaimed_bytes = stats.reclaimed_bytes,
|
||||
live_blocks = stats.live_blocks,
|
||||
dead_blocks = stats.dead_blocks,
|
||||
file_id = %result.file_id,
|
||||
reclaimed_bytes = result.reclaimed_bytes,
|
||||
live_blocks = result.live_blocks,
|
||||
dead_blocks = result.dead_blocks,
|
||||
"compaction complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(tranquil_store::blockstore::CompactionResult::Purged {
|
||||
file_id,
|
||||
phantom_blocks,
|
||||
}) => {
|
||||
warn!(
|
||||
file_id = %file_id,
|
||||
phantom_blocks,
|
||||
"compaction target missing on disk, purged phantom index entries"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {
|
||||
debug!(file_id = %file_id, "skipped active file");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
blocklist.lock().record_failure(file_id);
|
||||
Err(anyhow::anyhow!("compaction failed: {e}"))
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("compaction failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,24 +693,6 @@ pub async fn generate_repo_car(
|
||||
.await
|
||||
.context("Failed to fetch blocks")?;
|
||||
|
||||
let missing: Vec<Cid> = chunk
|
||||
.iter()
|
||||
.zip(blocks.iter())
|
||||
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
missing.len(),
|
||||
missing
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
chunk
|
||||
.iter()
|
||||
.zip(blocks.iter())
|
||||
@@ -822,8 +746,6 @@ pub struct ReachabilityResult {
|
||||
pub leaked_blocks: u64,
|
||||
pub repaired_blocks: u64,
|
||||
pub bloom_heap_bytes: usize,
|
||||
pub phantom_files_purged: u64,
|
||||
pub phantom_blocks_purged: u64,
|
||||
}
|
||||
|
||||
const REPO_PAGE_SIZE: i64 = 500;
|
||||
@@ -839,7 +761,6 @@ fn walk_repo_dag_sync(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
head_cid: &Cid,
|
||||
reachable: &mut std::collections::HashSet<CidBytes>,
|
||||
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut to_visit = vec![cid_to_bytes(head_cid)?];
|
||||
|
||||
@@ -848,56 +769,15 @@ fn walk_repo_dag_sync(
|
||||
continue;
|
||||
}
|
||||
|
||||
let block = match store.get_block_sync(&cid_bytes) {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
let block = match store.get_block_sync(&cid_bytes)? {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
?cid_bytes,
|
||||
"referenced block missing during reachability walk"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
let Some(entry) = store.block_index().get(&cid_bytes) else {
|
||||
tracing::warn!(
|
||||
?cid_bytes,
|
||||
error = %e,
|
||||
"reachability walk: index entry vanished between read attempt and re-check"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let file_path = store.data_file_path(entry.location.file_id);
|
||||
match file_path.try_exists() {
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
?cid_bytes,
|
||||
file_id = %entry.location.file_id,
|
||||
error = %e,
|
||||
"indexed block points at missing data file, scheduling phantom purge"
|
||||
);
|
||||
phantom_files.insert(entry.location.file_id);
|
||||
continue;
|
||||
}
|
||||
Ok(true) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"reachability walk read error on present data file {}: {e}",
|
||||
entry.location.file_id
|
||||
));
|
||||
}
|
||||
Err(probe_err) => {
|
||||
tracing::warn!(
|
||||
?cid_bytes,
|
||||
file_id = %entry.location.file_id,
|
||||
existence_probe_error = %probe_err,
|
||||
"could not probe data file existence after read error"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"reachability walk read error on file {}: {e}",
|
||||
entry.location.file_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(commit) = Commit::from_cbor(&block) {
|
||||
@@ -978,15 +858,13 @@ pub fn run_reachability_walk(
|
||||
|
||||
let mut repos_walked: u64 = 0;
|
||||
let mut seen_heads: std::collections::HashMap<Did, CidLink> = std::collections::HashMap::new();
|
||||
let mut phantom_files: std::collections::HashSet<tranquil_store::blockstore::DataFileId> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
paginate_repos(&rt, repo_repo, |page| {
|
||||
page.iter().try_for_each(|repo| -> anyhow::Result<()> {
|
||||
let cid =
|
||||
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
|
||||
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
|
||||
walk_repo_dag_sync(store, &cid, &mut visited, &mut phantom_files)?;
|
||||
walk_repo_dag_sync(store, &cid, &mut visited)?;
|
||||
repos_walked = repos_walked.saturating_add(1);
|
||||
if repos_walked.is_multiple_of(1000) {
|
||||
info!(
|
||||
@@ -1016,7 +894,7 @@ pub fn run_reachability_walk(
|
||||
let cid =
|
||||
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
|
||||
let mut extra = std::collections::HashSet::new();
|
||||
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
|
||||
walk_repo_dag_sync(store, &cid, &mut extra)?;
|
||||
extra.iter().for_each(|c| reachable.insert(c));
|
||||
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
|
||||
stale_repos = stale_repos.saturating_add(1);
|
||||
@@ -1044,7 +922,7 @@ pub fn run_reachability_walk(
|
||||
let cid =
|
||||
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
|
||||
let mut extra = std::collections::HashSet::new();
|
||||
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
|
||||
walk_repo_dag_sync(store, &cid, &mut extra)?;
|
||||
extra.iter().for_each(|c| reachable.insert(c));
|
||||
quiesced_stale = quiesced_stale.saturating_add(1);
|
||||
Ok(())
|
||||
@@ -1080,19 +958,6 @@ pub fn run_reachability_walk(
|
||||
}
|
||||
};
|
||||
|
||||
let phantom_files_purged = u64::try_from(phantom_files.len()).unwrap_or(u64::MAX);
|
||||
let phantom_blocks_purged = phantom_files
|
||||
.iter()
|
||||
.map(|fid| store.block_index().purge_by_file_id(*fid))
|
||||
.sum::<u64>();
|
||||
|
||||
if phantom_files_purged > 0 {
|
||||
warn!(
|
||||
phantom_files_purged,
|
||||
phantom_blocks_purged, "purged phantom index entries from unreadable data files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ReachabilityResult {
|
||||
repos_walked,
|
||||
blocks_visited,
|
||||
@@ -1100,7 +965,5 @@ pub fn run_reachability_walk(
|
||||
leaked_blocks,
|
||||
repaired_blocks,
|
||||
bloom_heap_bytes,
|
||||
phantom_files_purged,
|
||||
phantom_blocks_purged,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -580,18 +577,6 @@ fn wire_tranquil_store(
|
||||
"repaired orphan data files"
|
||||
);
|
||||
}
|
||||
if repair.orphan_hints_removed > 0 {
|
||||
tracing::info!(
|
||||
removed = repair.orphan_hints_removed,
|
||||
"repaired orphan hint files"
|
||||
);
|
||||
}
|
||||
if repair.phantom_index_entries_purged > 0 {
|
||||
tracing::info!(
|
||||
purged = repair.phantom_index_entries_purged,
|
||||
"purged phantom index entries pointing at missing data files"
|
||||
);
|
||||
}
|
||||
if repair.had_errors() {
|
||||
tracing::warn!(errors = repair.repair_errors, "some repairs failed");
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use common::{base_url, client, get_test_db_pool, get_test_repos};
|
||||
use helpers::verify_new_account;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_types::Did;
|
||||
|
||||
async fn create_account_and_get_did(handle: &str, email: &str, password: &str) -> Did {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createAccount request failed");
|
||||
assert_eq!(res.status(), StatusCode::OK, "createAccount failed");
|
||||
let body: Value = res.json().await.expect("invalid createAccount JSON");
|
||||
let did_str = body["did"]
|
||||
.as_str()
|
||||
.expect("no did in response")
|
||||
.to_string();
|
||||
let _ = verify_new_account(&client, &did_str).await;
|
||||
Did::new(did_str).expect("invalid DID format")
|
||||
}
|
||||
|
||||
async fn insert_token_with_created_at(
|
||||
pool: &sqlx::PgPool,
|
||||
did: &Did,
|
||||
token_id: &str,
|
||||
created_at: DateTime<Utc>,
|
||||
) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO oauth_token (
|
||||
did, token_id, created_at, updated_at, expires_at,
|
||||
client_id, client_auth, parameters
|
||||
) VALUES ($1, $2, $3, $3, $4, $5, $6::jsonb, $7::jsonb)
|
||||
"#,
|
||||
)
|
||||
.bind(did.as_str())
|
||||
.bind(token_id)
|
||||
.bind(created_at)
|
||||
.bind(created_at + Duration::hours(1))
|
||||
.bind("https://test.example/client")
|
||||
.bind(r#"{"method":"none"}"#)
|
||||
.bind(
|
||||
r#"{"response_type":"code","client_id":"https://test.example/client","redirect_uri":"https://test.example/cb","code_challenge":"x","code_challenge_method":"S256"}"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("token insert failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_oldest_tokens_evicts_lowest_created_at() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("tok-evict-{}.test", ts);
|
||||
let email = format!("tok-evict-{}@test.com", ts);
|
||||
let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await;
|
||||
|
||||
let pool = get_test_db_pool().await;
|
||||
let repos = get_test_repos().await;
|
||||
|
||||
let base = Utc::now();
|
||||
let token_ids: Vec<String> = (0..5).map(|i| format!("tok-{}-{}", ts, i)).collect();
|
||||
|
||||
for (i, tid) in token_ids.iter().enumerate() {
|
||||
let created = base + Duration::seconds(i as i64);
|
||||
insert_token_with_created_at(pool, &did, tid, created).await;
|
||||
}
|
||||
|
||||
let count_before = repos
|
||||
.oauth
|
||||
.count_tokens_for_user(&did)
|
||||
.await
|
||||
.expect("count failed");
|
||||
assert_eq!(count_before, 5, "all 5 tokens should be present");
|
||||
|
||||
let deleted = repos
|
||||
.oauth
|
||||
.delete_oldest_tokens_for_user(&did, 3)
|
||||
.await
|
||||
.expect("delete failed");
|
||||
assert_eq!(deleted, 2, "two oldest tokens should be deleted");
|
||||
|
||||
let remaining = repos
|
||||
.oauth
|
||||
.list_tokens_for_user(&did)
|
||||
.await
|
||||
.expect("list failed");
|
||||
assert_eq!(remaining.len(), 3, "three newest tokens should remain");
|
||||
|
||||
let remaining_ids: std::collections::HashSet<String> =
|
||||
remaining.iter().map(|t| t.token_id.0.clone()).collect();
|
||||
let expected_ids: std::collections::HashSet<String> = token_ids[2..].iter().cloned().collect();
|
||||
assert_eq!(
|
||||
remaining_ids, expected_ids,
|
||||
"surviving tokens must be the three newest by created_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_oldest_tokens_no_op_when_under_keep_count() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("tok-evict-noop-{}.test", ts);
|
||||
let email = format!("tok-evict-noop-{}@test.com", ts);
|
||||
let did = create_account_and_get_did(&handle, &email, "EvictTest123!").await;
|
||||
|
||||
let pool = get_test_db_pool().await;
|
||||
let repos = get_test_repos().await;
|
||||
|
||||
let base = Utc::now();
|
||||
for i in 0..2 {
|
||||
let tid = format!("noop-tok-{}-{}", ts, i);
|
||||
let created = base + Duration::seconds(i);
|
||||
insert_token_with_created_at(pool, &did, &tid, created).await;
|
||||
}
|
||||
|
||||
let deleted = repos
|
||||
.oauth
|
||||
.delete_oldest_tokens_for_user(&did, 5)
|
||||
.await
|
||||
.expect("delete failed");
|
||||
assert_eq!(deleted, 0, "nothing to delete when count <= keep");
|
||||
|
||||
let remaining = repos
|
||||
.oauth
|
||||
.list_tokens_for_user(&did)
|
||||
.await
|
||||
.expect("list failed");
|
||||
assert_eq!(remaining.len(), 2);
|
||||
}
|
||||
@@ -402,7 +402,10 @@ async fn test_apply_writes_delete_then_create_same_rkey() {
|
||||
"{}/xrpc/com.atproto.repo.listRecords",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list records");
|
||||
@@ -481,7 +484,10 @@ async fn test_apply_writes_create_then_delete_same_rkey() {
|
||||
"{}/xrpc/com.atproto.repo.listRecords",
|
||||
base_url().await
|
||||
))
|
||||
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list records");
|
||||
|
||||
@@ -1,7 +1,43 @@
|
||||
mod common;
|
||||
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username};
|
||||
use tranquil_pds::comms::{
|
||||
SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value,
|
||||
};
|
||||
use tranquil_pds::image::{ImageError, ImageProcessor};
|
||||
|
||||
#[test]
|
||||
fn test_header_injection_sanitization() {
|
||||
let malicious = "Injected\r\nBcc: attacker@evil.com";
|
||||
let sanitized = sanitize_header_value(malicious);
|
||||
assert!(!sanitized.contains('\r') && !sanitized.contains('\n'));
|
||||
assert!(sanitized.contains("Injected") && sanitized.contains("Bcc:"));
|
||||
|
||||
let normal = "Normal Subject Line";
|
||||
assert_eq!(sanitize_header_value(normal), "Normal Subject Line");
|
||||
|
||||
let padded = " Subject ";
|
||||
assert_eq!(sanitize_header_value(padded), "Subject");
|
||||
|
||||
let multi_newline = "Line1\r\nLine2\nLine3\rLine4";
|
||||
let sanitized = sanitize_header_value(multi_newline);
|
||||
assert!(!sanitized.contains('\r') && !sanitized.contains('\n'));
|
||||
assert!(sanitized.contains("Line1") && sanitized.contains("Line4"));
|
||||
|
||||
let header_injection = "Normal Subject\r\nBcc: attacker@evil.com\r\nX-Injected: value";
|
||||
let sanitized = sanitize_header_value(header_injection);
|
||||
assert_eq!(sanitized.split("\r\n").count(), 1);
|
||||
assert!(
|
||||
sanitized.contains("Normal Subject")
|
||||
&& sanitized.contains("Bcc:")
|
||||
&& sanitized.contains("X-Injected:")
|
||||
);
|
||||
|
||||
let with_null = "client\0id";
|
||||
assert!(sanitize_header_value(with_null).contains("client"));
|
||||
|
||||
let long_input = "x".repeat(10000);
|
||||
assert!(!sanitize_header_value(&long_input).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phone_number_validation() {
|
||||
assert!(is_valid_phone_number("+1234567890"));
|
||||
|
||||
@@ -53,12 +53,16 @@ async fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
if let Err(e) = config.validate(*ignore_secrets) {
|
||||
eprint!("{e}");
|
||||
return ExitCode::FAILURE;
|
||||
match config.validate(*ignore_secrets) {
|
||||
Ok(()) => {
|
||||
println!("Configuration is valid.");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprint!("{e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
println!("Configuration is valid.");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -137,18 +141,11 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let cfg = tranquil_config::get();
|
||||
|
||||
match EmailSender::from_config(cfg) {
|
||||
Ok(Some(email_sender)) => {
|
||||
info!("Email comms enabled");
|
||||
comms_service = comms_service.register_sender(email_sender);
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("Email comms disabled (MAIL_FROM_ADDRESS unset)");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Email configuration invalid");
|
||||
return Err(e.into());
|
||||
}
|
||||
if let Some(email_sender) = EmailSender::from_config(cfg) {
|
||||
info!("Email comms enabled");
|
||||
comms_service = comms_service.register_sender(email_sender);
|
||||
} else {
|
||||
warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)");
|
||||
}
|
||||
|
||||
if let Some(discord_sender) = DiscordSender::from_config(cfg) {
|
||||
|
||||
@@ -76,7 +76,6 @@ fn tiny_config() -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,9 +7,7 @@ use super::group_commit::{ActiveFileSet, FileIdAllocator};
|
||||
use super::hash_index::{BlockIndex, BlockIndexError};
|
||||
use super::hint::{HintFileWriter, hint_file_path};
|
||||
use super::manager::DataFileManager;
|
||||
use super::types::{
|
||||
BlockLocation, CidBytes, CommitEpoch, CompactionResult, CompactionStats, DataFileId,
|
||||
};
|
||||
use super::types::{BlockLocation, CidBytes, CommitEpoch, CompactionResult, DataFileId};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CompactionError {
|
||||
@@ -70,13 +68,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
return Err(CompactionError::ActiveFileCannotBeCompacted);
|
||||
}
|
||||
|
||||
let source_handle = match manager.open_for_read(source_file_id) {
|
||||
Ok(handle) => handle,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
return purge_phantom_file(manager, index, hint_positions, epoch, source_file_id);
|
||||
}
|
||||
Err(e) => return Err(CompactionError::Io(e)),
|
||||
};
|
||||
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();
|
||||
@@ -100,16 +92,10 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
.ok();
|
||||
Err(e)
|
||||
}
|
||||
Ok((new_size, live_count, dead_count, new_hint_offset)) => {
|
||||
match live_count {
|
||||
0 => hint_positions.forget_extra(new_file_id),
|
||||
_ => hint_positions.record_extra(new_file_id, new_hint_offset),
|
||||
Ok((new_size, live_count, dead_count)) => {
|
||||
if let Err(e) = index.write_checkpoint(epoch.current(), hint_positions) {
|
||||
tracing::warn!(error = %e, "pre-delete checkpoint failed during compaction");
|
||||
}
|
||||
hint_positions.forget_extra(source_file_id);
|
||||
|
||||
index
|
||||
.write_checkpoint(epoch.current(), hint_positions)
|
||||
.map_err(CompactionError::Io)?;
|
||||
|
||||
manager.delete_data_file(source_file_id)?;
|
||||
manager
|
||||
@@ -138,51 +124,18 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
"compaction complete"
|
||||
);
|
||||
|
||||
Ok(CompactionResult::Compacted(CompactionStats {
|
||||
Ok(CompactionResult {
|
||||
file_id: source_file_id,
|
||||
old_size: source_size,
|
||||
new_size,
|
||||
live_blocks: live_count,
|
||||
dead_blocks: dead_count,
|
||||
reclaimed_bytes,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn purge_phantom_file<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
hint_positions: &super::group_commit::ShardHintPositions,
|
||||
epoch: &super::types::EpochCounter,
|
||||
source_file_id: DataFileId,
|
||||
) -> Result<CompactionResult, CompactionError> {
|
||||
let phantom_blocks = index.purge_by_file_id(source_file_id);
|
||||
|
||||
tracing::warn!(
|
||||
file_id = %source_file_id,
|
||||
phantom_blocks,
|
||||
"source data file missing on disk, purged phantom index entries"
|
||||
);
|
||||
|
||||
hint_positions.forget_extra(source_file_id);
|
||||
|
||||
manager
|
||||
.io()
|
||||
.delete(&hint_file_path(manager.data_dir(), source_file_id))
|
||||
.ok();
|
||||
manager.io().sync_dir(manager.data_dir()).ok();
|
||||
|
||||
index
|
||||
.write_checkpoint(epoch.current(), hint_positions)
|
||||
.map_err(CompactionError::Io)?;
|
||||
|
||||
Ok(CompactionResult::Purged {
|
||||
file_id: source_file_id,
|
||||
phantom_blocks,
|
||||
})
|
||||
}
|
||||
|
||||
fn stream_compact<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
index: &BlockIndex,
|
||||
@@ -191,7 +144,7 @@ fn stream_compact<S: StorageIO>(
|
||||
new_file_id: DataFileId,
|
||||
current_epoch: CommitEpoch,
|
||||
grace_period_ms: u64,
|
||||
) -> Result<(u64, u64, u64, super::types::HintOffset), CompactionError> {
|
||||
) -> Result<(u64, u64, u64), CompactionError> {
|
||||
let mut reader = DataFileReader::open(manager.io(), source_fd)?;
|
||||
let now = crate::wall_clock_ms();
|
||||
|
||||
@@ -269,10 +222,8 @@ 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 final_hint_offset = hint_writer.position();
|
||||
let _ = manager.io().close(hint_fd);
|
||||
|
||||
finalize_result?;
|
||||
@@ -281,5 +232,5 @@ fn stream_compact<S: StorageIO>(
|
||||
|
||||
index.apply_compaction(&relocations, &dead_cids);
|
||||
|
||||
Ok((new_size, live_count, dead_count, final_hint_offset))
|
||||
Ok((new_size, live_count, dead_count))
|
||||
}
|
||||
|
||||
@@ -74,49 +74,30 @@ impl ActiveFileSet {
|
||||
}
|
||||
|
||||
pub struct ShardHintPositions {
|
||||
shard_positions: RwLock<Vec<(DataFileId, HintOffset)>>,
|
||||
extra_positions: RwLock<HashMap<DataFileId, HintOffset>>,
|
||||
positions: RwLock<Vec<(DataFileId, HintOffset)>>,
|
||||
}
|
||||
|
||||
impl ShardHintPositions {
|
||||
pub fn new(shard_count: u8) -> Self {
|
||||
Self {
|
||||
shard_positions: RwLock::new(
|
||||
positions: RwLock::new(
|
||||
(0..shard_count as usize)
|
||||
.map(|_| (DataFileId::new(0), HintOffset::new(0)))
|
||||
.collect(),
|
||||
),
|
||||
extra_positions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&self, shard_id: ShardId, file_id: DataFileId, offset: HintOffset) {
|
||||
let mut positions = self.shard_positions.write();
|
||||
let mut positions = self.positions.write();
|
||||
let idx = shard_id.as_usize();
|
||||
if idx < positions.len() {
|
||||
positions[idx] = (file_id, offset);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_extra(&self, file_id: DataFileId, offset: HintOffset) {
|
||||
self.extra_positions.write().insert(file_id, offset);
|
||||
}
|
||||
|
||||
pub fn forget_extra(&self, file_id: DataFileId) {
|
||||
self.extra_positions.write().remove(&file_id);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> CheckpointPositions {
|
||||
let shard = self.shard_positions.read().clone();
|
||||
let extra = self.extra_positions.read().clone();
|
||||
debug_assert!(
|
||||
shard
|
||||
.iter()
|
||||
.filter(|(fid, _)| fid.raw() != 0)
|
||||
.all(|(fid, _)| !extra.contains_key(fid)),
|
||||
"shard_positions and extra_positions must not overlap on the same DataFileId"
|
||||
);
|
||||
CheckpointPositions(shard.into_iter().chain(extra).collect())
|
||||
CheckpointPositions(self.positions.read().clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,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() {
|
||||
|
||||
@@ -606,34 +606,6 @@ impl HashTable {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cids_in_file(&self, file_id: DataFileId) -> Vec<CidBytes> {
|
||||
self.iter()
|
||||
.filter(|s| s.file_id == file_id)
|
||||
.map(|s| s.cid)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn purge_by_file_id(&mut self, file_id: DataFileId) -> u64 {
|
||||
let victims: Vec<(CidBytes, RefCount)> = self
|
||||
.iter()
|
||||
.filter(|s| s.file_id == file_id)
|
||||
.map(|s| (s.cid, s.refcount))
|
||||
.collect();
|
||||
|
||||
let live_discarded = victims.iter().filter(|(_, rc)| !rc.is_zero()).count();
|
||||
if live_discarded > 0 {
|
||||
tracing::warn!(
|
||||
file_id = %file_id,
|
||||
live_discarded,
|
||||
total_purged = victims.len(),
|
||||
"discarding live index entries for missing data file"
|
||||
);
|
||||
}
|
||||
|
||||
let removed = victims.iter().filter(|(cid, _)| self.remove(cid)).count();
|
||||
u64::try_from(removed).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
pub fn cleanup_stale_gc(&mut self) -> u64 {
|
||||
self.slots
|
||||
.iter_mut()
|
||||
@@ -1531,14 +1503,6 @@ impl BlockIndex {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cids_in_file(&self, file_id: DataFileId) -> Vec<CidBytes> {
|
||||
self.table.read().cids_in_file(file_id)
|
||||
}
|
||||
|
||||
pub fn purge_by_file_id(&self, file_id: DataFileId) -> u64 {
|
||||
self.table.write().purge_by_file_id(file_id)
|
||||
}
|
||||
|
||||
pub fn read_write_cursor(&self) -> Option<WriteCursor> {
|
||||
self.table.read().write_cursor()
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ 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, CompactionStats, DataFileId, EpochCounter, HintOffset,
|
||||
IndexEntry, LivenessInfo, MAX_BLOCK_SIZE, RefCount, ShardId, WallClockMs, WriteCursor,
|
||||
CommitEpoch, CompactionResult, DataFileId, EpochCounter, HintOffset, IndexEntry, LivenessInfo,
|
||||
MAX_BLOCK_SIZE, RefCount, ShardId, WallClockMs, WriteCursor,
|
||||
};
|
||||
|
||||
use std::io;
|
||||
|
||||
@@ -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!(
|
||||
@@ -599,16 +533,6 @@ impl<S: StorageIO + Send + Sync + 'static> TranquilBlockStore<S> {
|
||||
.map_err(RepoError::storage)
|
||||
}
|
||||
|
||||
pub fn list_hint_files(&self) -> Result<Vec<DataFileId>, RepoError> {
|
||||
let io = self.reader.manager().io();
|
||||
super::list_files_by_extension(io, &self.data_dir, super::hint::HINT_FILE_EXTENSION)
|
||||
.map_err(RepoError::storage)
|
||||
}
|
||||
|
||||
pub fn hint_file_path(&self, file_id: DataFileId) -> std::path::PathBuf {
|
||||
super::hint::hint_file_path(&self.data_dir, file_id)
|
||||
}
|
||||
|
||||
pub fn put_blocks_blocking(
|
||||
&self,
|
||||
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
|
||||
@@ -789,210 +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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,7 @@ pub struct CollectionResult {
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CompactionStats {
|
||||
pub struct CompactionResult {
|
||||
pub file_id: DataFileId,
|
||||
pub old_size: u64,
|
||||
pub new_size: u64,
|
||||
@@ -78,24 +77,6 @@ pub struct CompactionStats {
|
||||
pub reclaimed_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CompactionResult {
|
||||
Compacted(CompactionStats),
|
||||
Purged {
|
||||
file_id: DataFileId,
|
||||
phantom_blocks: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl CompactionResult {
|
||||
pub fn file_id(&self) -> DataFileId {
|
||||
match self {
|
||||
Self::Compacted(stats) => stats.file_id,
|
||||
Self::Purged { file_id, .. } => *file_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LivenessInfo {
|
||||
pub live_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
|
||||
@@ -28,8 +28,6 @@ pub struct ConsistencyReport {
|
||||
pub orphaned_user_repos: Vec<OrphanedUserRepo>,
|
||||
pub inconsistent_handles: Vec<InconsistentHandle>,
|
||||
pub orphan_data_files: Vec<DataFileId>,
|
||||
pub orphan_hint_files: Vec<DataFileId>,
|
||||
pub missing_indexed_files: Vec<DataFileId>,
|
||||
pub deserialization_failures: u64,
|
||||
pub eventlog_contiguity: Option<SequenceContiguityResult>,
|
||||
pub cursor_ahead_of_eventlog: bool,
|
||||
@@ -76,8 +74,6 @@ impl ConsistencyReport {
|
||||
&& self.orphaned_user_repos.is_empty()
|
||||
&& self.inconsistent_handles.is_empty()
|
||||
&& self.orphan_data_files.is_empty()
|
||||
&& self.orphan_hint_files.is_empty()
|
||||
&& self.missing_indexed_files.is_empty()
|
||||
&& self.deserialization_failures == 0
|
||||
&& self
|
||||
.eventlog_contiguity
|
||||
@@ -88,8 +84,6 @@ impl ConsistencyReport {
|
||||
|
||||
pub fn has_repairable_issues(&self) -> bool {
|
||||
!self.orphan_data_files.is_empty()
|
||||
|| !self.orphan_hint_files.is_empty()
|
||||
|| !self.missing_indexed_files.is_empty()
|
||||
}
|
||||
|
||||
pub fn has_unrecoverable_issues(&self) -> bool {
|
||||
@@ -142,20 +136,6 @@ impl ConsistencyReport {
|
||||
"orphan data files with no index references"
|
||||
);
|
||||
}
|
||||
if !self.orphan_hint_files.is_empty() {
|
||||
tracing::warn!(
|
||||
count = self.orphan_hint_files.len(),
|
||||
files = ?self.orphan_hint_files,
|
||||
"orphan hint files with no matching data file"
|
||||
);
|
||||
}
|
||||
if !self.missing_indexed_files.is_empty() {
|
||||
tracing::warn!(
|
||||
count = self.missing_indexed_files.len(),
|
||||
files = ?self.missing_indexed_files,
|
||||
"index references data files that are missing on disk"
|
||||
);
|
||||
}
|
||||
if self.deserialization_failures > 0 {
|
||||
tracing::error!(
|
||||
count = self.deserialization_failures,
|
||||
@@ -201,15 +181,13 @@ impl fmt::Display for ConsistencyReport {
|
||||
write!(
|
||||
f,
|
||||
"INCONSISTENT: dangling_roots={}, dangling_records={}, orphaned_repos={}, \
|
||||
inconsistent_handles={}, orphan_files={}, orphan_hints={}, missing_indexed_files={}, \
|
||||
deserialize_failures={}, eventlog_gaps={}, cursor_ahead={}",
|
||||
inconsistent_handles={}, orphan_files={}, deserialize_failures={}, \
|
||||
eventlog_gaps={}, cursor_ahead={}",
|
||||
self.dangling_root_cids.len(),
|
||||
self.dangling_record_cids.len(),
|
||||
self.orphaned_user_repos.len(),
|
||||
self.inconsistent_handles.len(),
|
||||
self.orphan_data_files.len(),
|
||||
self.orphan_hint_files.len(),
|
||||
self.missing_indexed_files.len(),
|
||||
self.deserialization_failures,
|
||||
self.eventlog_contiguity
|
||||
.as_ref()
|
||||
@@ -226,8 +204,6 @@ pub struct ConsistencyCheckOptions {
|
||||
pub check_user_blocks: bool,
|
||||
pub check_eventlog: bool,
|
||||
pub check_orphan_files: bool,
|
||||
pub check_missing_indexed_files: bool,
|
||||
pub check_orphan_hint_files: bool,
|
||||
}
|
||||
|
||||
impl Default for ConsistencyCheckOptions {
|
||||
@@ -238,8 +214,6 @@ impl Default for ConsistencyCheckOptions {
|
||||
check_user_blocks: true,
|
||||
check_eventlog: true,
|
||||
check_orphan_files: true,
|
||||
check_missing_indexed_files: true,
|
||||
check_orphan_hint_files: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,14 +269,6 @@ pub fn verify_store_consistency_with_options<S: StorageIO + 'static>(
|
||||
check_orphan_data_files(blockstore, block_index, &mut report);
|
||||
}
|
||||
|
||||
if options.check_missing_indexed_files {
|
||||
check_missing_indexed_files(blockstore, block_index, &mut report);
|
||||
}
|
||||
|
||||
if options.check_orphan_hint_files {
|
||||
check_orphan_hint_files(blockstore, &mut report);
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
@@ -599,52 +565,6 @@ fn check_orphan_data_files(
|
||||
});
|
||||
}
|
||||
|
||||
fn check_missing_indexed_files(
|
||||
blockstore: &TranquilBlockStore,
|
||||
block_index: &BlockIndex,
|
||||
report: &mut ConsistencyReport,
|
||||
) {
|
||||
let disk_files: HashSet<DataFileId> = match blockstore.list_data_files() {
|
||||
Ok(files) => files.into_iter().collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to list data files for missing-file check");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let epoch = blockstore.epoch().current();
|
||||
let now = crate::wall_clock_ms();
|
||||
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
|
||||
|
||||
indexed_files
|
||||
.iter()
|
||||
.filter(|(fid, _)| !disk_files.contains(fid))
|
||||
.for_each(|(fid, _)| report.missing_indexed_files.push(*fid));
|
||||
}
|
||||
|
||||
fn check_orphan_hint_files(blockstore: &TranquilBlockStore, report: &mut ConsistencyReport) {
|
||||
let data_files: HashSet<DataFileId> = match blockstore.list_data_files() {
|
||||
Ok(files) => files.into_iter().collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to list data files for orphan-hint check");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let hint_files = match blockstore.list_hint_files() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to list hint files for orphan-hint check");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
hint_files
|
||||
.iter()
|
||||
.filter(|fid| !data_files.contains(fid))
|
||||
.for_each(|fid| report.orphan_hint_files.push(*fid));
|
||||
}
|
||||
|
||||
fn try_cid_bytes_to_fixed(bytes: &[u8]) -> Option<[u8; CID_SIZE]> {
|
||||
bytes.try_into().ok()
|
||||
}
|
||||
@@ -701,39 +621,12 @@ pub fn repair_known_issues(
|
||||
}
|
||||
});
|
||||
|
||||
report.orphan_hint_files.iter().for_each(|&file_id| {
|
||||
let path = blockstore.hint_file_path(file_id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(%file_id, "removed orphan hint file");
|
||||
result.orphan_hints_removed = result.orphan_hints_removed.saturating_add(1);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%file_id, error = %e, "failed to remove orphan hint file");
|
||||
result.repair_errors = result.repair_errors.saturating_add(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
report.missing_indexed_files.iter().for_each(|&file_id| {
|
||||
let purged = blockstore.block_index().purge_by_file_id(file_id);
|
||||
tracing::info!(
|
||||
%file_id,
|
||||
purged,
|
||||
"purged phantom index entries for missing data file"
|
||||
);
|
||||
result.phantom_index_entries_purged =
|
||||
result.phantom_index_entries_purged.saturating_add(purged);
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RepairResult {
|
||||
pub orphan_files_removed: u64,
|
||||
pub orphan_hints_removed: u64,
|
||||
pub phantom_index_entries_purged: u64,
|
||||
pub repair_errors: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,113 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::NodeData;
|
||||
|
||||
use super::oracle::{hex_short, try_cid_to_fixed};
|
||||
use crate::StorageIO;
|
||||
use crate::blockstore::{CidBytes, TranquilBlockStore};
|
||||
|
||||
pub enum LookupResult {
|
||||
Found(Cid),
|
||||
NotFound,
|
||||
LostPath,
|
||||
}
|
||||
|
||||
pub fn walk_mst_node_cids_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
root: Cid,
|
||||
lost: &HashSet<CidBytes>,
|
||||
) -> Result<Vec<CidBytes>, String> {
|
||||
let mut visited: HashSet<CidBytes> = HashSet::new();
|
||||
let mut to_visit: Vec<Cid> = vec![root];
|
||||
let mut result: Vec<CidBytes> = Vec::new();
|
||||
|
||||
while let Some(cid) = to_visit.pop() {
|
||||
let cid_bytes = try_cid_to_fixed(&cid).map_err(|e| format!("cid format: {e}"))?;
|
||||
if !visited.insert(cid_bytes) {
|
||||
continue;
|
||||
}
|
||||
if lost.contains(&cid_bytes) {
|
||||
continue;
|
||||
}
|
||||
let node = read_node(store, &cid_bytes)?;
|
||||
result.push(cid_bytes);
|
||||
if let Some(left) = node.left {
|
||||
to_visit.push(left);
|
||||
}
|
||||
node.entries
|
||||
.into_iter()
|
||||
.filter_map(|e| e.tree)
|
||||
.for_each(|t| to_visit.push(t));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn mst_get_tolerant<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
root: Cid,
|
||||
target: &str,
|
||||
lost: &HashSet<CidBytes>,
|
||||
) -> Result<LookupResult, String> {
|
||||
let mut cursor = root;
|
||||
loop {
|
||||
let cursor_bytes = try_cid_to_fixed(&cursor).map_err(|e| format!("cid format: {e}"))?;
|
||||
if lost.contains(&cursor_bytes) {
|
||||
return Ok(LookupResult::LostPath);
|
||||
}
|
||||
let node = read_node(store, &cursor_bytes)?;
|
||||
let keys = full_keys(&node)?;
|
||||
let index = keys
|
||||
.iter()
|
||||
.position(|k| k.as_str() >= target)
|
||||
.unwrap_or(keys.len());
|
||||
if index < keys.len() && keys[index] == target {
|
||||
return Ok(LookupResult::Found(node.entries[index].value));
|
||||
}
|
||||
let subtree = match index {
|
||||
0 => node.left,
|
||||
n => node.entries[n - 1].tree,
|
||||
};
|
||||
match subtree {
|
||||
Some(child) => cursor = child,
|
||||
None => return Ok(LookupResult::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_node<S: StorageIO + Send + Sync + 'static>(
|
||||
store: &TranquilBlockStore<S>,
|
||||
cid_bytes: &CidBytes,
|
||||
) -> Result<NodeData, String> {
|
||||
let bytes = match store.get_block_sync(cid_bytes) {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => return Err(format!("missing block: {}", hex_short(cid_bytes))),
|
||||
Err(e) => return Err(format!("read {}: {e}", hex_short(cid_bytes))),
|
||||
};
|
||||
serde_ipld_dagcbor::from_slice(&bytes)
|
||||
.map_err(|e| format!("deserialize node {}: {e}", hex_short(cid_bytes)))
|
||||
}
|
||||
|
||||
fn full_keys(node: &NodeData) -> Result<Vec<String>, String> {
|
||||
node.entries
|
||||
.iter()
|
||||
.scan(String::new(), |last_key, entry| {
|
||||
let suffix = match std::str::from_utf8(&entry.key_suffix) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Some(Err(format!("invalid utf-8 in key suffix: {e}"))),
|
||||
};
|
||||
let prefix_len = entry.prefix_len as usize;
|
||||
if prefix_len > last_key.len() {
|
||||
return Some(Err(format!(
|
||||
"prefix length {} exceeds last key length {}",
|
||||
prefix_len,
|
||||
last_key.len()
|
||||
)));
|
||||
}
|
||||
let full = format!("{}{}", &last_key[..prefix_len], suffix);
|
||||
*last_key = full.clone();
|
||||
Some(Ok(full))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -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,59 +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()?;
|
||||
|
||||
@@ -31,9 +31,6 @@ impl InvariantSet {
|
||||
pub const MONOTONIC_SEQ: Self = Self(1 << 10);
|
||||
pub const FSYNC_ORDERING: Self = Self(1 << 11);
|
||||
pub const TOMBSTONE_BOUND: Self = Self(1 << 12);
|
||||
pub const INDEX_BACKED_BY_DISK: Self = Self(1 << 13);
|
||||
pub const HINT_BACKED_BY_DATA: Self = Self(1 << 14);
|
||||
pub const INDEX_BLOCKS_READABLE: Self = Self(1 << 15);
|
||||
|
||||
const ALL_KNOWN: u32 = Self::REFCOUNT_CONSERVATION.0
|
||||
| Self::REACHABILITY.0
|
||||
@@ -47,10 +44,7 @@ impl InvariantSet {
|
||||
| Self::CHECKSUM_COVERAGE.0
|
||||
| Self::MONOTONIC_SEQ.0
|
||||
| Self::FSYNC_ORDERING.0
|
||||
| Self::TOMBSTONE_BOUND.0
|
||||
| Self::INDEX_BACKED_BY_DISK.0
|
||||
| Self::HINT_BACKED_BY_DATA.0
|
||||
| Self::INDEX_BLOCKS_READABLE.0;
|
||||
| Self::TOMBSTONE_BOUND.0;
|
||||
|
||||
pub const fn contains(self, other: Self) -> bool {
|
||||
(self.0 & other.0) == other.0
|
||||
@@ -383,164 +377,6 @@ fn compact_by_liveness<S: StorageIO + Send + Sync + 'static>(
|
||||
})
|
||||
}
|
||||
|
||||
pub struct HintBackedByData;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for HintBackedByData {
|
||||
fn name(&self) -> &'static str {
|
||||
"HintBackedByData"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let data: std::collections::HashSet<_> = store_c
|
||||
.list_data_files()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let hints = store_c.list_hint_files().map_err(|e| e.to_string())?;
|
||||
let orphans: Vec<String> = hints
|
||||
.iter()
|
||||
.filter(|fid| !data.contains(fid))
|
||||
.map(|fid| fid.to_string())
|
||||
.collect();
|
||||
Ok::<_, String>(orphans)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| InvariantViolation {
|
||||
invariant: "HintBackedByData",
|
||||
detail: format!("join: {e}"),
|
||||
})?;
|
||||
|
||||
let orphans = result.map_err(|e| InvariantViolation {
|
||||
invariant: "HintBackedByData",
|
||||
detail: e,
|
||||
})?;
|
||||
|
||||
if orphans.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvariantViolation {
|
||||
invariant: "HintBackedByData",
|
||||
detail: format!(
|
||||
"hint files without matching data file (orphan hints): {}",
|
||||
orphans.join(", ")
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IndexBlocksReadable;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBlocksReadable {
|
||||
fn name(&self) -> &'static str {
|
||||
"IndexBlocksReadable"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let entries = store_c.block_index().live_entries_snapshot();
|
||||
let unreadable: Vec<String> = entries
|
||||
.iter()
|
||||
.take(INDEX_READABLE_SAMPLE_CAP)
|
||||
.filter_map(|(cid, _)| match store_c.get_block_sync(cid) {
|
||||
Ok(Some(_)) => None,
|
||||
Ok(None) => Some(format!(
|
||||
"{}: index says present but reader missed",
|
||||
hex_short(cid)
|
||||
)),
|
||||
Err(e) => Some(format!("{}: read error {e}", hex_short(cid))),
|
||||
})
|
||||
.take(INDEX_READABLE_REPORT_CAP)
|
||||
.collect();
|
||||
Ok::<_, String>(unreadable)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| InvariantViolation {
|
||||
invariant: "IndexBlocksReadable",
|
||||
detail: format!("join: {e}"),
|
||||
})?;
|
||||
|
||||
let unreadable = result.map_err(|e| InvariantViolation {
|
||||
invariant: "IndexBlocksReadable",
|
||||
detail: e,
|
||||
})?;
|
||||
|
||||
if unreadable.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvariantViolation {
|
||||
invariant: "IndexBlocksReadable",
|
||||
detail: format!(
|
||||
"live index entries cannot be read back (first {INDEX_READABLE_REPORT_CAP}): {}",
|
||||
unreadable.join("; ")
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const INDEX_READABLE_SAMPLE_CAP: usize = 512;
|
||||
const INDEX_READABLE_REPORT_CAP: usize = 20;
|
||||
|
||||
pub struct IndexBackedByDisk;
|
||||
|
||||
#[async_trait]
|
||||
impl<S: StorageIO + Send + Sync + 'static> Invariant<S> for IndexBackedByDisk {
|
||||
fn name(&self) -> &'static str {
|
||||
"IndexBackedByDisk"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &InvariantCtx<'_, S>) -> Result<(), InvariantViolation> {
|
||||
let store_c = ctx.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let disk: std::collections::HashSet<_> = store_c
|
||||
.list_data_files()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let liveness = store_c.compaction_liveness(0).map_err(|e| e.to_string())?;
|
||||
let missing: Vec<String> = liveness
|
||||
.iter()
|
||||
.filter(|(fid, _)| !disk.contains(fid))
|
||||
.map(|(fid, info)| {
|
||||
format!(
|
||||
"{fid} (live_blocks={}, total_blocks={})",
|
||||
info.live_blocks, info.total_blocks
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok::<_, String>(missing)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| InvariantViolation {
|
||||
invariant: "IndexBackedByDisk",
|
||||
detail: format!("join: {e}"),
|
||||
})?;
|
||||
|
||||
let missing = result.map_err(|e| InvariantViolation {
|
||||
invariant: "IndexBackedByDisk",
|
||||
detail: e,
|
||||
})?;
|
||||
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvariantViolation {
|
||||
invariant: "IndexBackedByDisk",
|
||||
detail: format!(
|
||||
"index references data files missing on disk (iris-shaped corruption): {}",
|
||||
missing.join(", ")
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOrphanFiles;
|
||||
|
||||
#[async_trait]
|
||||
@@ -930,18 +766,6 @@ pub fn invariants_for<S: StorageIO + Send + Sync + 'static>(
|
||||
Box::new(CompactionIdempotent),
|
||||
),
|
||||
(InvariantSet::NO_ORPHAN_FILES, Box::new(NoOrphanFiles)),
|
||||
(
|
||||
InvariantSet::INDEX_BACKED_BY_DISK,
|
||||
Box::new(IndexBackedByDisk),
|
||||
),
|
||||
(
|
||||
InvariantSet::HINT_BACKED_BY_DATA,
|
||||
Box::new(HintBackedByData),
|
||||
),
|
||||
(
|
||||
InvariantSet::INDEX_BLOCKS_READABLE,
|
||||
Box::new(IndexBlocksReadable),
|
||||
),
|
||||
(InvariantSet::BYTE_BUDGET, Box::new(ByteBudget::default())),
|
||||
(
|
||||
InvariantSet::MANIFEST_EQUALS_REALITY,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod chaos_walker;
|
||||
pub mod farm;
|
||||
pub mod flaky;
|
||||
pub mod invariants;
|
||||
@@ -18,14 +17,13 @@ pub use flaky::{
|
||||
BackingMegabytes, DownIntervalSecs, FlakyConfig, FlakyError, FlakyMount, UpIntervalSecs,
|
||||
};
|
||||
pub use invariants::{
|
||||
EventLogSnapshot, HintBackedByData, IndexBackedByDisk, IndexBlocksReadable, Invariant,
|
||||
InvariantCtx, InvariantSet, InvariantViolation, SnapshotEvent, invariants_for,
|
||||
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, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
|
||||
RetentionSecs, Seed, ValueSeed,
|
||||
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
|
||||
ValueSeed,
|
||||
};
|
||||
pub use oracle::{EventExpectation, Oracle};
|
||||
pub use overrides::{ConfigOverrides, GroupCommitOverrides, StoreOverrides};
|
||||
|
||||
@@ -29,9 +29,6 @@ pub enum EventKind {
|
||||
Sync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FileChoice(pub u32);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Op {
|
||||
AddRecord {
|
||||
@@ -61,9 +58,6 @@ pub enum Op {
|
||||
ReadBlock {
|
||||
value_seed: ValueSeed,
|
||||
},
|
||||
ExternalDeleteDataFile {
|
||||
choice: FileChoice,
|
||||
},
|
||||
}
|
||||
|
||||
impl Op {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cid::Cid;
|
||||
|
||||
@@ -29,7 +29,6 @@ pub struct Oracle {
|
||||
unsynced_events: Vec<EventExpectation>,
|
||||
last_synced_seq: Option<EventSequence>,
|
||||
last_retention_cutoff_us: Option<u64>,
|
||||
lost_blocks: HashSet<CidBytes>,
|
||||
}
|
||||
|
||||
impl Oracle {
|
||||
@@ -94,27 +93,6 @@ impl Oracle {
|
||||
self.unsynced_events.push(event);
|
||||
}
|
||||
|
||||
pub fn mark_blocks_lost(&mut self, cids: impl IntoIterator<Item = CidBytes>) -> usize {
|
||||
let added: HashSet<CidBytes> = cids.into_iter().collect();
|
||||
let added_count = added.len();
|
||||
self.live
|
||||
.retain(|_, record_cid| !added.contains(record_cid));
|
||||
self.lost_blocks.extend(added);
|
||||
added_count
|
||||
}
|
||||
|
||||
pub fn lost_blocks(&self) -> &HashSet<CidBytes> {
|
||||
&self.lost_blocks
|
||||
}
|
||||
|
||||
pub fn is_block_lost(&self, cid: &CidBytes) -> bool {
|
||||
self.lost_blocks.contains(cid)
|
||||
}
|
||||
|
||||
pub fn has_lost_blocks(&self) -> bool {
|
||||
!self.lost_blocks.is_empty()
|
||||
}
|
||||
|
||||
pub fn record_event_sync(&mut self, synced_through: EventSequence) {
|
||||
let (promoted, remaining): (Vec<_>, Vec<_>) = self
|
||||
.unsynced_events
|
||||
|
||||
@@ -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 {
|
||||
@@ -93,7 +93,6 @@ pub struct GauntletConfig {
|
||||
pub store: StoreConfig,
|
||||
pub eventlog: Option<EventLogConfig>,
|
||||
pub writer_concurrency: WriterConcurrency,
|
||||
pub tolerate_op_errors: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -179,7 +178,6 @@ pub struct SharedState<S: StorageIO + Send + Sync + 'static> {
|
||||
|
||||
pub struct Gauntlet {
|
||||
config: GauntletConfig,
|
||||
scratch_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -187,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 {
|
||||
@@ -221,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(
|
||||
@@ -230,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,
|
||||
@@ -281,14 +269,9 @@ 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 tolerate = config.tolerate_op_errors;
|
||||
let report = run_inner_real_on_root(
|
||||
config,
|
||||
root,
|
||||
@@ -296,7 +279,7 @@ async fn run_inner_real(
|
||||
ops_counter,
|
||||
op_errors_counter,
|
||||
restarts_counter,
|
||||
tolerate,
|
||||
false,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
@@ -381,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())?;
|
||||
@@ -434,15 +417,14 @@ async fn run_inner_simulated(
|
||||
) -> GauntletReport {
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let cfg = blockstore_config(dir.path(), &config.store);
|
||||
let tolerate_errors = fault.injects_errors() || config.tolerate_op_errors;
|
||||
let tolerate_errors = fault.injects_errors();
|
||||
let eventlog_cfg = config.eventlog;
|
||||
let segments_dir = segments_subdir(dir.path());
|
||||
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::new(config.seed.0, fault));
|
||||
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)
|
||||
@@ -530,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));
|
||||
@@ -770,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();
|
||||
@@ -778,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}"));
|
||||
@@ -830,6 +810,7 @@ async fn run_quick_check<S: StorageIO + Send + Sync + 'static>(
|
||||
};
|
||||
};
|
||||
|
||||
let mst = Mst::load(store.clone(), r, None);
|
||||
let live: Vec<(super::op::CollectionName, super::op::RecordKey, CidBytes)> = oracle
|
||||
.live_records()
|
||||
.map(|(c, k, v)| (c.clone(), k.clone(), *v))
|
||||
@@ -841,39 +822,24 @@ async fn run_quick_check<S: StorageIO + Send + Sync + 'static>(
|
||||
sample_distinct(rng, total, sample_size)
|
||||
};
|
||||
|
||||
let store_c = store.clone();
|
||||
let lost_clone = oracle.lost_blocks().clone();
|
||||
let live_clone = live.clone();
|
||||
let picks_c = picks.clone();
|
||||
let violations: Vec<String> = tokio::task::spawn_blocking(move || {
|
||||
picks_c
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let (coll, rkey, expected) = &live_clone[idx];
|
||||
let key = format!("{}/{}", coll.0, rkey.0);
|
||||
match super::chaos_walker::mst_get_tolerant(&store_c, r, &key, &lost_clone) {
|
||||
Ok(super::chaos_walker::LookupResult::Found(cid)) => {
|
||||
match try_cid_to_fixed(&cid) {
|
||||
Ok(actual) if actual == *expected => None,
|
||||
Ok(actual) => Some(format!(
|
||||
"{key}: MST cid {} != oracle cid {}",
|
||||
hex_short(&actual),
|
||||
hex_short(expected)
|
||||
)),
|
||||
Err(e) => Some(format!("{key}: cid format: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(super::chaos_walker::LookupResult::NotFound) => {
|
||||
Some(format!("{key}: missing after reopen"))
|
||||
}
|
||||
Ok(super::chaos_walker::LookupResult::LostPath) => None,
|
||||
Err(e) => Some(format!("{key}: mst.get error: {e}")),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| vec![format!("quick_check join: {e}")]);
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
for idx in picks {
|
||||
let (coll, rkey, expected) = &live[idx];
|
||||
let key = format!("{}/{}", coll.0, rkey.0);
|
||||
match mst.get(&key).await {
|
||||
Ok(Some(cid)) => match try_cid_to_fixed(&cid) {
|
||||
Ok(actual) if actual == *expected => {}
|
||||
Ok(actual) => violations.push(format!(
|
||||
"{key}: MST cid {} != oracle cid {}",
|
||||
hex_short(&actual),
|
||||
hex_short(expected)
|
||||
)),
|
||||
Err(e) => violations.push(format!("{key}: cid format: {e}")),
|
||||
},
|
||||
Ok(None) => violations.push(format!("{key}: missing after reopen")),
|
||||
Err(e) => violations.push(format!("{key}: mst.get error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
if violations.is_empty() {
|
||||
Vec::new()
|
||||
@@ -983,13 +949,16 @@ pub(super) async fn refresh_oracle_graph<S: StorageIO + Send + Sync + 'static>(
|
||||
Ok(())
|
||||
}
|
||||
Some(r) => {
|
||||
let store_c = store.clone();
|
||||
let lost_clone = oracle.lost_blocks().clone();
|
||||
let fixed = tokio::task::spawn_blocking(move || {
|
||||
super::chaos_walker::walk_mst_node_cids_tolerant(&store_c, r, &lost_clone)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("refresh join: {e}"))??;
|
||||
let settled = Mst::load(store.clone(), r, None);
|
||||
let cids = settled
|
||||
.collect_node_cids()
|
||||
.await
|
||||
.map_err(|e| format!("collect_node_cids: {e}"))?;
|
||||
let fixed: Vec<CidBytes> = cids
|
||||
.iter()
|
||||
.map(try_cid_to_fixed)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| format!("mst node cid: {e}"))?;
|
||||
oracle.set_root(r);
|
||||
oracle.set_mst_node_cids(fixed);
|
||||
Ok(())
|
||||
@@ -1213,39 +1182,6 @@ pub(super) async fn apply_op<S: StorageIO + Send + Sync + 'static>(
|
||||
let _ = harness.store.get_block_sync(&record_cid);
|
||||
Ok(())
|
||||
}
|
||||
Op::ExternalDeleteDataFile { choice } => {
|
||||
let s = harness.store.clone();
|
||||
let pick = choice.0;
|
||||
let lost_cids = tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick))
|
||||
.await
|
||||
.map_err(|e| OpError::Join(e.to_string()))??;
|
||||
if !lost_cids.is_empty() {
|
||||
oracle.mark_blocks_lost(lost_cids);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn externally_delete_data_file(
|
||||
store: &std::sync::Arc<TranquilBlockStore<impl StorageIO + 'static>>,
|
||||
pick: u32,
|
||||
) -> Result<Vec<CidBytes>, OpError> {
|
||||
let active = store.block_index().read_write_cursor().map(|c| c.file_id);
|
||||
let mut candidates = match store.list_data_files() {
|
||||
Ok(files) => files,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
candidates.retain(|fid| active.is_none_or(|a| *fid < a));
|
||||
if candidates.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let idx = (pick as usize) % candidates.len();
|
||||
let victim = candidates[idx];
|
||||
let cids = store.block_index().cids_in_file(victim);
|
||||
match std::fs::remove_file(store.data_file_path(victim)) {
|
||||
Ok(()) => Ok(cids),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1255,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;
|
||||
@@ -1557,18 +1489,6 @@ async fn apply_op_concurrent<S: StorageIO + Send + Sync + 'static>(
|
||||
let _ = shared.store.get_block_sync(&record_cid);
|
||||
Ok(())
|
||||
}
|
||||
Op::ExternalDeleteDataFile { choice } => {
|
||||
let mut guard = shared.write.lock().await;
|
||||
let s = shared.store.clone();
|
||||
let pick = choice.0;
|
||||
let lost_cids = tokio::task::spawn_blocking(move || externally_delete_data_file(&s, pick))
|
||||
.await
|
||||
.map_err(|e| OpError::Join(e.to_string()))??;
|
||||
if !lost_cids.is_empty() {
|
||||
guard.oracle.mark_blocks_lost(lost_cids);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1644,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();
|
||||
@@ -1657,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;
|
||||
@@ -1883,126 +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),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -31,7 +31,6 @@ pub enum Scenario {
|
||||
ContendedReaders,
|
||||
ContendedWriters,
|
||||
FlakyDevice,
|
||||
ExternalCorruption,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
@@ -54,7 +53,6 @@ impl Scenario {
|
||||
Self::ContendedReaders => "ContendedReaders",
|
||||
Self::ContendedWriters => "ContendedWriters",
|
||||
Self::FlakyDevice => "FlakyDevice",
|
||||
Self::ExternalCorruption => "ExternalCorruption",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +75,6 @@ impl Scenario {
|
||||
Self::ContendedReaders => "contended-readers",
|
||||
Self::ContendedWriters => "contended-writers",
|
||||
Self::FlakyDevice => "flaky-device",
|
||||
Self::ExternalCorruption => "external-corruption",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +109,6 @@ impl Scenario {
|
||||
Self::FlakyDevice => {
|
||||
"Real IO on ext4 atop dm-flakey. Requires root with dm-flakey available, skips otherwise."
|
||||
}
|
||||
Self::ExternalCorruption => {
|
||||
"Rare external data-file deletion mid-workload. Validates phantom-purge self-heal under chaos."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +138,6 @@ impl Scenario {
|
||||
Self::ContendedReaders,
|
||||
Self::ContendedWriters,
|
||||
Self::FlakyDevice,
|
||||
Self::ExternalCorruption,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -218,7 +211,6 @@ pub fn config_for(scenario: Scenario, seed: Seed) -> GauntletConfig {
|
||||
Scenario::ContendedReaders => contended_readers(seed),
|
||||
Scenario::ContendedWriters => contended_writers(seed),
|
||||
Scenario::FlakyDevice => flaky_device(seed),
|
||||
Scenario::ExternalCorruption => external_corruption(seed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +280,6 @@ fn smoke_pr(seed: Seed) -> GauntletConfig {
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +305,6 @@ fn mst_churn(seed: Seed) -> GauntletConfig {
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +330,6 @@ fn mst_restart_churn(seed: Seed) -> GauntletConfig {
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +359,6 @@ fn full_stack_restart(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,9 +372,6 @@ fn phase2_invariants() -> InvariantSet {
|
||||
| InvariantSet::BYTE_BUDGET
|
||||
| InvariantSet::MANIFEST_EQUALS_REALITY
|
||||
| InvariantSet::CHECKSUM_COVERAGE
|
||||
| InvariantSet::INDEX_BACKED_BY_DISK
|
||||
| InvariantSet::HINT_BACKED_BY_DATA
|
||||
| InvariantSet::INDEX_BLOCKS_READABLE
|
||||
}
|
||||
|
||||
fn catastrophic_churn(seed: Seed) -> GauntletConfig {
|
||||
@@ -407,7 +392,6 @@ fn catastrophic_churn(seed: Seed) -> GauntletConfig {
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,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),
|
||||
@@ -440,7 +424,6 @@ fn huge_values(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +454,6 @@ fn tiny_batches(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +484,6 @@ fn giant_batches(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +509,6 @@ fn many_files(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,9 +521,6 @@ fn sim_invariants() -> InvariantSet {
|
||||
| InvariantSet::NO_ORPHAN_FILES
|
||||
| InvariantSet::BYTE_BUDGET
|
||||
| InvariantSet::CHECKSUM_COVERAGE
|
||||
| InvariantSet::INDEX_BACKED_BY_DISK
|
||||
| InvariantSet::HINT_BACKED_BY_DATA
|
||||
| InvariantSet::INDEX_BLOCKS_READABLE
|
||||
}
|
||||
|
||||
fn sim_microbench_workload() -> WorkloadModel {
|
||||
@@ -581,7 +558,6 @@ fn moderate_faults(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +577,6 @@ fn aggressive_faults(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +596,6 @@ fn torn_pages(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +615,6 @@ fn fsyncgate(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +654,6 @@ fn firehose_fanout(seed: Seed) -> GauntletConfig {
|
||||
max_segment_size: MaxSegmentSize(64 * 1024),
|
||||
}),
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,7 +688,6 @@ fn contended_readers(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(64),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +719,6 @@ fn flaky_device(seed: Seed) -> GauntletConfig {
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,35 +753,5 @@ fn contended_writers(seed: Seed) -> GauntletConfig {
|
||||
store: sim_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(32),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn external_corruption(seed: Seed) -> GauntletConfig {
|
||||
GauntletConfig {
|
||||
seed,
|
||||
io: IoBackend::Real,
|
||||
workload: block_workload(
|
||||
OpWeights {
|
||||
add: 50,
|
||||
delete: 30,
|
||||
compact: 18,
|
||||
checkpoint: 1,
|
||||
external_delete_data_file: 1,
|
||||
..OpWeights::default()
|
||||
},
|
||||
SizeDistribution::Fixed(ValueBytes(128)),
|
||||
KeySpaceSize(200),
|
||||
),
|
||||
op_count: OpCount(2_000),
|
||||
invariants: InvariantSet::NO_ORPHAN_FILES | InvariantSet::BYTE_BUDGET,
|
||||
limits: RunLimits {
|
||||
max_wall_ms: Some(WallMs(60_000)),
|
||||
},
|
||||
restart_policy: RestartPolicy::EveryNOps(OpInterval(1_000)),
|
||||
store: tiny_store(),
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ mod tests {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::op::{
|
||||
CollectionName, DidSeed, EventKind, FileChoice, Op, OpStream, PayloadSeed, RecordKey,
|
||||
RetentionSecs, Seed, ValueSeed,
|
||||
CollectionName, DidSeed, EventKind, Op, OpStream, PayloadSeed, RecordKey, RetentionSecs, Seed,
|
||||
ValueSeed,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -23,7 +23,6 @@ pub struct OpWeights {
|
||||
pub run_retention: u32,
|
||||
pub read_record: u32,
|
||||
pub read_block: u32,
|
||||
pub external_delete_data_file: u32,
|
||||
}
|
||||
|
||||
impl OpWeights {
|
||||
@@ -37,7 +36,6 @@ impl OpWeights {
|
||||
+ self.run_retention
|
||||
+ self.read_record
|
||||
+ self.read_block
|
||||
+ self.external_delete_data_file
|
||||
}
|
||||
|
||||
pub const fn touches_eventlog(&self) -> bool {
|
||||
@@ -105,7 +103,6 @@ impl Default for WorkloadModel {
|
||||
run_retention: 0,
|
||||
read_record: 0,
|
||||
read_block: 0,
|
||||
external_delete_data_file: 0,
|
||||
},
|
||||
size_distribution: SizeDistribution::Fixed(ValueBytes(64)),
|
||||
collections: vec![CollectionName("app.bsky.feed.post".to_string())],
|
||||
@@ -141,7 +138,6 @@ impl WorkloadModel {
|
||||
let t6 = t5 + w.sync_event_log;
|
||||
let t7 = t6 + w.run_retention;
|
||||
let t8 = t7 + w.read_record;
|
||||
let t9 = t8 + w.read_block;
|
||||
|
||||
match bucket {
|
||||
b if b < t1 => Op::AddRecord {
|
||||
@@ -170,12 +166,9 @@ impl WorkloadModel {
|
||||
collection: coll,
|
||||
rkey,
|
||||
},
|
||||
b if b < t9 => Op::ReadBlock {
|
||||
_ => Op::ReadBlock {
|
||||
value_seed: ValueSeed(rng.next_u32()),
|
||||
},
|
||||
_ => Op::ExternalDeleteDataFile {
|
||||
choice: FileChoice(rng.next_u32()),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use record::{
|
||||
};
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
pub use sim::{
|
||||
FaultConfig, LatencyNs, OpRecord, PristineGuard, Probability, SimulatedIO, SyncReorderWindow,
|
||||
FaultConfig, LatencyNs, OpRecord, Probability, SimulatedIO, SyncReorderWindow,
|
||||
sim_proptest_cases, sim_seed_count, sim_seed_range, sim_single_seed,
|
||||
};
|
||||
|
||||
|
||||
@@ -1860,18 +1860,6 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::Infra(
|
||||
InfraRequest::MarkCommsFailedPermanent {
|
||||
id,
|
||||
error: error.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn create_invite_code(
|
||||
&self,
|
||||
code: &str,
|
||||
|
||||
@@ -1789,11 +1789,6 @@ pub enum InfraRequest {
|
||||
error: String,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
MarkCommsFailedPermanent {
|
||||
id: Uuid,
|
||||
error: String,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
CreateInviteCode {
|
||||
code: String,
|
||||
use_count: i32,
|
||||
@@ -3893,14 +3888,6 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
|
||||
.map_err(metastore_to_db);
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
InfraRequest::MarkCommsFailedPermanent { id, error, tx } => {
|
||||
let result = state
|
||||
.metastore
|
||||
.infra_ops()
|
||||
.mark_comms_failed_permanent(id, &error)
|
||||
.map_err(metastore_to_db);
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
InfraRequest::CreateInviteCode {
|
||||
code,
|
||||
use_count,
|
||||
|
||||
@@ -247,6 +247,7 @@ impl InfraOps {
|
||||
|
||||
val.status = status_to_u8(CommsStatus::Sent);
|
||||
val.sent_at_ms = Some(Utc::now().timestamp_millis());
|
||||
val.attempts = val.attempts.saturating_add(1);
|
||||
|
||||
let mut batch = self.db.batch();
|
||||
batch.insert(&self.infra, key.as_slice(), val.serialize());
|
||||
@@ -271,46 +272,9 @@ impl InfraOps {
|
||||
)?
|
||||
.ok_or(MetastoreError::InvalidInput("comms entry not found"))?;
|
||||
|
||||
let next_attempts = val.attempts.saturating_add(1);
|
||||
let exhausted = next_attempts >= val.max_attempts;
|
||||
let next_status = match exhausted {
|
||||
true => CommsStatus::Failed,
|
||||
false => CommsStatus::Pending,
|
||||
};
|
||||
let now_ms = Utc::now().timestamp_millis();
|
||||
let backoff_ms = i64::from(next_attempts).saturating_mul(60_000);
|
||||
|
||||
val.status = status_to_u8(next_status);
|
||||
val.error_message = Some(error.to_owned());
|
||||
val.attempts = next_attempts;
|
||||
val.scheduled_for_ms = now_ms.saturating_add(backoff_ms);
|
||||
|
||||
let mut batch = self.db.batch();
|
||||
batch.insert(&self.infra, key.as_slice(), val.serialize());
|
||||
|
||||
if let Some((hk, mut hv)) =
|
||||
self.find_history_entry(val.user_id.unwrap_or(Uuid::nil()), val.id)?
|
||||
{
|
||||
hv.status = status_to_u8(next_status);
|
||||
batch.insert(&self.infra, hk.as_slice(), hv.serialize());
|
||||
}
|
||||
|
||||
batch.commit().map_err(MetastoreError::Fjall)
|
||||
}
|
||||
|
||||
pub fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), MetastoreError> {
|
||||
let key = comms_queue_key(id);
|
||||
let mut val: QueuedCommsValue = point_lookup(
|
||||
&self.infra,
|
||||
key.as_slice(),
|
||||
QueuedCommsValue::deserialize,
|
||||
"corrupt comms queue entry",
|
||||
)?
|
||||
.ok_or(MetastoreError::InvalidInput("comms entry not found"))?;
|
||||
|
||||
val.status = status_to_u8(CommsStatus::Failed);
|
||||
val.error_message = Some(error.to_owned());
|
||||
val.attempts = val.max_attempts;
|
||||
val.attempts = val.attempts.saturating_add(1);
|
||||
|
||||
let mut batch = self.db.batch();
|
||||
batch.insert(&self.infra, key.as_slice(), val.serialize());
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{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,21 +522,22 @@ 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) {
|
||||
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)
|
||||
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.saturating_add(drift)
|
||||
}
|
||||
} else {
|
||||
offset
|
||||
};
|
||||
offset
|
||||
};
|
||||
|
||||
let storage = state.storage.get(&sid).unwrap();
|
||||
|
||||
@@ -590,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;
|
||||
@@ -606,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;
|
||||
@@ -615,33 +580,36 @@ 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) {
|
||||
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 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, fault.partial_write_probability) => {
|
||||
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, 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;
|
||||
@@ -675,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;
|
||||
@@ -684,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,
|
||||
@@ -725,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 {
|
||||
@@ -734,7 +695,7 @@ impl StorageIO for SimulatedIO {
|
||||
|
||||
state.op_log.push(OpRecord::Sync {
|
||||
fd: id,
|
||||
succeeded: true,
|
||||
succeeded: sync_succeeded,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -813,30 +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);
|
||||
let actually_persisted =
|
||||
!state.should_fault(seed, self.fault_config.dir_sync_failure_probability);
|
||||
|
||||
if actually_persisted {
|
||||
state.dirs_durable.insert(dir_path.clone());
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use common::{block_data, test_cid, tiny_blockstore_config, with_runtime};
|
||||
use tranquil_store::blockstore::{
|
||||
CompactionResult, DataFileId, TranquilBlockStore, hint_file_path,
|
||||
};
|
||||
|
||||
fn data_file_path(dir: &std::path::Path, file_id: DataFileId) -> std::path::PathBuf {
|
||||
dir.join(format!("{file_id}.tqb"))
|
||||
}
|
||||
|
||||
fn populate_with_compaction_history(store: &TranquilBlockStore, live_cids: &[u32]) {
|
||||
live_cids.iter().for_each(|&seed| {
|
||||
store
|
||||
.put_blocks_blocking(vec![(test_cid(seed), block_data(seed))])
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
(0..200u32).for_each(|round| {
|
||||
let churn = test_cid(50_000 + round);
|
||||
store
|
||||
.put_blocks_blocking(vec![(churn, block_data(50_000 + round))])
|
||||
.unwrap();
|
||||
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
|
||||
|
||||
if round % 4 == 0 {
|
||||
common::compact_lowest_liveness(store);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_indexed_data_file_externally_self_heals_on_compaction() {
|
||||
with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let live_cids: Vec<u32> = (0..12u32).collect();
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
populate_with_compaction_history(&store, &live_cids);
|
||||
drop(store);
|
||||
}
|
||||
|
||||
let data_dir = dir.path().join("data");
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
|
||||
let liveness = store.compaction_liveness(0).unwrap();
|
||||
let victim_fid = liveness
|
||||
.iter()
|
||||
.filter(|(_, info)| info.live_blocks > 0)
|
||||
.map(|(&fid, _)| fid)
|
||||
.next()
|
||||
.expect("expected at least one file with live blocks");
|
||||
|
||||
drop(store);
|
||||
|
||||
let victim_path = data_file_path(&data_dir, victim_fid);
|
||||
assert!(
|
||||
victim_path.exists(),
|
||||
"victim data file should exist before deletion"
|
||||
);
|
||||
fs::remove_file(&victim_path).unwrap();
|
||||
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
|
||||
let liveness_before = store.compaction_liveness(0).unwrap();
|
||||
assert!(
|
||||
liveness_before.contains_key(&victim_fid),
|
||||
"index should still claim the deleted file before compaction self-heal"
|
||||
);
|
||||
|
||||
let result = store.compact_file(victim_fid, 0).unwrap();
|
||||
match result {
|
||||
CompactionResult::Purged {
|
||||
file_id,
|
||||
phantom_blocks,
|
||||
} => {
|
||||
assert_eq!(file_id, victim_fid);
|
||||
assert!(
|
||||
phantom_blocks > 0,
|
||||
"expected to purge non-zero phantom entries"
|
||||
);
|
||||
}
|
||||
CompactionResult::Compacted(stats) => {
|
||||
panic!(
|
||||
"expected purge for missing source file, got compaction with {stats:?} live={} dead={}",
|
||||
stats.live_blocks, stats.dead_blocks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let liveness_after = store.compaction_liveness(0).unwrap();
|
||||
assert!(
|
||||
!liveness_after.contains_key(&victim_fid),
|
||||
"compaction-purge must remove all index entries pointing at the deleted file"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_hint_orphan_cleaned_by_consistency_repair() {
|
||||
with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
populate_with_compaction_history(&store, &(0..6u32).collect::<Vec<_>>());
|
||||
drop(store);
|
||||
}
|
||||
|
||||
let data_dir = dir.path().join("data");
|
||||
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
let any_existing_fid = store
|
||||
.list_data_files()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("expected at least one data file after populate");
|
||||
drop(store);
|
||||
|
||||
let orphan_hint_fid = DataFileId::new(any_existing_fid.raw().saturating_add(10_000));
|
||||
let orphan_path = hint_file_path(&data_dir, orphan_hint_fid);
|
||||
fs::write(&orphan_path, b"\x00").unwrap();
|
||||
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
|
||||
let metastore_dir = dir.path().join("metastore");
|
||||
std::fs::create_dir_all(&metastore_dir).unwrap();
|
||||
let metastore = tranquil_store::metastore::Metastore::open(
|
||||
&metastore_dir,
|
||||
tranquil_store::metastore::MetastoreConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let segments_dir = dir.path().join("eventlog").join("segments");
|
||||
std::fs::create_dir_all(&segments_dir).unwrap();
|
||||
let eventlog = tranquil_store::eventlog::EventLog::open(
|
||||
tranquil_store::eventlog::EventLogConfig {
|
||||
segments_dir,
|
||||
..tranquil_store::eventlog::EventLogConfig::default()
|
||||
},
|
||||
tranquil_store::RealIO::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report =
|
||||
tranquil_store::consistency::verify_store_consistency(&store, &metastore, &eventlog);
|
||||
|
||||
assert!(
|
||||
report.orphan_hint_files.contains(&orphan_hint_fid),
|
||||
"consistency check should flag the synthetic orphan hint file"
|
||||
);
|
||||
|
||||
let repair = tranquil_store::consistency::repair_known_issues(&store, &report);
|
||||
assert!(
|
||||
repair.orphan_hints_removed >= 1,
|
||||
"repair should remove the orphan hint file"
|
||||
);
|
||||
assert!(
|
||||
!orphan_path.exists(),
|
||||
"orphan hint file should be unlinked after repair"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consistency_check_flags_and_repairs_missing_indexed_file() {
|
||||
with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let live_cids: Vec<u32> = (0..10u32).collect();
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
populate_with_compaction_history(&store, &live_cids);
|
||||
drop(store);
|
||||
}
|
||||
|
||||
let data_dir = dir.path().join("data");
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
let victim_fid = store
|
||||
.compaction_liveness(0)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, info)| info.live_blocks > 0)
|
||||
.map(|(&fid, _)| fid)
|
||||
.next()
|
||||
.expect("expected at least one indexed file");
|
||||
drop(store);
|
||||
|
||||
fs::remove_file(data_file_path(&data_dir, victim_fid)).unwrap();
|
||||
let _ = fs::remove_file(hint_file_path(&data_dir, victim_fid));
|
||||
|
||||
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
|
||||
let metastore_dir = dir.path().join("metastore");
|
||||
std::fs::create_dir_all(&metastore_dir).unwrap();
|
||||
let metastore = tranquil_store::metastore::Metastore::open(
|
||||
&metastore_dir,
|
||||
tranquil_store::metastore::MetastoreConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let segments_dir = dir.path().join("eventlog").join("segments");
|
||||
std::fs::create_dir_all(&segments_dir).unwrap();
|
||||
let eventlog = tranquil_store::eventlog::EventLog::open(
|
||||
tranquil_store::eventlog::EventLogConfig {
|
||||
segments_dir,
|
||||
..tranquil_store::eventlog::EventLogConfig::default()
|
||||
},
|
||||
tranquil_store::RealIO::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report =
|
||||
tranquil_store::consistency::verify_store_consistency(&store, &metastore, &eventlog);
|
||||
assert!(
|
||||
report.missing_indexed_files.contains(&victim_fid),
|
||||
"consistency check should flag the missing indexed file"
|
||||
);
|
||||
|
||||
let repair = tranquil_store::consistency::repair_known_issues(&store, &report);
|
||||
assert!(
|
||||
repair.phantom_index_entries_purged > 0,
|
||||
"repair should purge phantom entries"
|
||||
);
|
||||
|
||||
let post_repair_liveness = store.compaction_liveness(0).unwrap();
|
||||
assert!(
|
||||
!post_repair_liveness.contains_key(&victim_fid),
|
||||
"no index entries should remain for the missing file after repair"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::with_runtime;
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
use tranquil_store::gauntlet::{
|
||||
Gauntlet, IndexBackedByDisk, Invariant, InvariantCtx, InvariantSet, Oracle, Scenario, Seed,
|
||||
config_for,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn index_backed_by_disk_invariant_catches_phantom_after_external_delete() {
|
||||
with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.path().join("data"),
|
||||
index_dir: dir.path().join("index"),
|
||||
max_file_size: 4096,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
let store = Arc::new(TranquilBlockStore::open(cfg).expect("open store"));
|
||||
|
||||
let cids: Vec<[u8; 36]> = (0..20u32)
|
||||
.map(|seed| {
|
||||
let le = seed.to_le_bytes();
|
||||
std::array::from_fn(|i| match i {
|
||||
0 => 0x01,
|
||||
1 => 0x71,
|
||||
2 => 0x12,
|
||||
3 => 0x20,
|
||||
4..8 => le[i - 4],
|
||||
_ => (seed as u8).wrapping_add(i as u8),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
cids.iter().for_each(|cid| {
|
||||
store
|
||||
.put_blocks_blocking(vec![(*cid, vec![0xAA; 96])])
|
||||
.expect("put block");
|
||||
});
|
||||
|
||||
let victim_fid = store
|
||||
.compaction_liveness(0)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, info)| info.live_blocks > 0)
|
||||
.map(|(&fid, _)| fid)
|
||||
.next()
|
||||
.expect("expected at least one indexed file");
|
||||
|
||||
let victim_path = dir.path().join("data").join(format!("{victim_fid}.tqb"));
|
||||
std::fs::remove_file(&victim_path).unwrap();
|
||||
|
||||
let oracle = Oracle::new();
|
||||
let ctx = InvariantCtx {
|
||||
store: &store,
|
||||
oracle: &oracle,
|
||||
root: None,
|
||||
eventlog: None,
|
||||
};
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let result = runtime.block_on(IndexBackedByDisk.check(&ctx));
|
||||
|
||||
let violation = result.expect_err("phantom index entry must trigger violation");
|
||||
assert_eq!(violation.invariant, "IndexBackedByDisk");
|
||||
assert!(
|
||||
violation.detail.contains(&victim_fid.to_string()),
|
||||
"violation detail must reference the deleted file_id: {}",
|
||||
violation.detail
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_corruption_scenario_survives_many_seeds() {
|
||||
let failures: Vec<String> = futures::future::join_all((0..5).map(Seed).map(|seed| async move {
|
||||
let cfg = config_for(Scenario::ExternalCorruption, seed);
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
(seed, report)
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|(_, r)| !r.is_clean())
|
||||
.map(|(seed, r)| {
|
||||
format!(
|
||||
"seed {}: {} violations\n {}",
|
||||
seed.0,
|
||||
r.violations.len(),
|
||||
r.violations
|
||||
.iter()
|
||||
.map(|v| format!("{}: {}", v.invariant, v.detail))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "long running, validates iris-class regression over many seeds"]
|
||||
async fn iris_class_regression_30_seeds() {
|
||||
let failures: Vec<String> =
|
||||
futures::future::join_all((0..30).map(Seed).map(|seed| async move {
|
||||
let mut cfg = config_for(Scenario::SmokePR, seed);
|
||||
cfg.invariants = cfg.invariants | InvariantSet::INDEX_BACKED_BY_DISK;
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
(seed, report)
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|(_, r)| !r.is_clean())
|
||||
.map(|(seed, r)| {
|
||||
format!(
|
||||
"seed {}: {} violations\n {}",
|
||||
seed.0,
|
||||
r.violations.len(),
|
||||
r.violations
|
||||
.iter()
|
||||
.map(|v| format!("{}: {}", v.invariant, v.detail))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n---\n"));
|
||||
}
|
||||
@@ -82,7 +82,6 @@ fn fast_sanity_config(seed: Seed) -> GauntletConfig {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +142,6 @@ async fn compaction_idempotent_sanity() {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -183,7 +181,6 @@ async fn no_orphan_files_sanity() {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -228,7 +225,6 @@ async fn simulated_pristine_roundtrip() {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -283,7 +279,6 @@ async fn firehose_fanout_pristine_smoke() {
|
||||
max_segment_size: MaxSegmentSize(32 * 1024),
|
||||
}),
|
||||
writer_concurrency: WriterConcurrency(1),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -330,7 +325,6 @@ async fn contended_readers_pristine_smoke() {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(16),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -378,7 +372,6 @@ async fn contended_writers_pristine_smoke() {
|
||||
},
|
||||
eventlog: None,
|
||||
writer_concurrency: WriterConcurrency(8),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
let report = Gauntlet::new(cfg).expect("build gauntlet").run().await;
|
||||
assert_clean(&report);
|
||||
@@ -467,113 +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),
|
||||
tolerate_op_errors: false,
|
||||
};
|
||||
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(fast_sanity_config, &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()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,15 +193,10 @@ fn compact_data_file_preserves_live_removes_dead() {
|
||||
.unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
|
||||
let stats = match store.compact_file(first_file, 0).unwrap() {
|
||||
tranquil_store::blockstore::CompactionResult::Compacted(s) => s,
|
||||
tranquil_store::blockstore::CompactionResult::Purged { .. } => {
|
||||
panic!("expected compaction, got phantom purge")
|
||||
}
|
||||
};
|
||||
assert!(stats.dead_blocks > 0, "should have removed dead blocks");
|
||||
assert!(stats.live_blocks > 0, "should have preserved live blocks");
|
||||
assert!(stats.reclaimed_bytes > 0, "should have reclaimed space");
|
||||
let result = store.compact_file(first_file, 0).unwrap();
|
||||
assert!(result.dead_blocks > 0, "should have removed dead blocks");
|
||||
assert!(result.live_blocks > 0, "should have preserved live blocks");
|
||||
assert!(result.reclaimed_bytes > 0, "should have reclaimed space");
|
||||
|
||||
[1u8, 3].iter().for_each(|&seed| {
|
||||
let data = store.get_block_sync(&test_cid(seed)).unwrap();
|
||||
|
||||
@@ -579,14 +579,9 @@ fn all_dead_file_compaction() {
|
||||
advance_epoch(&store);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
|
||||
let stats = match store.compact_file(first_file, 0).unwrap() {
|
||||
tranquil_store::blockstore::CompactionResult::Compacted(s) => s,
|
||||
tranquil_store::blockstore::CompactionResult::Purged { .. } => {
|
||||
panic!("expected compaction, got phantom purge")
|
||||
}
|
||||
};
|
||||
assert_eq!(stats.live_blocks, 0);
|
||||
assert!(stats.dead_blocks > 0);
|
||||
let result = store.compact_file(first_file, 0).unwrap();
|
||||
assert_eq!(result.live_blocks, 0);
|
||||
assert!(result.dead_blocks > 0);
|
||||
|
||||
(0u32..5).for_each(|seed| {
|
||||
let data = store.get_block_sync(&test_cid_u32(seed)).unwrap();
|
||||
@@ -843,15 +838,10 @@ fn grace_period_prevents_collection_during_active_write() {
|
||||
|
||||
let files = store.list_data_files().unwrap();
|
||||
let first_file = files[0];
|
||||
let stats = match store.compact_file(first_file, 600_000).unwrap() {
|
||||
tranquil_store::blockstore::CompactionResult::Compacted(s) => s,
|
||||
tranquil_store::blockstore::CompactionResult::Purged { .. } => {
|
||||
panic!("expected compaction, got phantom purge")
|
||||
}
|
||||
};
|
||||
let result = store.compact_file(first_file, 600_000).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
stats.dead_blocks, 0,
|
||||
result.dead_blocks, 0,
|
||||
"grace period should prevent any collection"
|
||||
);
|
||||
|
||||
|
||||
@@ -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,53 +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}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,172 +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));
|
||||
}
|
||||
|
||||
@@ -124,7 +124,6 @@ fn verify_integrity(stores: &TestStores, oracle: &SoakOracle) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "wall-clock soak, 30min runtime. Reenable once we mock time for deterministic soak"]
|
||||
fn sim_soak_continuous_operations_with_crash_recovery() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let _guard = rt.enter();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,13 +85,14 @@ async fn recover_lagged_events(
|
||||
};
|
||||
for event in events {
|
||||
*last_seen = event.seq;
|
||||
let bytes = match format_event_with_prefetched_blocks(state, event, &prefetched).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Lag recovery format failed: {}", e);
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
let bytes =
|
||||
match format_event_with_prefetched_blocks(state, event, &prefetched).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Lag recovery format failed: {}", e);
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
if let Err(e) = socket.send(Message::Binary(bytes.into())).await {
|
||||
warn!("Lag recovery send failed: {}", e);
|
||||
return Err(());
|
||||
@@ -301,6 +302,8 @@ async fn handle_socket_inner(
|
||||
break;
|
||||
};
|
||||
|
||||
info!("{msg:?}");
|
||||
|
||||
if let Message::Close(_) = msg {
|
||||
info!("Client closed connection");
|
||||
break;
|
||||
@@ -311,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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-174
@@ -38,189 +38,18 @@ In `configuration.nix`:
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true; # set to false if you prefer to manually manage postgres. You must then set settings.database.url.
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
# database.url = "postgresql://user:postgres@example.com" -- Only if database.createLocally is set to false.
|
||||
# see example.toml for all options
|
||||
};
|
||||
# see Secrets section for more information.
|
||||
environmentFiles = [ "/etc/secrets/tranquil.env.production" ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
You will also likely want to configure Caddy or nginx to actually serve traffic to the service. An example Caddy config is provided below.
|
||||
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](https://tangled.org/tranquil.farm/tranquil-pds/blob/main/example.toml) at the repository root for the full set of configuration options.
|
||||
|
||||
### Example Caddy config
|
||||
|
||||
```nix
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
|
||||
virtualHosts = {
|
||||
"pds.example.com" = {
|
||||
# by default, tranquil runs on port 3000.
|
||||
# You can change this with the tranquil-pds.settings.server.port option in the service config.
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:3000
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Secrets
|
||||
|
||||
Additionally, you will need to provide the following environment variables.
|
||||
|
||||
```env
|
||||
# use `openssl rand -base64 32` to generate these.
|
||||
JWT_SECRET=<secret_here>
|
||||
DPOP_SECRET=<secret_here>
|
||||
MASTER_KEY=<secret_here>
|
||||
```
|
||||
|
||||
The simplest (least secure and least reproducible) option is to provide these secrets in a `.env` file using the `environmentFiles` option as shown above.
|
||||
|
||||
It is recommended that you use something like [`agenix`](https://github.com/ryantm/agenix) or [`sops-nix`](https://github.com/Mic92/sops-nix) for proper secrets management on a NixOS machine instead.
|
||||
|
||||
Example `sops-nix` config:
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (config.sops) secrets;
|
||||
in
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
};
|
||||
environmentFiles = [ secrets.tranquils-secrets.path ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Communications
|
||||
|
||||
To actually be able to receive communications from the PDS for things like verification codes, or PLC operations, you must set at least one of the following options.
|
||||
|
||||
### Email
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (config.sops) secrets;
|
||||
in
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
email.from_address = "tranquil_admin@pds.example.com";
|
||||
# email.from_name = "Tranquil PDS";
|
||||
};
|
||||
environmentFiles = [ secrets.tranquils-secrets.path ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
For DKIM options, please consult the [example.toml](https://tangled.org/tranquil.farm/tranquil-pds/blob/main/example.toml) at the repository root.
|
||||
|
||||
### Discord
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (config.sops) secrets;
|
||||
in
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
# if you're using proper secrets management, you should provide DISCORD_BOT_TOKEN in the environment file instead.
|
||||
discord.bot_token = "whatever";
|
||||
};
|
||||
environmentFiles = [ secrets.tranquils-secrets.path ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Telegram
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (config.sops) secrets;
|
||||
in
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
telegram = {
|
||||
# if you're using proper secrets management, you should provide TELEGRAM_BOT_TOKEN in the environment file instead.
|
||||
bot_token = "whatever";
|
||||
# if you're using proper secrets management, you should provide TELEGRAM_WEBHOOK_SECRET in the environment file instead.
|
||||
webhook_secret = "whatever2";
|
||||
};
|
||||
};
|
||||
environmentFiles = [ secrets.tranquils-secrets.path ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Signal
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (config.sops) secrets;
|
||||
in
|
||||
{
|
||||
services.tranquil-pds = {
|
||||
enable = true;
|
||||
database.createLocally = true;
|
||||
settings = {
|
||||
server.hostname = "pds.example.com";
|
||||
# you must link a device using the admin API before enabling this option.
|
||||
signal.enabled = true;
|
||||
};
|
||||
environmentFiles = [ secrets.tranquils-secrets.path ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### No comms channel
|
||||
|
||||
If you have not set up any of these, you can technically still access any relevant information by querying the database directly.
|
||||
|
||||
Please keep in mind this is a last-ditch attempt and it is highly recommended that you do in fact specify some channel for communications.
|
||||
|
||||
Running `sudo -u tranquil-pds psql` will give you command line access to the PostgreSQL database.
|
||||
|
||||
From there, you can run `SELECT * FROM comms_queue;` which will return all communications sent from the PDS. From there, you can extract any relevant information.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
When the PDS service is able to properly run for the first time, you will be given a bootstrap invite code to migrate your repository to this PDS.
|
||||
|
||||
The simplest way to find this invite code is to check the service logs by doing `journalctl -u tranquil-pds`.
|
||||
|
||||
The log entry shold look something like this:
|
||||
|
||||
`INFO tranquil_pds::state: No users exist and invite codes are required. Bootstrap invite code: <invite_code_here>`
|
||||
See [example.toml](../example.toml) for the full set of configuration options.
|
||||
|
||||
## Binary cache
|
||||
|
||||
|
||||
+4
-109
@@ -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.
|
||||
|
||||
Generated
+10
-7
@@ -2,15 +2,18 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778672786,
|
||||
"narHash": "sha256-Tf7q8/0aIwg4Btd/GUMNHXDTqlrWTr5o/vZ8euFUAQY=",
|
||||
"rev": "eef00dfd8a712b34af845f9350bac681b1228bd1",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre997230.eef00dfd8a71/nixexprs.tar.xz?lastModified=1778672786&rev=eef00dfd8a712b34af845f9350bac681b1228bd1"
|
||||
"lastModified": 1775888245,
|
||||
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13043924aaa7375ce482ebe2494338e058282925",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-qbmIAvE/3u/NB5x9bERCGQqwiDLkzjff3QchgR+ZDFs=";
|
||||
hash = "sha256-E0S8dOaTOpY9m7Ft59tUQ6CLlLriWPE4WE1+S45vomY=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
FROM node:24-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . ./
|
||||
RUN pnpm build
|
||||
|
||||
Generated
+1
@@ -1311,6 +1311,7 @@ packages:
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
confirmModulesPurge: false
|
||||
allowBuilds:
|
||||
es5-ext: true
|
||||
esbuild: true
|
||||
@@ -120,6 +120,14 @@ in
|
||||
description = "Directory for storing blobs";
|
||||
};
|
||||
};
|
||||
|
||||
email = {
|
||||
sendmail_path = mkOption {
|
||||
type = types.path;
|
||||
default = lib.getExe pkgs.system-sendmail;
|
||||
description = "Path to the sendmail executable to use for sending emails.";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user