Skip to content
Closed
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
45 changes: 44 additions & 1 deletion hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1468,12 +1468,46 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:

@contextlib.contextmanager
def write_txn(conn: sqlite3.Connection):
"""Context manager for an IMMEDIATE write transaction.
"""Context manager for an IMMEDIATE write transaction with optional board-level lock.

Use for any multi-statement write (creating a task + link, claiming a
task + recording an event, etc.). A claim CAS inside this context is
atomic -- at most one concurrent writer can succeed.

On Unix, acquires an exclusive flock on ``<board_dir>/kanban.write.lock``
before BEGIN IMMEDIATE to serialize concurrent writers at the OS level,
reducing SQLITE_BUSY under heavy multi-process write load. Disabled on
Windows (fcntl unavailable) and for :memory: DBs. Falls through without
crash if the lock file cannot be created (e.g. read-only filesystem).
"""
import sys as _sys

lock_fd = None
if _sys.platform != "win32":
try:
row = conn.execute("PRAGMA database_list").fetchone()
db_file = row[2] if row and row[2] else None
except Exception:
db_file = None
if db_file and db_file != ":memory:":
board_dir = os.path.dirname(db_file)
lock_path = os.path.join(board_dir, "kanban.write.lock")
try:
import fcntl as _fcntl

lock_fd = open(lock_path, "w", encoding="utf-8")
_fcntl.flock(lock_fd, _fcntl.LOCK_EX)
except OSError:
_log.debug(
"write_txn: could not acquire board write lock at %s; proceeding",
lock_path,
)
if lock_fd is not None:
try:
lock_fd.close()
except OSError:
pass
lock_fd = None
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
Expand All @@ -1482,6 +1516,15 @@ def write_txn(conn: sqlite3.Connection):
raise
else:
conn.execute("COMMIT")
finally:
if lock_fd is not None:
try:
import fcntl as _fcntl

_fcntl.flock(lock_fd, _fcntl.LOCK_UN)
lock_fd.close()
except OSError:
pass


# ---------------------------------------------------------------------------
Expand Down
72 changes: 62 additions & 10 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import json
import logging
import os
import random
import re
import sqlite3
import sys
import threading
import time
from pathlib import Path
Expand Down Expand Up @@ -125,10 +127,22 @@ def format_session_db_unavailable(prefix: str = "Session database not available"
return f"{prefix}: {cause}{hint}."


def _wal_init_lock_path(db_path: str) -> "Optional[str]":
"""Return flock path for WAL init serialization, or None if not applicable."""
if db_path == ":memory:":
return None
if os.environ.get("HERMES_WAL_INIT_FLOCK_DISABLE"):
return None
if sys.platform == "win32":
return None
return db_path + ".wal-init.lock"


def apply_wal_with_fallback(
conn: sqlite3.Connection,
*,
db_label: str = "state.db",
db_path: "Optional[str]" = None,
) -> str:
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.

Expand All @@ -148,17 +162,55 @@ def apply_wal_with_fallback(
Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
both databases get identical fallback behavior.
"""
# Derive db_path from the connection's filename if not provided.
if db_path is None:
try:
row = conn.execute("PRAGMA database_list").fetchone()
if row and row[2]:
db_path = row[2]
except Exception:
pass

lock_path = _wal_init_lock_path(db_path) if db_path else None
lock_fd = None
if lock_path:
try:
import fcntl as _fcntl

lock_fd = open(lock_path, "w", encoding="utf-8")
_fcntl.flock(lock_fd, _fcntl.LOCK_EX)
except OSError:
logger.debug(
"apply_wal_with_fallback: could not acquire wal-init lock at %s; proceeding",
lock_path,
)
if lock_fd is not None:
try:
lock_fd.close()
except OSError:
pass
lock_fd = None
try:
conn.execute("PRAGMA journal_mode=WAL")
return "wal"
except sqlite3.OperationalError as exc:
msg = str(exc).lower()
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
# Unrelated OperationalError — don't silently swallow.
raise
_log_wal_fallback_once(db_label, exc)
conn.execute("PRAGMA journal_mode=DELETE")
return "delete"
try:
conn.execute("PRAGMA journal_mode=WAL")
return "wal"
except sqlite3.OperationalError as exc:
msg = str(exc).lower()
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
# Unrelated OperationalError — don't silently swallow.
raise
_log_wal_fallback_once(db_label, exc)
conn.execute("PRAGMA journal_mode=DELETE")
return "delete"
finally:
if lock_fd is not None:
try:
import fcntl as _fcntl

_fcntl.flock(lock_fd, _fcntl.LOCK_UN)
lock_fd.close()
except OSError:
pass


def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"dafeng@DafengdeMacBook-Pro.local": "WorldWriter",
"schepers.zander1@gmail.com": "Strontvod",
"anadi.jaggia@gmail.com": "Jaggia",
"steveonjava@gmail.com": "steveonjava",
"32201324+simpolism@users.noreply.github.com": "simpolism",
"simpolism@gmail.com": "simpolism",
"jake@nousresearch.com": "simpolism",
Expand Down
Loading