Skip to content

ADFA-5153: Compress Content table with a trained Brotli dictionary - #26

Open
davidschachterADFA wants to merge 12 commits into
fix/ADFA-4737from
ADFA-5153-content-brotli-dictionary
Open

ADFA-5153: Compress Content table with a trained Brotli dictionary#26
davidschachterADFA wants to merge 12 commits into
fix/ADFA-4737from
ADFA-5153-content-brotli-dictionary

Conversation

@davidschachterADFA

@davidschachterADFA davidschachterADFA commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Trains a shared zstd fast-cover Brotli dictionary (256 KiB) and compresses Content rows against it via the brotli CLI's -D flag (the installed Python brotli package has no dictionary API). Stored in a new single-row CompressionDictionary table inside documentation.db itself, so it always ships in sync with the content compressed against it.
  • populate_db.py / insert_optimized_media.py: the Kotlin-website ingestion pipeline now compresses/decompresses through the shared dictionary, never retraining an existing one (a dictionary-compressed row is only decodable against the exact dictionary it was compressed with — verified empirically to fail, or silently produce different bytes, on any mismatch).
  • New migrate_content_to_dictionary_brotli.py: one-time, idempotent, whole-database migration for every Content row the above pipeline doesn't own (reference docs, tooltip-linked pages, etc.). Idempotent because a plain decode of already-migrated content reliably fails (verified over 200 trials).
  • docdb-studio/docdb_studio.py: fixes a real correctness gap this surfaced — its Content reads (get_html_anchors_for_path, fetch_content_for_path) and writes (compress_for_storage) were still plain Brotli, which was already broken against the migrated database (anchor validation / content preview were silently erroring on every real page). Now dictionary-aware, same approach as populate_db.py, with a graceful fallback for a database with no dictionary yet.
  • 29 new tests total across the three changed areas. Ran the full migration against the real 299MB documentation.db: 29,748/29,751 brotli rows migrated, compressed bytes 131.1MB → 85.6MB (34.7% smaller), file overall 299.0MB → 255.3MB. Spot-checked real pages post-migration (both via the migration script and via docdb-studio) — all decode correctly.

Companion PR: appdevforall/CodeOnTheGo#1677 (WebServer.kt read-side).

Note: scripts/DocumentationDatabase.py looks obsolete against the current schema (its own schema-conformance check would reject this database outright — no templateId/Templates awareness) — flagged for a follow-up decision on deletion, not touched here.

Test plan

  • docdb-studio: uv run pytest tests/ — 170/170 pass (162 existing + 8 new)
  • ProcessKotlinWebsiteJSON: python3 -m unittest across both new test files — 21/21 pass
  • Ran migrate_content_to_dictionary_brotli.py against the real documentation.db, verified idempotent (second run: 0 migrated, all already-dictionary-compressed)
  • Spot-checked 8+ random real pages (androidx/java/kotlin doc sets) decode correctly post-migration, both standalone and through docdb-studio's fixed read paths

populate_db.py trains a zstd fast-cover dictionary (256 KiB) from this run's
own pages/nav on first use and stores it in a new CompressionDictionary
table, then compresses every page/nav/image/asset row against it via the
brotli CLI's -D flag (the installed Python brotli package has no dictionary
API). Never retrains an existing dictionary: a dictionary-compressed row is
only decodable against the exact dictionary it was compressed with, verified
empirically to fail silently-wrong rather than loudly on a mismatch, so
retraining would orphan every already-migrated row.

insert_optimized_media.py rewrites the same rows populate_db.py writes (image
optimization, in-place URL rewrites), so it now loads and reuses the same
dictionary instead of the old plain-Brotli calls it would otherwise silently
corrupt those rows with.

ADFA-5153.
populate_db.py and insert_optimized_media.py only ever touch their own
subset of Content (k/html/%, assets/%). Every other Content row -- reference
docs, tooltip-linked pages, whatever else -- was still plain Brotli, no
dictionary. migrate_content_to_dictionary_brotli.py recompresses every
remaining 'brotli' row against the shared CompressionDictionary (training one
from a representative whole-corpus sample if none exists yet), so the "every
brotli row uses the dictionary" assumption WebServer.kt's reader depends on
actually holds.

Idempotent by construction: a plain decode reliably fails once a row is
already dictionary-compressed (verified over 200 trials), so re-running is
always a safe no-op. Backs up first (VACUUM INTO), runs in one transaction.

Run against the real documentation.db: 29,748/29,751 brotli rows migrated,
131.1MB -> 85.6MB compressed, 299.0MB -> 255.3MB overall.

ADFA-5153.
Every 'brotli' Content row in the real database is now compressed against
the shared CompressionDictionary (see the prior two commits), but
docdb_studio.py still read and wrote plain Brotli in three places:
get_html_anchors_for_path, fetch_content_for_path (both decode), and
compress_for_storage via import_content_files (encode). Against the
migrated database this wasn't a latent risk -- it was already broken: a
plain decode of dictionary-compressed content reliably fails, so anchor
validation and content preview were silently erroring on every real page,
and any new import would have written dictionary-incompatible plain Brotli
back into a database that assumes there is none left.

get_compression_dictionary(db_path) reads and caches a database's
CompressionDictionary (or None, for a database that predates ADFA-5153) --
docdb-studio never creates or retrains one itself, only ever reads whatever
another tool already produced. compress_for_storage/decompress_brotli shell
out to the brotli CLI's -D flag when a dictionary is present, matching
populate_db.py's approach, and fall back to the plain brotli package
otherwise. decompress_brotli deliberately raises brotli.error on failure so
the two existing call sites' `except brotli.error:` handling didn't need to
change.

Verified against the real (migrated) documentation.db: anchor lookup and
content fetch both now work on real pages that previously would have
errored.

ADFA-5153.
davidschachterADFA and others added 2 commits August 14, 2026 23:30
Each row's recompress spawns its own `brotli` subprocess, so the ~30,000-row
real migration was dominated by process-spawn overhead running strictly
sequentially. Retrospective feedback: this should have been parallelized
from the start rather than accepting a slow serial run.

migrate() now runs reassemble+plain-decompress+dictionary-recompress on a
ThreadPoolExecutor (defaults to ThreadPoolExecutor's own min(32,
cpu_count+4), tuned for exactly this I/O/subprocess-bound shape); each
worker opens its own read-only connection (a single sqlite3.Connection
isn't safe across threads) and reuses one DictionaryCompressor per thread
rather than one per row. The actual delete+insert writes stay serialized on
the caller's connection, which SQLite requires anyway. Measured 3-6x faster
than sequential on synthetic benchmarks.

DictionaryCompressor gets an atexit safety-net close(), since a per-thread
instance has no single call site that can cleanly scope a `with` block
around it the way populate_db.py's/insert_optimized_media.py's own
single-threaded usage already does.

Test fixture switched from :memory: to a real temp file, since worker
threads need an actual db_path to open their own connections against - an
in-memory database has none and can't be shared across connections at all.

ADFA-5153.
This is the pipeline that actually produces the live documentation.db
(scripts/DocumentationDatabase.py, fixed earlier on this ticket, turned
out to be dead code -- its tag-triggered workflow hasn't fired since
db-2025-07-16b). populate_db.py has always run its own bare VACUUM
with no page_size pin, so the real fix belongs here.

Extracted vacuum_and_pin_page_size(), mirroring docdb_studio.py's
vacuum_database(): pins page_size via PRAGMA before VACUUM, and works
around WAL journal mode silently preventing PRAGMA page_size from
taking effect (this file's own backup_database docstring already
anticipates a live/WAL-mode database).

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

Copy link
Copy Markdown
Collaborator Author

Piggybacking one more ADFA-5141 fix onto this PR (commit b203500), since it lands in the same file this PR already touches:

While reviewing PR #25 (the docdb-studio side of ADFA-5141), I traced where the actually shipped documentation.db gets built and found scripts/DocumentationDatabase.py — which #25 fixed — is dead code: its tag-triggered publish-doc-db.yaml workflow hasn't fired since db-2025-07-16b, over a year ago.

The real pipeline is this branch's populate_db.py, which runs its own independent VACUUM (previously with no page_size pin at all). Extracted that into vacuum_and_pin_page_size(), mirroring docdb-studio's vacuum_database() — same WAL-mode workaround, same page_size pin. Added test_vacuum_and_pin_page_size.py (3 tests, all passing) covering the real 1024→2048 migration and WAL mode.

cc @alexmmiller since this builds on your fix/ADFA-4737 (PR #21) — flagging in case you want this pulled into #21 directly instead of riding in on top here.

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

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 the db file. SQLite refuses to switch a WAL-mode database away from WAL while any other connection has it open — even one from a function that already returned, since with sqlite3.connect(...) as conn: does not close conn on exit.

Found and fixed the identical bug in docdb_studio.py's vacuum_database on PR #25 (empirically reproduced as a deterministic deadlock), so applied the same fix here since this pipeline's own VACUUM is the one actually run against the live documentation.db.

Rewritten on VACUUM INTO + atomic os.replace — only needs a read snapshot of the source, so it works regardless of what else has db_path open. Two new tests: succeeds with both an unrelated open connection and an unclosed caller-style connection present at once (the scenario the old design was fragile against), and the original file is left untouched if VACUUM INTO fails partway.

17/17 local tests pass (test_vacuum_and_pin_page_size.py, test_populate_db_dictionary.py, test_migrate_content_to_dictionary_brotli.py).

Note: this branch predates PR #25's docdb_studio.py fixes (forked before ADFA-5141's work started), so docdb_studio.py here still has the old, page_size-unaware vacuum_database — that'll need a rebase/merge when PR #25 lands, separate from this fix.

davidschachterADFA and others added 2 commits August 17, 2026 16:44
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 of the mirrored
docdb-studio.py fix caught this silently dropping documentation.db
from 644 to 600 on every vacuum; same bug here since this pipeline's
vacuum_and_pin_page_size uses the identical mkstemp+replace pattern.

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_and_pin_page_size (and fails against the pre-fix code, dropping
to 600).

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

Same fix as the mirrored docdb-studio.py version: VACUUM INTO's target
accepts a bound parameter (already used by this file's own
backup_database for the same reason), sidestepping SQL string-literal
escaping for a path containing a single quote (e.g. "David's Docs")
rather than hand-rolling it. Also explicitly closes the journal_mode
-read connection instead of relying on it being reassigned by the next
`with` block.

New test: a quote in db_path's parent directory no longer breaks the
statement.

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

Copy link
Copy Markdown
Collaborator Author

Follow-up: applied the same fixes here that a maintainer's QA and a follow-up self-review turned up on the mirrored PR #25 fix:

  • Restored file permissions after the VACUUM INTO + os.replace swap (tempfile.mkstemp always creates its file mode 0600, silently dropping a 644 documentation.db to 600 on every vacuum — confirmed on real hardware in PR ADFA-5141: Pin doc DB page_size to 2048 in vacuum_database #25's QA).
  • Switched VACUUM INTO's target from an f-string to a bound parameter (matching this file's own backup_database, which already does this) — a single quote in db_path's parent directory otherwise broke the statement.
  • Closed the journal_mode-read connection explicitly instead of relying on it being reassigned by the next with block.

19/19 local tests pass.

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

Applied the applicable subset of PR #25's third self-review fixes here too: chmod-before-replace ordering and explicit connection closes with consistent timeouts. 19/19 local tests passing.

…to ADFA-5153

Benchmarking showed page_size=1024 vs 2048 has essentially the same
performance and a negligible size difference before compression (and
likely less after this PR's dictionary compression) -- adding
complexity without benefit. ADFA-5141 is declined; this PR is only
about the Brotli dictionary compression (ADFA-5153) and the page_size
work rode along on this branch by coincidence of timing, not by scope.

Restores populate_db.py's original plain VACUUM call and removes
vacuum_and_pin_page_size, SQLITE_PAGE_SIZE_BYTES, the now-unused os/stat
imports, and their dedicated test file.

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

Copy link
Copy Markdown
Collaborator Author

ADFA-5141 (the page_size=2048 change this PR's vacuum_and_pin_page_size was mirroring) has been declined — benchmarking showed essentially no performance difference between 1024 and 2048, and a negligible size difference before compression (likely even less after this PR's dictionary compression). Reverted that code out of this PR in 801f5eb6 so it stays scoped to the Brotli dictionary compression work (ADFA-5153) it's actually about. populate_db.py is back to its original plain VACUUM call; test_vacuum_and_pin_page_size.py removed along with it. 12/12 remaining local tests pass.

WebServer.kt's reassembly loop always probes "<path>-1" first, but 14 of
19 chunked Content rows in the real documentation.db number their
continuations starting at "-2" instead, with no "-1" row at all. The
first lookup misses, the loop stops after the base 1 MB chunk, and the
row is served short: a corrupt image (compression='none', silent 200) or
a decode failure (compression='brotli', 500) - confirmed against a local
copy of the shipped database (md5 34c879595bd6fb87e5b68989369680a8).

No writer in this tool ever produced that numbering - populate_db.py,
insert_optimized_media.py, and migrate_content_to_dictionary_brotli.py
all go through insert_chunked_content, which has always started
fragments at -1. This is inherited data older than this pipeline, not
something it can regenerate correctly by re-running existing tools.

renumber_misnumbered_fragments.py finds base rows whose fragment chain
(via LIKE, sorted on the parsed numeric suffix rather than assumed
paths) doesn't start at 1, and renumbers it to a contiguous run starting
at -1, lowest-suffix first so each rename's target is the path just
vacated by the previous one. A chain with an actual gap (a genuinely
missing chunk, a different failure) is reported and left alone rather
than guessed at. Content bytes are never touched, only paths, so it's
safe regardless of a row's compression. Verified against a scratch copy
of the real database: renumbers exactly the 14 chains the ticket found,
and the two example rows (the devsite gif, the Javadoc index) reassemble
and decode correctly afterward.
…bering

ADFA-5171: Repair chunked Content rows misnumbered from -2

@alexmmiller alexmmiller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA'd this against the real ~300MB production documentation.db (post-ADFA-5141 page_size migration). The dictionary migration itself works and matches the PR's claimed numbers closely (29,748/29,751 rows, 131.08MB → 85.91MB, 34.5% smaller; spot-checked several real pages decode correctly through docdb-studio's fixed read paths). Found two bugs while doing so — left inline comments on each. Neither corrupted data (the first rolls back cleanly; the second just produces redundant, byte-identical work), but both are worth fixing before this ships.


conn = sqlite3.connect(args.db_path)
try:
conn.execute("BEGIN")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: this crashes with sqlite3.OperationalError: database is locked against a DB in rollback-journal mode (the default, and what our real production documentation.db actually uses — journal_mode=delete, not WAL).

migrate() runs the whole thing in one write transaction on conn, while each _migrate_one_row call (dispatched across a ThreadPoolExecutor, default up to min(32, cpu_count+4) workers) opens its own read connection to the same file (line ~165). Under journal_mode=WAL that's fine — readers and a writer don't block each other. But under rollback-journal mode, once this transaction's dirty-page cache overflows (very likely here: ~30k rows, ~85MB of new compressed content), SQLite has to escalate to an EXCLUSIVE lock to spill pages to the file, and that lock holds for the rest of the transaction. Every worker-thread read connection opened after that point fails with database is locked, executor.map raises, and the except Exception: conn.rollback() in main() discards all the work done so far — not silent corruption, but a guaranteed crash + total loss of progress on a real 300MB-class DB in the journal mode this project actually uses.

Reproduced this directly: it crashed partway through a real ~300MB copy of documentation.db (journal_mode=delete). Switching that copy to PRAGMA journal_mode=WAL first let the exact same script run to completion without any code changes. Worth either (a) documenting/enforcing that the target DB must be in WAL mode before running this, or (b) having the script itself switch to WAL for the duration and restore the original mode after, similar to how docdb_studio.vacuum_database already handles this WAL/rollback-journal distinction (ADFA-5141).

worker_conn.close()

try:
plain = brotli.decompress(full)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: idempotency doesn't hold for small/near-incompressible content, contrary to the module docstring's claim ("verified empirically over 200 trials").

The "already migrated" check is: try a plain (no-dictionary) brotli.decompress; success means "not yet migrated", failure means "already dictionary-compressed". That assumption breaks for small payloads where the encoder never actually needed to reference the custom dictionary — the resulting stream is then valid Brotli with or without it, so a plain decode of an already dictionary-compressed row can still succeed.

Concretely, on the real DB, re-running this script immediately after a fully successful migration (which should report migrated: 0) instead reported migrated: 228 (all .webp images plus k/version.html / k/version.json), with byte-identical before/after compressed sizes on every single one of those rows. Verified directly: brotli.decompress() on the already-migrated k/version.html blob succeeds and returns the correct plaintext, with no dictionary involved.

It's non-destructive (output is byte-for-byte identical, so nothing is lost), but every future re-run will burn CPU + spawn brotli subprocesses reprocessing this same ~228-row set forever, and the "idempotent, verified over 200 trials" claim in the docstring above should be scoped to note this known exception (or the detection logic should be hardened, e.g. checking for a minimum size before trusting the plain-decode heuristic, or storing a per-row/global migration marker instead of inferring it from decode behavior).

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