Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/core/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ pub enum ExpectedErrorKind {
/// SQLite lock text, so unrelated DB lock errors in other domains still
/// reach Sentry.
WhatsAppDataSqliteBusy,
/// WhatsApp structured-ingest write hit a `SQLITE_CORRUPT` malformed on-disk
/// image ("database disk image is malformed" / "file is not a database").
///
/// This is **defense-in-depth after** the store's own quarantine + rebuild
/// recovery (`whatsapp_data::store::WhatsAppDataStore::recover_corrupt_db`),
/// never instead of it: the store detects the corrupt image, reports it to
/// Sentry exactly once (process-wide latch), quarantines the damaged file,
/// and rebuilds an empty schema so ingest resumes. This classifier only
/// demotes the residual noise that can leak in the narrow window between
/// detection and a successful rebuild, or when a rebuild keeps failing on a
/// wedged host filesystem the app can't fix. Without both, one corrupt file
/// re-pages on every 2–30s scan tick (Sentry TAURI-RUST-KNH: 1,813
/// escalating events from a single host).
///
/// Anchored to the whatsapp ingest failure envelope plus the malformed-image
/// text, so unrelated corruption in other domains still reaches Sentry.
WhatsAppDataSqliteCorrupt,
/// Host disk is full — the filesystem returned `ENOSPC` to a write,
/// `mkdir`, or `open` syscall. The user cannot recover from this without
/// freeing space on their machine, and Sentry has no remediation path
Expand Down Expand Up @@ -588,6 +605,12 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
if is_memory_store_breaker_open(&lower) {
return Some(ExpectedErrorKind::MemoryStoreBreakerOpen);
}
// Corruption is checked before the busy matcher: the two envelopes are
// mutually exclusive by their SQLite text (malformed-image vs locked), but
// ordering keeps the more-specific on-disk-damage signal unambiguous.
if is_whatsapp_data_sqlite_corrupt_message(&lower) {
return Some(ExpectedErrorKind::WhatsAppDataSqliteCorrupt);
}
if is_whatsapp_data_sqlite_busy_message(&lower) {
return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy);
}
Expand Down Expand Up @@ -791,6 +814,30 @@ fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool {
|| lower.contains("error code 5")
}

/// Match whatsapp structured-ingest failures caused by a `SQLITE_CORRUPT`
/// malformed on-disk image. Scoped to the whatsapp ingest envelope plus an
/// upsert write frame, so unrelated malformed-image errors in other domains
/// still reach Sentry.
///
/// This is defense-in-depth **after** the store's quarantine + rebuild recovery
/// (see [`ExpectedErrorKind::WhatsAppDataSqliteCorrupt`]) — the store already
/// reports the first hit once and rebuilds the DB; this only demotes residual
/// noise. Both `upsert wa_chat` and `upsert wa_message` frames are matched
/// because the observed Sentry symptom (TAURI-RUST-KNH) fired on the
/// `upsert wa_chat <jid>@lid` path.
fn is_whatsapp_data_sqlite_corrupt_message(lower: &str) -> bool {
if !lower.contains("[whatsapp_data] ingest failed:") {
return false;
}
if !(lower.contains("upsert wa_message") || lower.contains("upsert wa_chat")) {
return false;
Comment on lines +832 to +833

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Demote corrupt errors from the prune write path

This classifier only accepts upsert wa_chat/upsert wa_message frames, but the same commit wraps prune_old_messages in corrupt recovery. If corruption is detected from the prune path and recovery/retry still fails, the RPC error is shaped around prune old wa_messages (or the bare SQLite prepare/open error), so this predicate returns false and the residual RPC-path event can still page Sentry every scan tick; include the prune write context in the narrow match.

Useful? React with 👍 / 👎.

}
lower.contains("disk image is malformed")
|| lower.contains("file is not a database")
|| lower.contains("error code 11")
|| lower.contains("error code 26")
}

/// Match subconscious-engine SQLite schema-init failures caused by the host
/// filesystem being unable to open the DB file (`SQLITE_CANTOPEN` /
/// `SQLITE_IOERR_SHMMAP`). Anchored to the subconscious open/DDL envelope so it
Expand Down Expand Up @@ -2097,6 +2144,14 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
"[observability] {domain}.{operation} skipped expected whatsapp_data sqlite busy/locked error"
);
}
ExpectedErrorKind::WhatsAppDataSqliteCorrupt => {
tracing::warn!(
domain = domain,
operation = operation,
kind = "whatsapp_data_sqlite_corrupt",
"[observability] {domain}.{operation} skipped expected whatsapp_data sqlite corrupt error (store quarantines + rebuilds the DB and reports once)"
);
}
ExpectedErrorKind::FilesystemUserPathInvalid => {
// User-input validation failure surfaced at the RPC
// boundary — e.g. `openhuman.vault_create` called with a
Expand Down Expand Up @@ -4330,6 +4385,42 @@ mod tests {
}
}

#[test]
fn classifies_whatsapp_data_sqlite_corrupt_errors() {
for raw in [
// The observed TAURI-RUST-KNH symptom: corruption on the wa_chat path.
r#"[whatsapp_data] ingest failed: upsert wa_chat 207897942335683@lid: database disk image is malformed: Error code 11: The database disk image is malformed"#,
// The wa_message path — same on-disk damage class.
r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=abc: file is not a database: Error code 26: File opened that is not a database file"#,
// Wrapped in outer RPC context — classifier runs on the full chain.
r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_chat 207897942335683@lid: database disk image is malformed"#,
] {
assert_eq!(
expected_error_kind(raw),
Some(ExpectedErrorKind::WhatsAppDataSqliteCorrupt),
"should classify whatsapp_data sqlite corrupt: {raw}"
);
}
}

#[test]
fn does_not_classify_unrelated_corrupt_messages_as_whatsapp_corrupt() {
for raw in [
// Malformed image outside the whatsapp ingest envelope must still page.
"memory queue write failed: database disk image is malformed",
// Read-path whatsapp failure (no upsert frame) is not the ingest write.
"[whatsapp_data] list_messages failed: database disk image is malformed",
// whatsapp ingest lock contention is the *busy* bucket, not corrupt.
"[whatsapp_data] ingest failed: upsert wa_message chat=x msg=y: database is locked",
] {
assert_ne!(
expected_error_kind(raw),
Some(ExpectedErrorKind::WhatsAppDataSqliteCorrupt),
"must not classify as whatsapp_data sqlite corrupt: {raw}"
);
}
}

#[test]
fn classifies_subconscious_schema_unavailable_errors() {
for raw in [
Expand Down
119 changes: 118 additions & 1 deletion src/openhuman/whatsapp_data/sqlite_retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use std::thread;
use std::time::Duration;

use anyhow::{Context, Result};
// `anyhow::Error::context` (used in `retry_on_sqlite_busy`) is the inherent
// method on `Error`, not the `Context` trait, so only `Result` is imported.
use anyhow::Result;

/// Per-connection busy handler window (issue #2077).
pub const BUSY_TIMEOUT: Duration = Duration::from_millis(5000);
Expand All @@ -30,6 +32,40 @@ pub fn is_sqlite_busy(err: &anyhow::Error) -> bool {
msg.contains("database is locked") || msg.contains("database table is locked")
}

/// Returns true when `err` is a `SQLITE_CORRUPT` malformed-image condition
/// (primary code `DatabaseCorrupt`, code 11) or the closely-related
/// `NotADatabase` (code 26 — the header itself is unreadable).
///
/// Unlike [`is_sqlite_busy`], a malformed on-disk image is **persistent
/// damage**: the upsert can never succeed, so every scan poll (2–30s)
/// re-opens the dead file, re-hits the error, and re-reports to Sentry —
/// turning one corrupt file into an escalating flood (Sentry TAURI-RUST-KNH:
/// 1,813 events from a single host). Detecting it here is what lets the store
/// drive its quarantine + rebuild recovery
/// ([`WhatsAppDataStore::recover_corrupt_db`](crate::openhuman::whatsapp_data::store::WhatsAppDataStore))
/// instead of retrying forever.
///
/// Matching on the error **code** is rusqlite-version-stable. The text
/// fallback covers the case where the rusqlite error was flattened to a plain
/// `anyhow!` string across `.context()` layers — SQLite renders these as
/// "database disk image is malformed" (code 11) and "file is not a database"
/// (code 26). The `"disk image is malformed"` substring matches both the bare
/// and `Error code 11: The database disk image is malformed` envelope shapes.
pub fn is_sqlite_corrupt(err: &anyhow::Error) -> bool {
if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) =
err.downcast_ref::<rusqlite::Error>()
{
if matches!(
sqlite_err.code,
rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase
) {
return true;
}
}
let msg = format!("{err:#}").to_ascii_lowercase();
msg.contains("disk image is malformed") || msg.contains("file is not a database")
}

/// Run `f` up to [`WRITE_RETRY_ATTEMPTS`] times when SQLite reports busy/locked.
pub fn retry_on_sqlite_busy<T, F>(op_name: &str, mut f: F) -> Result<T>
where
Expand Down Expand Up @@ -101,6 +137,87 @@ mod tests {
assert!(!is_sqlite_busy(&err));
}

#[test]
fn is_sqlite_corrupt_matches_database_corrupt_code() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseCorrupt,
extended_code: 11,
},
Some("database disk image is malformed".into()),
);
assert!(is_sqlite_corrupt(&anyhow::Error::from(raw)));
}

#[test]
fn is_sqlite_corrupt_matches_not_a_database_code() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::NotADatabase,
extended_code: 26,
},
Some("file is not a database".into()),
);
assert!(is_sqlite_corrupt(&anyhow::Error::from(raw)));
}

/// The rusqlite error sits under the `[whatsapp_data] ingest failed:` +
/// `upsert wa_chat …` context layers when it bubbles out of the store; the
/// downcast must still find the `DatabaseCorrupt` code (regression guard:
/// don't rely on the top-level error type).
#[test]
fn is_sqlite_corrupt_matches_through_context_layers() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseCorrupt,
extended_code: 11,
},
Some("database disk image is malformed".into()),
);
let wrapped = anyhow::Error::from(raw)
.context("upsert wa_chat 207897942335683@lid")
.context("[whatsapp_data] ingest failed");
assert!(is_sqlite_corrupt(&wrapped));
}

/// Text fallback: the exact flattened Sentry string (TAURI-RUST-KNH) must
/// classify even when no rusqlite error is available to downcast.
#[test]
fn is_sqlite_corrupt_text_fallback() {
let err = anyhow::anyhow!(
"[whatsapp_data] ingest failed: upsert wa_chat 207897942335683@lid: \
database disk image is malformed: Error code 11: The database disk image is malformed"
);
assert!(is_sqlite_corrupt(&err));
}

/// Busy/locked and constraint violations must NOT be swallowed as
/// corruption — quarantining on those would destroy a perfectly good DB.
#[test]
fn is_sqlite_corrupt_does_not_match_busy_or_constraint() {
let busy = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5,
},
Some("database is locked".into()),
);
assert!(!is_sqlite_corrupt(&anyhow::Error::from(busy)));

let constraint = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::ConstraintViolation,
extended_code: 19,
},
Some("UNIQUE constraint failed".into()),
);
assert!(!is_sqlite_corrupt(&anyhow::Error::from(constraint)));

assert!(!is_sqlite_corrupt(&anyhow::anyhow!(
"upstream returned 500: internal server error"
)));
}

#[test]
fn retry_on_sqlite_busy_succeeds_after_transient_busy() {
let mut calls = 0u32;
Expand Down
Loading
Loading