fix(kanban): add post-commit page_count invariant check to write_txn

Reads header bytes 28-31 after every COMMIT and compares against actual file size. Raises sqlite3.DatabaseError on torn-extend (actual_pages < page_count). Also sets PRAGMA wal_autocheckpoint=100 in connect().

Refs: #31208 (Bug E - same file, coordinate), #30973 (wal_autocheckpoint)
Refs: #30445, #30896, #30908 (corruption reports)
This commit is contained in:
steveonjava 2026-05-24 21:46:50 -07:00 committed by kshitij
parent c002668ff0
commit 99c19eb2fe
2 changed files with 147 additions and 0 deletions

View file

@ -1212,6 +1212,7 @@ def connect(
# FULL (was NORMAL): fsync before each checkpoint to narrow the
# crash window that can leave a b-tree page header torn.
conn.execute("PRAGMA synchronous=FULL")
conn.execute("PRAGMA wal_autocheckpoint=100")
conn.execute("PRAGMA foreign_keys=ON")
# Zero freed pages so a later torn write cannot expose stale
# cell content; persisted in the DB header for new DBs.
@ -1502,6 +1503,45 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
)
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
"""Read the SQLite header page_count and compare against actual file size.
Raises sqlite3.DatabaseError if the file is shorter than the header claims
(torn-extend corruption).
"""
try:
row = conn.execute("PRAGMA database_list").fetchone()
if row is None:
return
path_str = row[2] # column 2 is the file path; empty for in-memory DBs
if not path_str:
return # in-memory or unnamed DB; skip
path = path_str
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
file_size = os.path.getsize(path)
with open(path, "rb") as f:
f.seek(28)
header_bytes = f.read(4)
if len(header_bytes) < 4:
return # can't read header; skip
header_page_count = int.from_bytes(header_bytes, "big")
if header_page_count == 0:
return # new/empty DB; skip
actual_pages = file_size // page_size
if actual_pages < header_page_count:
raise sqlite3.DatabaseError(
f"torn-extend detected: page count mismatch on {path}: "
f"header claims {header_page_count} pages, "
f"file has {actual_pages} pages "
f"(missing {header_page_count - actual_pages} pages, "
f"file_size={file_size}, page_size={page_size})"
)
except sqlite3.DatabaseError:
raise
except Exception:
pass # I/O errors during check are non-fatal; let normal ops continue
@contextlib.contextmanager
def write_txn(conn: sqlite3.Connection):
"""Context manager for an IMMEDIATE write transaction.
@ -1528,6 +1568,9 @@ def write_txn(conn: sqlite3.Connection):
raise
else:
conn.execute("COMMIT")
# Post-commit file-length check: header page_count must match actual file pages.
# A discrepancy means a torn-extend — raise now rather than silently corrupt.
_check_file_length_invariant(conn)
# ---------------------------------------------------------------------------