mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(subconscious): degrade WAL journal mode to fix Intelligence tab hang on shm-incompatible filesystems (#3233)
This commit is contained in:
@@ -226,6 +226,29 @@ pub enum ExpectedErrorKind {
|
||||
/// `is_network_unreachable_message` anchors miss the inner OS message.
|
||||
ChannelSupervisorRestart,
|
||||
ConfigLoadTimedOut,
|
||||
/// The subconscious engine's SQLite schema init couldn't open its database
|
||||
/// file at all — a host-filesystem condition, not a code bug. Two canonical
|
||||
/// renderings, both bound to the user's local FS:
|
||||
///
|
||||
/// - `SQLITE_CANTOPEN` (14): `unable to open the database file` — the
|
||||
/// `subconscious/` dir or DB file isn't writable/openable (permissions,
|
||||
/// a vanished mount, a read-only volume).
|
||||
/// - `SQLITE_IOERR_SHMMAP` (4618): `I/O error within the xShmMap method` —
|
||||
/// the filesystem can't back WAL's mmap'd `-shm` segment (network mounts,
|
||||
/// FUSE, some sandboxed/synced macOS paths).
|
||||
///
|
||||
/// The `xShmMap` case is now *prevented* at the source by
|
||||
/// `subconscious::store::apply_journal_mode`, which degrades WAL to a
|
||||
/// rollback journal that needs no shared memory (issue #3231). This kind
|
||||
/// demotes the *residual* genuine `CANTOPEN` failures — where even opening
|
||||
/// the file fails — which the user must resolve locally (fix permissions,
|
||||
/// remount, free the volume) and which Sentry has no remediation path for.
|
||||
///
|
||||
/// Anchored to the subconscious schema/open envelope plus the SQLite
|
||||
/// cant-open / shared-memory IO text, so transient `database is locked`
|
||||
/// contention (handled by the store's busy-retry loop) and unrelated DB
|
||||
/// failures in other domains still reach Sentry.
|
||||
SubconsciousSchemaUnavailable,
|
||||
}
|
||||
|
||||
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
|
||||
@@ -344,6 +367,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
|
||||
if is_whatsapp_data_sqlite_busy_message(&lower) {
|
||||
return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy);
|
||||
}
|
||||
if is_subconscious_schema_unavailable_message(&lower) {
|
||||
return Some(ExpectedErrorKind::SubconsciousSchemaUnavailable);
|
||||
}
|
||||
if is_disk_full_message(&lower) {
|
||||
return Some(ExpectedErrorKind::DiskFull);
|
||||
}
|
||||
@@ -409,6 +435,26 @@ fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool {
|
||||
|| lower.contains("error code 5")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// can't demote unrelated DB failures, and deliberately scoped to cant-open /
|
||||
/// shared-memory IO text — *not* `database is locked`, which the store retries
|
||||
/// and which (if persistent) is a real contention signal worth surfacing.
|
||||
///
|
||||
/// See [`ExpectedErrorKind::SubconsciousSchemaUnavailable`].
|
||||
fn is_subconscious_schema_unavailable_message(lower: &str) -> bool {
|
||||
let in_subconscious_envelope = lower.contains("subconscious schema ddl")
|
||||
|| lower.contains("failed to open subconscious db");
|
||||
if !in_subconscious_envelope {
|
||||
return false;
|
||||
}
|
||||
lower.contains("unable to open the database file")
|
||||
|| lower.contains("xshmmap")
|
||||
|| lower.contains("error code 14")
|
||||
|| lower.contains("error code 4618")
|
||||
}
|
||||
|
||||
fn is_embedding_backend_auth_failure(lower: &str) -> bool {
|
||||
lower.contains("embedding api error")
|
||||
&& lower.contains("401")
|
||||
@@ -1503,6 +1549,26 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
|
||||
"[observability] {domain}.{operation} skipped expected config-load timeout: {message}"
|
||||
);
|
||||
}
|
||||
ExpectedErrorKind::SubconsciousSchemaUnavailable => {
|
||||
// Host-filesystem condition: SQLite couldn't open the subconscious
|
||||
// DB file (CANTOPEN / xShmMap). The WAL-fallback in
|
||||
// `subconscious::store` already prevents the shared-memory variant;
|
||||
// what reaches here is a genuine local open failure the user must
|
||||
// fix on their machine (permissions, remount, free the volume) —
|
||||
// Sentry has no remediation path. Demote at `warn!` so a sustained
|
||||
// spike still shows in operator dashboards without turning every
|
||||
// affected session into a Sentry error event. Drops TAURI-RUST-8WM.
|
||||
// Do not include the raw `message`: it can embed the absolute
|
||||
// subconscious DB path (home dir / username). Mirror the
|
||||
// metadata-only demotions (`DiskFull`, `FilesystemUserPathInvalid`)
|
||||
// and log only domain/operation/kind — no PII in the breadcrumb.
|
||||
tracing::warn!(
|
||||
domain = domain,
|
||||
operation = operation,
|
||||
kind = "subconscious_schema_unavailable",
|
||||
"[observability] {domain}.{operation} skipped expected subconscious schema DB-unavailable error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2528,6 +2594,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_subconscious_schema_unavailable_errors() {
|
||||
for raw in [
|
||||
// SQLITE_IOERR_SHMMAP (4618) — the original escalating issue (#3231).
|
||||
"failed to run subconscious schema DDL: disk I/O error: Error code 4618: I/O error within the xShmMap method (trying to open a new shared-memory segment)",
|
||||
// SQLITE_CANTOPEN (14) — sibling variant from the user report.
|
||||
"failed to run subconscious schema DDL: unable to open database file: Error code 14: Unable to open the database file",
|
||||
// Failure surfaced at the open step rather than the DDL step.
|
||||
"failed to open subconscious DB: /home/u/.openhuman/subconscious/subconscious.db: unable to open the database file",
|
||||
// Wrapped in outer RPC context — classifier runs on the full chain.
|
||||
"rpc.invoke_method failed: failed to run subconscious schema DDL: disk I/O error: Error code 4618",
|
||||
] {
|
||||
assert_eq!(
|
||||
expected_error_kind(raw),
|
||||
Some(ExpectedErrorKind::SubconsciousSchemaUnavailable),
|
||||
"should classify subconscious schema DB-unavailable: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_classify_subconscious_lock_or_unrelated_open_failures() {
|
||||
for raw in [
|
||||
// Transient busy/locked is retried by the store and, if persistent,
|
||||
// is a real contention signal — must NOT be demoted here.
|
||||
"failed to run subconscious schema DDL: database is locked",
|
||||
// Cant-open text without the subconscious envelope must not match.
|
||||
"failed to open memory DB: unable to open the database file",
|
||||
// Subconscious envelope but a non-FS error (e.g. malformed SQL) stays
|
||||
// a real bug worth reporting.
|
||||
"failed to run subconscious schema DDL: near \"CREAT\": syntax error",
|
||||
] {
|
||||
assert_ne!(
|
||||
expected_error_kind(raw),
|
||||
Some(ExpectedErrorKind::SubconsciousSchemaUnavailable),
|
||||
"must not classify as subconscious schema unavailable: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() {
|
||||
for raw in [
|
||||
|
||||
@@ -81,6 +81,18 @@ fn open_and_initialize(db_path: &Path) -> Result<Connection> {
|
||||
conn.busy_timeout(BUSY_TIMEOUT)
|
||||
.context("configure subconscious busy_timeout")?;
|
||||
|
||||
// Choose a journal mode this filesystem can actually back *before* running
|
||||
// the table DDL. WAL is preferred for read/write concurrency, but it
|
||||
// eagerly opens an mmap-backed `-shm` shared-memory segment; on network
|
||||
// mounts, FUSE, and some sandboxed/synced macOS paths that mmap fails with
|
||||
// SQLITE_IOERR_SHMMAP (4618) or SQLITE_CANTOPEN (14). Previously the
|
||||
// `PRAGMA journal_mode = WAL` lived inside `SCHEMA_DDL`, so such a failure
|
||||
// aborted the whole schema-init batch and hung the Intelligence tab
|
||||
// (issue #3231 / TAURI-RUST-8WM). WAL is a performance optimization, not a
|
||||
// correctness requirement, so degrade to a rollback journal that needs no
|
||||
// shared memory rather than failing init.
|
||||
apply_journal_mode(&conn);
|
||||
|
||||
conn.execute_batch(SCHEMA_DDL)
|
||||
.context("failed to run subconscious schema DDL")?;
|
||||
|
||||
@@ -91,6 +103,66 @@ fn open_and_initialize(db_path: &Path) -> Result<Connection> {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Set the SQLite journal mode for the subconscious DB, preferring WAL but
|
||||
/// gracefully degrading to a rollback journal when the filesystem can't back
|
||||
/// WAL's shared-memory segment.
|
||||
///
|
||||
/// `PRAGMA journal_mode = WAL` opens (and mmaps) the `-shm` file eagerly, so a
|
||||
/// filesystem that can't support shared memory surfaces the failure here as
|
||||
/// `SQLITE_IOERR_SHMMAP` / `SQLITE_CANTOPEN` rather than at first write. We try
|
||||
/// WAL, and on any error — or if the engine silently refuses to switch (e.g.
|
||||
/// in-memory DBs report `memory`) — fall back to `TRUNCATE`, which uses an
|
||||
/// ordinary rollback journal and needs no shared memory. Failures are logged,
|
||||
/// never propagated: a working rollback journal is strictly better than a
|
||||
/// failed schema init, and the caller's DDL/queries work identically either
|
||||
/// way.
|
||||
///
|
||||
/// Logs use a stable `db = "subconscious.db"` identifier rather than the
|
||||
/// absolute path — the workspace path embeds the user's home dir / username, so
|
||||
/// emitting it would leak PII into the breadcrumb stream.
|
||||
fn apply_journal_mode(conn: &Connection) {
|
||||
match query_journal_mode(conn, "WAL") {
|
||||
Ok(mode) if mode.eq_ignore_ascii_case("wal") => return,
|
||||
Ok(mode) => {
|
||||
tracing::warn!(
|
||||
target: "openhuman::subconscious::store",
|
||||
db = "subconscious.db",
|
||||
requested = "wal",
|
||||
effective = %mode,
|
||||
"[subconscious::store] WAL journal mode not honoured; falling back to TRUNCATE"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "openhuman::subconscious::store",
|
||||
db = "subconscious.db",
|
||||
error = %format!("{e}"),
|
||||
"[subconscious::store] WAL journal mode failed (filesystem can't back -shm); falling back to TRUNCATE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = query_journal_mode(conn, "TRUNCATE") {
|
||||
tracing::warn!(
|
||||
target: "openhuman::subconscious::store",
|
||||
db = "subconscious.db",
|
||||
error = %format!("{e}"),
|
||||
"[subconscious::store] TRUNCATE journal fallback also failed; continuing with engine default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `PRAGMA journal_mode = <mode>` and return the resulting mode string.
|
||||
///
|
||||
/// `mode` is a fixed internal SQLite keyword (`WAL`, `TRUNCATE`, …), never user
|
||||
/// input. PRAGMA statements don't accept bound parameters, so inlining the
|
||||
/// keyword in the statement text is the only option and is safe here.
|
||||
fn query_journal_mode(conn: &Connection, mode: &str) -> rusqlite::Result<String> {
|
||||
conn.query_row(&format!("PRAGMA journal_mode = {mode}"), [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_sqlite_busy(err: &anyhow::Error) -> bool {
|
||||
if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) =
|
||||
err.downcast_ref::<rusqlite::Error>()
|
||||
@@ -104,9 +176,12 @@ fn is_sqlite_busy(err: &anyhow::Error) -> bool {
|
||||
msg.contains("database is locked") || msg.contains("database table is locked")
|
||||
}
|
||||
|
||||
// NOTE: `PRAGMA journal_mode` is intentionally *not* part of this batch — it is
|
||||
// applied separately via `apply_journal_mode` so a filesystem that can't back
|
||||
// WAL's `-shm` segment degrades to a rollback journal instead of aborting the
|
||||
// whole schema init (issue #3231). Keep journal-mode selection out of here.
|
||||
const SCHEMA_DDL: &str = "
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
-- Legacy tables retained for backward compatibility with existing DBs.
|
||||
-- No longer written to or read from.
|
||||
|
||||
@@ -34,3 +34,66 @@ fn schema_ddl_creates_tables() {
|
||||
.unwrap();
|
||||
assert!(count >= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_ddl_has_no_journal_mode_pragma() {
|
||||
// Journal-mode selection must stay out of the DDL batch so a filesystem
|
||||
// that can't back WAL's `-shm` segment degrades via `apply_journal_mode`
|
||||
// instead of aborting schema init (issue #3231 / TAURI-RUST-8WM).
|
||||
assert!(
|
||||
!SCHEMA_DDL.to_ascii_lowercase().contains("journal_mode"),
|
||||
"SCHEMA_DDL must not set journal_mode — it is applied separately with a WAL fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_and_initialize_creates_usable_db_on_real_fs() {
|
||||
// A real on-disk DB exercises the actual journal-mode path (in-memory DBs
|
||||
// can never be WAL). The DB must be usable for reads/writes afterward.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("subconscious.db");
|
||||
|
||||
let conn = open_and_initialize(&db_path).unwrap();
|
||||
set_last_tick_at(&conn, 99.5).unwrap();
|
||||
assert_eq!(get_last_tick_at(&conn).unwrap(), 99.5);
|
||||
|
||||
// On a normal local filesystem WAL succeeds; assert we landed on a valid
|
||||
// persistent journal mode (wal when supported, otherwise the truncate
|
||||
// fallback — never an unusable state).
|
||||
let mode: String = conn
|
||||
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(mode.to_ascii_lowercase().as_str(), "wal" | "truncate"),
|
||||
"expected wal or truncate journal mode, got {mode}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_connection_creates_parent_dir_and_db() {
|
||||
// `with_connection` must create the `subconscious/` subdir under a fresh
|
||||
// workspace and initialize a working DB end-to-end.
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let tick = with_connection(workspace.path(), |conn| {
|
||||
set_last_tick_at(conn, 7.0)?;
|
||||
get_last_tick_at(conn)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tick, 7.0);
|
||||
assert!(workspace
|
||||
.path()
|
||||
.join("subconscious")
|
||||
.join("subconscious.db")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_journal_mode_falls_back_without_panicking() {
|
||||
// In-memory DBs always report `memory` and can never switch to WAL, so this
|
||||
// drives the fallback branch of `apply_journal_mode`. It must not panic and
|
||||
// must leave the connection fully usable for the table DDL.
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
apply_journal_mode(&conn);
|
||||
conn.execute_batch(SCHEMA_DDL).unwrap();
|
||||
assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user