diff --git a/src/core/observability.rs b/src/core/observability.rs index c31ee199b..0ca725a1b 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -183,6 +183,17 @@ pub enum ExpectedErrorKind { /// reset window elapses and a subsequent init succeeds. /// MemoryStoreBreakerOpen, + /// WhatsApp structured-ingest write hit a transient SQLite file lock + /// (`SQLITE_BUSY` / `SQLITE_LOCKED`) after exhausting the local retry + /// budget. This is an expected local-contention condition (typically on + /// Windows when another process briefly holds a file lock) and the + /// scanner retries on the next tick, so Sentry has no immediate + /// remediation path. + /// + /// Anchored narrowly to the whatsapp ingest failure envelope plus the + /// SQLite lock text, so unrelated DB lock errors in other domains still + /// reach Sentry. + WhatsAppDataSqliteBusy, /// 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 @@ -385,6 +396,9 @@ pub fn expected_error_kind(message: &str) -> Option { if is_memory_store_breaker_open(&lower) { return Some(ExpectedErrorKind::MemoryStoreBreakerOpen); } + if is_whatsapp_data_sqlite_busy_message(&lower) { + return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy); + } if is_disk_full_message(&lower) { return Some(ExpectedErrorKind::DiskFull); } @@ -425,6 +439,22 @@ fn is_disk_full_message(lower: &str) -> bool { lower.contains("no space left on device") || lower.contains("not enough space on the disk") } +/// Match whatsapp structured-ingest failures caused by transient SQLite lock +/// contention. Keep this matcher scoped to the whatsapp ingest envelope so we +/// don't demote unrelated database failures in other domains. +fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool { + if !lower.contains("[whatsapp_data] ingest failed:") { + return false; + } + if !lower.contains("upsert wa_message") { + return false; + } + lower.contains("database is locked") + || lower.contains("database table is locked") + || lower.contains("database file is locked") + || lower.contains("error code 5") +} + fn is_embedding_backend_auth_failure(lower: &str) -> bool { lower.contains("embedding api error") && lower.contains("401") @@ -1453,6 +1483,14 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, "[observability] {domain}.{operation} skipped expected memory-store circuit-breaker-open error" ); } + ExpectedErrorKind::WhatsAppDataSqliteBusy => { + tracing::warn!( + domain = domain, + operation = operation, + kind = "whatsapp_data_sqlite_busy", + "[observability] {domain}.{operation} skipped expected whatsapp_data sqlite busy/locked error" + ); + } ExpectedErrorKind::FilesystemUserPathInvalid => { // User-input validation failure surfaced at the RPC // boundary — e.g. `openhuman.vault_create` called with a @@ -2489,6 +2527,35 @@ mod tests { ); } + #[test] + fn classifies_whatsapp_data_sqlite_busy_errors() { + for raw in [ + r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "should classify whatsapp_data sqlite busy/locked: {raw}" + ); + } + } + + #[test] + fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { + for raw in [ + "failed to run subconscious schema DDL: database is locked", + "memory queue write failed: database table is locked", + "[whatsapp_data] list_messages failed: database is locked", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "must not classify as whatsapp_data sqlite busy: {raw}" + ); + } + } + #[test] fn does_not_classify_unrelated_messages_as_memory_pii_rejection() { // A generic "personal identifiers" mention without the "cannot contain"