Skip to content

ADFA-5141: Pin doc DB page_size to 2048 in vacuum_database - #25

Closed
davidschachterADFA wants to merge 8 commits into
mainfrom
ADFA-5141
Closed

ADFA-5141: Pin doc DB page_size to 2048 in vacuum_database#25
davidschachterADFA wants to merge 8 commits into
mainfrom
ADFA-5141

Conversation

@davidschachterADFA

Copy link
Copy Markdown
Collaborator

Summary

  • SQLite page_size=1024 (the current docdb setting) carries ~2x the per-page header overhead of 2048 on the real ~300MB docdb, with no size tradeoff versus other sizes tested (see ADFA-5124 comments' page-size sweep — 2048 was smallest of the sizes tried).
  • vacuum_database() already rewrites the whole DB file on every delete-bearing mutation (project DB-hygiene policy). Setting PRAGMA page_size=2048 immediately before that VACUUM migrates the file to the new page size for free on the next regeneration, rather than requiring a one-off migration script.
  • Renamed the existing PAGE_SIZE UI-pagination constant to UI_PAGE_SIZE to avoid confusion with the new SQLITE_PAGE_SIZE_BYTES constant.

Test plan

  • pytest tests/ — 178/178 pass, including new test_vacuum_database_sets_target_page_size asserting the pragma sticks after VACUUM.

Jira: ADFA-5141

🤖 Generated with Claude Code

The 1024-byte page_size (SQLite's old default) carries ~2x the
per-page header overhead of 2048 on the real ~300MB docdb, with no
tradeoff versus larger sizes tested. vacuum_database() already
rewrites the whole file on every delete-bearing mutation, so setting
PRAGMA page_size before that VACUUM migrates the file for free on the
next regeneration rather than as a one-off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Trigger the migration on pure-insert paths too (import_content_files,
  import_csv_rows), not just delete/overwrite-gated ones, via a new
  _page_size_migration_pending check.
- Work around WAL journal mode silently preventing PRAGMA page_size from
  taking effect on VACUUM.
- Pin page_size on the release pipeline's DocumentationDatabase at
  creation time, so the actually-shipped DB gets the fix for free
  instead of only a maintainer's locally vacuumed copy.
- Raise fetch_content_for_path's busy timeout so a concurrent VACUUM
  can't surface real content as a false 404.
- Correct vacuum_database's docstring (VACUUM is never a no-op) and
  align its connection handling with the repo's with-block convention.
- Update README's "no migrations" claim and add test coverage for the
  real 1024->2048 migration, WAL mode, and both pure-insert paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Addressed a /code-review max pass on this PR (9 findings). Summary of fixes, most significant first:

  1. Release pipeline never got the migrationDocumentationDatabase.__init__ now pins PRAGMA page_size=2048 before creating tables on a fresh DB, so the actual shipped documentation.db artifact gets this for free, not just a maintainer's locally-vacuumed copy.
  2. Pure-insert imports skipped the migrationimport_content_files/import_csv_rows were gated on delete/overwrite only. Added _page_size_migration_pending() (cheap PRAGMA page_size read) so bulk-loading fresh content still triggers the one-time migration.
  3. WAL journal mode silently no-ops the migrationvacuum_database now temporarily switches to DELETE journal mode for the rewrite (page_size PRAGMA has no effect on VACUUM under WAL) and restores the original mode after.
  4. False 404s during VACUUMfetch_content_for_path now uses a 30s busy timeout instead of the 5s default, since the migrated DB's VACUUM lock can outlast that on the ~380MB file.
  5. Docstring/README fixes — corrected the false "VACUUM is a no-op at target size" claim, and reconciled README's "no migrations" line with this page_size pin (a storage-format change, not a schema change).
  6. Conventionvacuum_database's connection now uses the repo's with sqlite3.connect(...) as conn: pattern.
  7. Added tests for the real 1024→2048 migration path, WAL mode, and both previously-uncovered pure-insert migration paths.

The PAGE_SIZEUI_PAGE_SIZE rename was verified as a pure mechanical, value-preserving rename by diff inspection (no behavior change possible) — I wasn't able to launch the Flet UI from this environment to manually verify per this repo's CLAUDE.md guidance, so that verification is diff-based rather than a live UI check.

All 181 docdb-studio tests and 7 DocumentationDatabase tests pass.

davidschachterADFA and others added 2 commits August 17, 2026 13:48
- vacuum_database: wrap the journal_mode switch/VACUUM/restore in
  try/finally so a lock error mid-VACUUM can't leave the DB stuck in
  DELETE journal mode forever; also dedupe the was-WAL check.
- _page_size_migration_pending: match fetch_content_for_path's 30s busy
  timeout, and cache confirmed-migrated paths so it stops reopening a
  connection on every import call once the one-time migration is done.
- Add a cross-file test asserting scripts/DocumentationDatabase.py and
  docdb-studio/docdb_studio.py agree on SQLITE_PAGE_SIZE_BYTES, since
  the two tools are separately deployed with no shared import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The in-place VACUUM + journal_mode round-trip required exclusive access
to db_path: SQLite refuses to switch a WAL-mode database away from WAL
while ANY other connection has it open. Empirically reproduced this as
a deterministic deadlock in import_csv_rows/import_content_files et al,
and confirmed it goes deeper than "close the caller's own connection" --
an already-returned function's unclosed `with sqlite3.connect(...) as
conn:` block can keep the file locked well past its own return, and
that convention is used 30+ times in this file. A live UI connection
left open elsewhere (e.g. a data browser) would hit the same deadlock.

vacuum_database now rewrites into a temp file via VACUUM INTO, which
only needs a read snapshot of the source, and atomically swaps it into
place with os.replace -- this works regardless of what else has
db_path open. journal_mode=WAL is reapplied to the new file's final
path (VACUUM INTO always produces a plain rollback-journal file), and
stale sidecars from the replaced file are cleaned up.

Also, from the second review pass:
- vacuum_database's connect uses the 30s timeout matching the other
  ADFA-5141 call sites.
- delete_tooltips_bulk now also triggers on _page_size_migration_pending,
  not just `deleted` -- it had the same gap the import paths were
  already fixed for.
- _page_size_confirmed is now lock-guarded (imports can run migration
  checks from worker threads).
- Callers close their own connections before any vacuum-triggering call,
  matching the (no longer strictly required, but still good practice)
  discipline established while chasing the deadlock down.

New tests cover: the WAL deadlock is gone even with an unrelated open
connection AND an unclosed caller-style connection present at once, the
original file is left untouched if VACUUM INTO fails partway (temp file
cleaned up, no partial swap), and the migration-pending gate on each
previously-gapped call path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexmmiller

Copy link
Copy Markdown
Collaborator

Was able to run the tests in this PR and verify that the page size got updated. However Claude pointed out that mkstemp() creates all files with 0600 permissions so 0600 would overwrite whatever the permissions were beforehand:

QA result: PR #25 works, but with one bug found

Migration confirmed working:

~/documentation.db page_size: 1024 → 2048 ✅
PRAGMA quick_check: ok
Row-level sanity check: Tooltips count identical before/after (46,105 rows) — no data loss
File shrank slightly (313,483,264 → 313,409,536 bytes), consistent with the PR's claimed reduced per-page overhead
Triggered via the real app code path: delete_tooltips_bulk(db_path, [999999999]) — a delete call that matches nothing, exactly like the test_delete_tooltips_bulk_no_op_still_migrates_page_size test exercises

Bug found during QA: vacuum_database() silently drops the database file's permissions from 644 (owner+group+other readable) to 600 (owner-only) every time it runs. It rewrites via tempfile.mkstemp() (which always creates files 0600) and then os.replace()s that temp file into place, never restoring the original file's mode. On your machine this hit — ~/documentation.db went from -rw-r--r-- to -rw------- immediately after the migration ran. I've restored it to 644 by hand, but the underlying code will re-break it on every future vacuum. This isn't covered by the existing test suite (no test checks file mode after vacuum_database).

davidschachterADFA added a commit that referenced this pull request Aug 17, 2026
vacuum_and_pin_page_size (commit b203500) mirrored docdb-studio.py's
original vacuum_database(): in-place VACUUM + a journal_mode round-trip,
which requires exclusive access to db_path. SQLite refuses to switch a
WAL-mode database away from WAL while ANY other connection has it open
-- even one from a function that has already returned, since Python's
`with sqlite3.connect(...) as conn:` does not close conn on exit.
Empirically reproduced and fixed the identical bug in docdb-studio.py's
vacuum_database (PR #25); this mirrors that fix here since this
pipeline's own VACUUM is the one actually run against the live
documentation.db.

Rewritten on VACUUM INTO: rebuild into a temp file next to db_path
(read-only snapshot of the source, no exclusive access needed), then
atomically swap it into place with os.replace. journal_mode=WAL is
reapplied to the new file's final path (VACUUM INTO always produces a
plain rollback-journal file), and stale sidecars from the replaced file
are cleaned up.

Two new tests: the fix succeeds with both an unrelated open connection
and an unclosed caller-style connection present at once (the actual
scenario the old design was fragile against), and the original file is
left untouched if VACUUM INTO fails partway (temp file cleaned up, no
partial swap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Summary of the last two review rounds

Round 2 findings (all fixed, commit 1018a57):

  • Missing try/finally around the journal_mode switch in vacuum_database — a lock error mid-VACUUM could leave the DB stuck in DELETE journal mode.
  • _page_size_migration_pending's busy timeout was inconsistent with fetch_content_for_path's.
  • scripts/DocumentationDatabase.py's SQLITE_PAGE_SIZE_BYTES was hand-duplicated with no shared source of truth — added a cross-file consistency test.
  • Minor efficiency/style nits.

Round 3 (commit 7bed792) — a deeper problem found while testing round 2's fix:

While writing a regression test for "the caller's own connection must be closed before vacuum_database runs," I found the fix needed to go further than that. vacuum_database's in-place VACUUM + journal_mode round-trip requires exclusive access to the db file — SQLite refuses to switch a WAL-mode database away from WAL while any other connection has it open. That includes connections from functions that have already returned: with sqlite3.connect(...) as conn: does not close conn on exit, and empirically an unclosed connection can keep the file locked well past its enclosing function's return (confirmed via weakref instrumentation — this isn't simple refcounting, closing needs to be explicit). That pattern is used 30+ times in this file, so a live UI connection (e.g. a data browser left open) held open elsewhere while an import runs would hit the same deadlock.

Rewrote vacuum_database to use VACUUM INTO (write into a temp file next to db_path) + atomic os.replace, since VACUUM INTO only needs a read snapshot of the source — no exclusive access required, regardless of what else has the file open. Added a test that proves this: vacuum_database now succeeds with both an unrelated open connection and an unclosed caller-style connection present simultaneously — the exact scenario the old design was fragile against.

Also applied the identical fix to populate_db.py's vacuum_and_pin_page_size (PR #26), which mirrored the old buggy design and had the same latent issue.

190/190 tests passing on this branch.

davidschachterADFA and others added 3 commits August 17, 2026 16:40
tempfile.mkstemp() always creates its file mode 0600 regardless of the
original's mode or the process umask. The VACUUM INTO rewrite swaps
that temp file into db_path's place via os.replace, which never
restored the original permissions -- alexmmiller's QA on real hardware
caught this silently dropping documentation.db from 644 to 600 on
every vacuum.

Capture db_path's mode before the rewrite and os.chmod it back after
the swap. New test confirms a 644 file stays 644 across vacuum_database
(and fails against the pre-fix code, dropping to 600).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rewrite

- Escape embedded single quotes in the VACUUM INTO target path. It's a
  SQL string literal, not a bindable parameter, so a db_path whose
  parent directory contains a quote (a real user directory name like
  "David's Docs") broke the statement outright -- confirmed empirically.
- Serialize vacuum_database via a module-level lock. VACUUM INTO takes
  a read snapshot; without this, two concurrent invocations on the same
  db_path could race, with the second's os.replace silently discarding
  a write the first's snapshot missed. This only serializes
  vacuum_database against itself -- see its docstring for the residual
  gap against a fully independent concurrent writer.
- Close the journal_mode-read connection explicitly instead of relying
  on it being reassigned by the next `with` block, matching every other
  connection touched by this fix.
- insert_tooltip/update_tooltip/add_tooltip_button/update_tooltip_button
  never called vacuum_database at all (no delete, so the DB-hygiene gate
  never fired), so a docdb edited only through those single-row paths
  never migrated to SQLITE_PAGE_SIZE_BYTES. Added the same
  _page_size_migration_pending gate used elsewhere -- cheap once
  cached, so this only costs a real VACUUM the one time it's needed.

New tests: quote-in-path handling, concurrent vacuum_database calls
don't raise or corrupt the file, and each of the four previously-gapped
single-row mutation functions now migrates page_size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cleaner than manually escaping embedded single quotes: VACUUM INTO's
target accepts a bound parameter (already used by populate_db.py's
backup_database for the same reason), so this sidesteps SQL
string-literal escaping entirely rather than hand-rolling it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Thanks for catching this @alexmmiller — confirmed and fixed in 8022ab5: captured db_path's original mode before the rewrite and os.chmod it back after the os.replace swap. New test (test_vacuum_database_preserves_file_permissions) asserts a 644 file stays 644 across vacuum_database, and fails against the pre-fix code (drops to 600). Applied the same fix to populate_db.py's vacuum_and_pin_page_size on PR #26, which has the identical mkstemp+os.replace pattern.

A follow-up self-review turned up a few more real issues in the same rewrite, all fixed and pushed:

  • VACUUM INTO's target was built via an f-string (VACUUM INTO '{tmp_path}'), so a single quote anywhere in db_path's parent directory (e.g. a real user directory named David's Docs) broke the statement outright. Switched to a bound parameter (VACUUM INTO ?) — same pattern populate_db.py's existing backup_database already uses — which sidesteps escaping entirely.
  • vacuum_database had no mutual exclusion: two concurrent invocations on the same db_path could race, with the second's os.replace silently discarding a write the first's VACUUM INTO snapshot missed. Added a module-level lock serializing vacuum_database against itself (documented residual gap: this doesn't protect against a fully independent concurrent writer that never reaches vacuum_database at all — closing that would mean every mutator in the file taking the same lock around its own write+commit, which depends on this app's UI threading model rather than anything local to this function).
  • insert_tooltip/update_tooltip/add_tooltip_button/update_tooltip_button never called vacuum_database at all (no delete, so the DB-hygiene gate never fired), so a docdb edited only through those single-row paths never migrated to SQLITE_PAGE_SIZE_BYTES. Added the same _page_size_migration_pending gate used elsewhere.
  • Minor: an unclosed connection in vacuum_database itself, inconsistent with every other connection this fix touches.

197/197 tests passing.

- vacuum_database: chmod the temp file to the original permissions
  before os.replace, not after -- fixing it up afterward left a real
  (if brief) window where db_path was visible at mkstemp's 0600, and
  left permissions permanently wrong if the chmod itself failed.
- vacuum_database: explicitly close the VACUUM INTO and WAL-reapply
  connections, and give the WAL-reapply connection the same 30s
  timeout as its siblings in the same function -- empirically
  reproduced a ResourceWarning: unclosed database for the two that
  were missed, and the untimed connection could abort the function
  after the rewrite/permissions/sidecar-cleanup already succeeded,
  leaving _page_size_confirmed never updated.
- delete_tooltips_bulk: the empty-tooltip_ids early return skipped the
  migration-pending check entirely, unlike its non-empty "nothing
  matched" case which was already covered.
- get_category_name/update_last_change: same 30s timeout as
  fetch_content_for_path, for the same reason -- they run right after
  replace_tooltip_buttons's unconditional vacuum in the tooltip-save
  flow.
- Introduced _vacuum_if_dirty_or_pending() as a single choke point for
  the "vacuum after a mutation, or to migrate page_size on a pure
  insert" gate, replacing 6 near-identical copies of it (the 7th,
  import_content_files, keeps its own inline version since it wraps
  the gate with progress-callback reporting). A future new mutating
  helper can no longer add itself without also getting the migration
  check for free.

New tests: the empty-list migration gap, plus verified (and reverted
to confirm) that each fix here catches a real regression against the
pre-fix code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
davidschachterADFA added a commit that referenced this pull request Aug 18, 2026
Same findings as the mirrored docdb-studio.py fix's third self-review:
- chmod the temp file to the original permissions before os.replace,
  not after -- fixing it up afterward left a real window where db_path
  was visible at mkstemp's 0600, and left permissions permanently
  wrong if the chmod itself failed.
- Explicitly close the VACUUM INTO and WAL-reapply connections, and
  give the WAL-reapply connection the same 30s timeout as its siblings
  in the same function.

19/19 local tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Third self-review pass fixed: chmod-before-replace ordering (a failed chmod previously left permissions permanently wrong), two unclosed connections in vacuum_database (empirically confirmed via ResourceWarning), a missing 30s timeout on the WAL-reapply connection, delete_tooltips_bulk's empty-list early-return skipping the migration check, timeout consistency on get_category_name/update_last_change, and consolidated 6 near-identical migration-gate call sites into one helper. 198/198 tests passing.

@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Declining this per ADFA-5141: benchmarking showed essentially the same performance for page_size 1024 vs 2048, and a negligible size difference before compression (likely even less after ADFA-5153's dictionary compression on the Content table). Adds complexity without benefit — not pursuing this further. See ADFA-5141 for the full analysis and benchmark data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants