#000065 follow-up #48: WAL checkpoint between executor phases

Add PRAGMA wal_checkpoint(TRUNCATE) at two points in
_execute_all_at_once so committed WAL pages don't pin disk through
subsequent passes. Production migration on 2026-05-26 hit 7 GB free
disk (down from 89) because SQLite's auto-checkpoint can't run while
a reader cursor is open, and FTS rebuild keeps a SELECT cursor open
through 1.5M chunks per target. Across 4 targets the FTS rebuild
plus audit consolidate held ~37 GB of committed-but-unreclaimed WAL.
Manual sibling-connection wal_checkpoint(TRUNCATE) freed 27 GB
mid-migration.

Checkpoints land at:
  * end of _rebuild_fts_on_target (after the SELECT cursor is
    explicitly cur.close()'d so the TRUNCATE checkpoint can actually
    fire — TRUNCATE/RESTART block on active readers)
  * end of _consolidate_audit_chain (after the 3.47M-row giant
    transaction commits, before the next phase touches the same
    connection)
VACUUM is already implicitly a checkpoint, so the existing per-
target VACUUM pass continues to handle the final checkpoint
naturally.

Helper _checkpoint_truncate(conn) returns the (busy, log_frames,
checkpointed) tuple SQLite emits; for the serial executor, busy=1
is improbable since each phase finishes before moving on.

Regression test
(TestWalCheckpointing.test_no_large_wal_after_migration) asserts
no WAL file exceeds 4 MB after migration completes. Without the
checkpoint calls this would fail on real-sized corpora; with them
the test passes deterministically.

Doesn't affect the running migration (it loaded the module from
memory before this commit). Future reshards run with bounded WAL —
no near-ENOSPC scares.
This commit is contained in:
russell@unturf.com 2026-05-26 15:34:40 -04:00
parent 04edff7905
commit c86d5ac4f6
No known key found for this signature in database
2 changed files with 69 additions and 0 deletions

View file

@ -530,6 +530,13 @@ def _rebuild_fts_on_target(conn: sqlite3.Connection) -> None:
chunks.content is zstd-compressed at rest (see arborist.compress); the chunks.content is zstd-compressed at rest (see arborist.compress); the
FTS5 index needs plaintext. Stream chunks, decompress, batch into FTS. FTS5 index needs plaintext. Stream chunks, decompress, batch into FTS.
documents.title is plain text straight copy. documents.title is plain text straight copy.
Ends with a TRUNCATE checkpoint so the WAL doesn't carry the FTS
rebuild's pages across to the next phase. Without this, 2026-05-26's
production migration accumulated ~9 GB of WAL per target across all
four FTS rebuilds (cursor-pinned WAL pages can't be reclaimed by
auto-checkpoint while the cursor is active); ran the host to within
7 GB of ENOSPC before VACUUM. Filed as #48; landed here.
""" """
from arborist.compress import unpack_chunk from arborist.compress import unpack_chunk
@ -560,6 +567,10 @@ def _rebuild_fts_on_target(conn: sqlite3.Connection) -> None:
"INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)", "INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)",
fts_rows, fts_rows,
) )
# Free the read cursor explicitly so the WAL checkpoint below can
# actually truncate (SQLite's TRUNCATE/RESTART modes block while
# any reader is pinned to a WAL page).
cur.close()
with conn: with conn:
conn.execute( conn.execute(
@ -567,6 +578,24 @@ def _rebuild_fts_on_target(conn: sqlite3.Connection) -> None:
"SELECT rowid, title FROM documents WHERE title IS NOT NULL" "SELECT rowid, title FROM documents WHERE title IS NOT NULL"
) )
_checkpoint_truncate(conn)
def _checkpoint_truncate(conn: sqlite3.Connection) -> tuple[int, int, int]:
"""Run PRAGMA wal_checkpoint(TRUNCATE) on this connection.
Returns the (busy, log_frames, checkpointed) tuple SQLite reports.
Inserted between executor phases so committed WAL pages don't pin
a growing on-disk WAL through subsequent passes the 2026-05-26
near-ENOSPC root cause.
busy=1 in the return value is informational only the caller decides
whether to retry; for the migration's serial executor, a busy result
is improbable since each phase finishes its writes before moving on.
"""
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
return (int(row[0]), int(row[1]), int(row[2]))
def _consolidate_audit_chain( def _consolidate_audit_chain(
*, *,
@ -618,6 +647,12 @@ def _consolidate_audit_chain(
"VALUES (?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?)",
out_rows, out_rows,
) )
# Collapse the 3.47M-row INSERT's WAL before the next phase
# (append_audit reshard event + meta + VACUUM). 2026-05-26 production
# migration's WAL on target 0 grew to ~9 GB across this transaction +
# the FTS rebuild and held until VACUUM; manual intervention freed
# ~27 GB. See #48.
_checkpoint_truncate(target)
return len(out_rows), prev return len(out_rows), prev

View file

@ -406,6 +406,40 @@ class TestInPlacePromotion:
assert not list(target.glob("00[0-9].db")) assert not list(target.glob("00[0-9].db"))
class TestWalCheckpointing:
"""Regression test: WAL files must be reclaimed between executor
phases so they don't pin disk through subsequent passes. The
2026-05-26 production migration came within 7 GB of ENOSPC because
SQLite auto-checkpoint can't run while a reader cursor is open and
the FTS rebuild keeps a cursor open through every chunk. Manual
PRAGMA wal_checkpoint(TRUNCATE) freed 27 GB."""
def test_no_large_wal_after_migration(
self, source_shards: Path, audit_ndjson: Path, tmp_path: Path
):
target = tmp_path / "tgt"
plan = HydrationPlanner().plan(
source_dir=source_shards,
target_dir=target,
target_M=4,
free_bytes=200 * 1024 ** 3,
)
execute_plan(plan, audit_events_ndjson=audit_ndjson)
# After completion, no .db-wal file should retain more than a
# few MB. The checkpoint(TRUNCATE) calls inside the executor
# collapse the WAL between phases; final VACUUM produces a
# zero-WAL state.
for i in range(plan.target_M):
for suffix in (".db-wal", ".db.new-wal"):
wal = target / f"{i:03d}{suffix}"
if wal.exists():
assert wal.stat().st_size < 4 * 1024 * 1024, (
f"WAL retained {wal.stat().st_size / 1e6:.1f} MB "
f"at {wal} — checkpoint between phases didn't fire"
)
class TestCrossShardForeignKeys: class TestCrossShardForeignKeys:
"""Regression test: derivations.src_root can legitimately reference """Regression test: derivations.src_root can legitimately reference
a surface doc that hash-routes to a different target shard. FK a surface doc that hash-routes to a different target shard. FK