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:
parent
04edff7905
commit
c86d5ac4f6
2 changed files with 69 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue