mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(state): detect silent WAL→DELETE fallback on macOS NFS / SMB (no false "wal")
apply_wal_with_fallback() only detected WAL-incompatible filesystems via a
RAISED OperationalError matched against _WAL_INCOMPAT_MARKERS. But macOS NFS,
SMB/CIFS, and overlay filesystems (e.g. AgentFS's NFS-backed mount) refuse the
WAL switch WITHOUT raising: `PRAGMA journal_mode=WAL` returns the still-effective
mode ('delete') and no exception. The code then ran `return "wal"`
unconditionally, so it:
1. returned a false "wal" while the DB was actually in DELETE mode, and
2. never called _log_wal_fallback_once, so the operator got ZERO signal that
concurrency had silently degraded (reader-blocks-writer).
state.db and kanban.db share this path, so a session/kanban board DB on a
network or overlay filesystem ran in DELETE with no diagnostic.
Fix: read the row `PRAGMA journal_mode=WAL` returns and verify it is actually
'wal' instead of assuming success; on a silent no-op, emit the existing
fallback WARNING and return the true mode. The raise-based path is unchanged.
Reproduced on a real AgentFS NFS overlay (PRAGMA journal_mode=WAL returned
('delete',) with no OperationalError). Adds a regression test for the
silent-no-op shape; the 16 existing WAL-fallback tests are unchanged.
This commit is contained in:
parent
7dbf6c2589
commit
f50d80e8eb
2 changed files with 70 additions and 4 deletions
|
|
@ -616,10 +616,28 @@ def apply_wal_with_fallback(
|
|||
return actual
|
||||
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
_apply_macos_checkpoint_barrier(conn)
|
||||
_enforce_macos_synchronous_full(conn)
|
||||
return "wal"
|
||||
# ``PRAGMA journal_mode=WAL`` is a query-that-sets: it RETURNS the
|
||||
# resulting journal mode. Network filesystems that refuse WAL by
|
||||
# *raising* SQLITE_PROTOCOL ("locking protocol") are handled in the
|
||||
# except branch below. But macOS NFS — and SMB/CIFS, and the AgentFS
|
||||
# NFS overlay — refuse the switch WITHOUT raising: the pragma simply
|
||||
# returns the still-effective mode (e.g. ``delete``). Trust the
|
||||
# returned row, not the mere absence of an exception; otherwise we
|
||||
# report a false ``"wal"`` AND skip the fallback WARNING, leaving the
|
||||
# DB silently in DELETE (reader-blocks-writer) with no signal.
|
||||
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
mode = str(row[0]).strip().lower() if row and row[0] is not None else ""
|
||||
if mode == "wal":
|
||||
_apply_macos_checkpoint_barrier(conn)
|
||||
_enforce_macos_synchronous_full(conn)
|
||||
return "wal"
|
||||
_log_wal_fallback_once(
|
||||
db_label,
|
||||
sqlite3.OperationalError(
|
||||
f"journal_mode=WAL refused without raising (still {mode!r})"
|
||||
),
|
||||
)
|
||||
return mode or "delete"
|
||||
except sqlite3.OperationalError as exc:
|
||||
msg = str(exc).lower()
|
||||
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
|
||||
|
|
|
|||
|
|
@ -55,6 +55,27 @@ def _open_blocking(path, reason="locking protocol", **kwargs):
|
|||
return sqlite3.connect(str(path), factory=factory, **kwargs), attempts
|
||||
|
||||
|
||||
def _make_silent_noop_factory(returned_mode: str = "delete"):
|
||||
"""Return a ``sqlite3.Connection`` subclass whose ``PRAGMA journal_mode=WAL``
|
||||
silently NO-OPs: it returns the still-effective journal mode (e.g.
|
||||
``delete``) WITHOUT raising — the way macOS NFS / SMB / the AgentFS NFS
|
||||
overlay behave. NFS that *raises* SQLITE_PROTOCOL is covered by
|
||||
:func:`_make_blocking_factory`; this is the other, quieter failure shape.
|
||||
"""
|
||||
|
||||
class _WalSilentNoOpConnection(sqlite3.Connection):
|
||||
def execute(self, sql, *args, **kwargs): # type: ignore[override]
|
||||
if "journal_mode=wal" in sql.lower().replace(" ", ""):
|
||||
# Refuse the WAL switch but DON'T raise; report the mode that is
|
||||
# actually still in force, exactly as the NFS client does.
|
||||
return super().execute(
|
||||
f"PRAGMA journal_mode={returned_mode}", *args, **kwargs
|
||||
)
|
||||
return super().execute(sql, *args, **kwargs)
|
||||
|
||||
return _WalSilentNoOpConnection
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_last_init_error():
|
||||
"""Reset the module-global last-error before and after each test."""
|
||||
|
|
@ -94,6 +115,33 @@ class TestApplyWalWithFallback:
|
|||
|
||||
|
||||
|
||||
def test_falls_back_when_wal_silently_refused(self, tmp_path, caplog):
|
||||
"""macOS NFS / SMB / AgentFS-NFS can REFUSE the WAL switch WITHOUT
|
||||
raising: ``PRAGMA journal_mode=WAL`` returns the still-effective mode
|
||||
('delete') and no exception. The marker-exception path never fires, so
|
||||
the function must detect the silent no-op from the PRAGMA's RETURN
|
||||
value — otherwise it returns a false 'wal' and skips the WARNING.
|
||||
|
||||
Reproduced on a real AgentFS NFS overlay, where
|
||||
``PRAGMA journal_mode=WAL`` returned ('delete',) with no
|
||||
OperationalError.
|
||||
"""
|
||||
factory = _make_silent_noop_factory("delete")
|
||||
conn = sqlite3.connect(
|
||||
str(tmp_path / "macnfs.db"), factory=factory, isolation_level=None
|
||||
)
|
||||
with caplog.at_level("WARNING", logger="hermes_state"):
|
||||
mode = apply_wal_with_fallback(conn, db_label="kanban.db")
|
||||
|
||||
assert mode == "delete", "must report the true mode, not a false 'wal'"
|
||||
assert (
|
||||
conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "delete"
|
||||
)
|
||||
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert len(warnings) == 1, "silent no-op must still emit exactly one WARNING"
|
||||
assert "kanban.db" in warnings[0].getMessage()
|
||||
conn.close()
|
||||
|
||||
def test_reraises_on_disk_io_error(self, tmp_path):
|
||||
"""Transient EIO from ``PRAGMA journal_mode=WAL`` must NOT silently
|
||||
downgrade to DELETE.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue