Skip to content

Commit e83252d

Browse files
steveonjavakshitijk4poor
authored andcommitted
fix(kanban): preserve original exception when write_txn rollback fails
When code inside a write_txn block raises an OperationalError that SQLite has already auto-rolled-back (typical for disk I/O error, database is locked, and database disk image is malformed), the explicit ROLLBACK in write_txn.__exit__ itself raises cannot rollback - no transaction is active and the secondary exception replaces the original in the traceback. Operators see a misleading error and lose the diagnostic information they need. Swallow the rollback-time OperationalError so the caller always sees the original cause. Confirmed reproducer: tests/hermes_cli/test_kanban_db.py:: test_write_txn_preserves_original_exception_when_rollback_fails
1 parent 5c49cd0 commit e83252d

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

hermes_cli/kanban_db.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1481,12 +1481,22 @@ def write_txn(conn: sqlite3.Connection):
14811481
Use for any multi-statement write (creating a task + link, claiming a
14821482
task + recording an event, etc.). A claim CAS inside this context is
14831483
atomic -- at most one concurrent writer can succeed.
1484+
1485+
The explicit ROLLBACK on exception is wrapped in try/except so that
1486+
a SQLite auto-rollback (which leaves no active transaction) does not
1487+
shadow the original exception with a spurious rollback error.
14841488
"""
14851489
conn.execute("BEGIN IMMEDIATE")
14861490
try:
14871491
yield conn
14881492
except Exception:
1489-
conn.execute("ROLLBACK")
1493+
try:
1494+
conn.execute("ROLLBACK")
1495+
except sqlite3.OperationalError:
1496+
# SQLite has already auto-rolled-back the transaction (typical
1497+
# under EIO, lock contention, or corruption). Nothing to undo;
1498+
# do not let this secondary failure shadow the real one.
1499+
pass
14901500
raise
14911501
else:
14921502
conn.execute("COMMIT")

tests/hermes_cli/test_kanban_db.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3417,3 +3417,60 @@ def test_pragmas_not_accidentally_disabled_by_migrate_path(tmp_path):
34173417
assert conn.execute("PRAGMA cell_size_check").fetchone()[0] == 1
34183418
assert conn.execute("PRAGMA synchronous").fetchone()[0] == 2
34193419

3420+
# write_txn — rollback handler must not mask the original exception
3421+
# ---------------------------------------------------------------------------
3422+
3423+
3424+
def test_write_txn_preserves_original_exception_when_rollback_fails(kanban_home):
3425+
"""When a write inside write_txn raises an OperationalError that SQLite
3426+
has already auto-rolled-back (e.g. ``disk I/O error``,
3427+
``database is locked``, ``database disk image is malformed``), the
3428+
explicit ROLLBACK in ``write_txn.__exit__`` itself raises
3429+
``cannot rollback - no transaction is active``. The original cause
3430+
must NOT be masked by the secondary rollback failure — operators rely
3431+
on the original cause to diagnose the underlying issue.
3432+
"""
3433+
3434+
class FailingConnWrapper:
3435+
"""Delegate to a real connection, simulating an EIO during an INSERT
3436+
that SQLite has already auto-rolled-back."""
3437+
3438+
def __init__(self, real):
3439+
self._real = real
3440+
self._fail_armed = True
3441+
3442+
def execute(self, sql, *args, **kwargs):
3443+
if (
3444+
self._fail_armed
3445+
and sql.lstrip().upper().startswith("INSERT")
3446+
and "task_events" in sql.lower()
3447+
):
3448+
self._fail_armed = False # one-shot
3449+
# Simulate SQLite auto-rolling back the transaction by
3450+
# issuing a real ROLLBACK now. After this, BEGIN IMMEDIATE
3451+
# is no longer active and an explicit ROLLBACK would error.
3452+
try:
3453+
self._real.execute("ROLLBACK")
3454+
except sqlite3.OperationalError:
3455+
pass
3456+
raise sqlite3.OperationalError("disk I/O error")
3457+
return self._real.execute(sql, *args, **kwargs)
3458+
3459+
def __getattr__(self, name):
3460+
return getattr(self._real, name)
3461+
3462+
with kb.connect() as conn:
3463+
wrapper = FailingConnWrapper(conn)
3464+
with pytest.raises(sqlite3.OperationalError) as excinfo:
3465+
with kb.write_txn(wrapper):
3466+
kb._append_event(wrapper, "t_bogus", "promoted", None)
3467+
3468+
msg = str(excinfo.value)
3469+
assert "disk I/O error" in msg, (
3470+
f"write_txn masked the original exception with rollback failure; "
3471+
f"got {msg!r} (expected to contain 'disk I/O error')"
3472+
)
3473+
assert "cannot rollback" not in msg, (
3474+
f"write_txn surfaced the rollback failure instead of the original "
3475+
f"OperationalError; got {msg!r}"
3476+
)

0 commit comments

Comments
 (0)