Skip to content

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

Merged
davidschachterADFA merged 16 commits into
fix/ADFA-4737from
ADFA-5153-content-brotli-dictionary
Aug 22, 2026
Merged

ADFA-5153: Compress Content table with a trained Brotli dictionary#26
davidschachterADFA merged 16 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.

@hal-eisen-adfa hal-eisen-adfa 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.

Code review — findings in files outside this PR's diff

This pass reviewed origin/main...HEAD, which is wider than #26's own diff (the branch has #27 merged into it). The five inline comments on this review are on files #26 actually changes.

The findings below are on the branch but live in files #26 does not modify, so GitHub can't anchor them to a line. Recording them here rather than dropping them — they may belong on a separate PR or ticket. Every one was verified against the code; several were reproduced by execution.


CRITICAL — scripts/sync_kotlin_stdlib_docs/sync_kdoc_json_to_db.py:54

compress_for() calls plain brotli.compress(). CI step 2 (populate_db.py) creates and uses the shared CompressionDictionary on documentation.db, then step 5 (build-kotlin-docs.yaml:267) runs this script against that same file. Every k/kotlin-stdlib/*, k/kotlin-reflect/* and k/kotlin-test/* row is left plain-brotli inside a dictionary database.

Per this PR's own note in populate_db.py:271-289, a wrong-dictionary decode "can just as easily 'succeed' while silently returning different bytes" — so this surfaces as corrupted stdlib pages with no error anywhere. This is the finding most worth fixing before #26 lands, since #26 is what makes documentation.db a dictionary database in the first place.

HIGH — sync_kdoc_json_to_db.py:166

"Source file missing ⇒ delete the row" has no sanity floor. If a Dokka upgrade changes the emitted layout so that zero files resolve, this deletes every stdlib Content row plus their parent Tooltips via cleanup_orphaned_tooltips, prints Done: updated 0, deleted N, and exits 0. The workflow's Summary step only prints row counts without asserting on them, so the gutted DB uploads to production.

HIGH — ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py:84

TAG_RE = ^<(/?)(tabs|tab|note|tip|warning)([^>]*)/?>$ has no boundary after the alternation. Verified by execution: <table>('', 'tab', 'le'), </table>('/', 'tab', 'le'), <notes>('', 'note', 's'). A page with a hand-written HTML table (the docstring names roadmap.md) loses its <table> wrapper and the browser receives orphaned <tr>/<td> markup.

HIGH — md_to_json.py:417

Only fence is handled. markdown-it's code_block token (4-space-indented code, fully supported by the "commonmark" preset) falls through to the catch-all {"type": "html", "html": str(t.content)} and is rendered via {{ b.html|raw }}. Indented Kotlin such as fun <T> box(x: T) has <T> parsed as an unknown element and silently swallowed.

HIGH — optimize_media.py:233 and :349

dst.with_suffix(".webp") / .with_suffix(".png") collapses distinct sources onto one output path with no collision check. With --webp, a.png and a.jpg both write out/a.webp and one silently overwrites the other. build_rename_map accepts both, since its duplicate check only guards repeated old names — so pages that referenced a.jpg get rewritten to an image showing different content.

HIGH — build_nav.py:64

load_page_index runs json.loads(...).get("id") over rglob("*.json"), but line 207 writes nav.json — a top-level array — into that same directory, and the README documents exactly that invocation (build_nav.py <docs-root> <output-dir> <output-dir>). The second run dies with AttributeError: 'list' object has no attribute 'get' until the user manually deletes nav.json.

HIGH — .github/workflows/build-kotlin-docs.yaml:163

Requests only the drive.file scope. The already-working sibling workflow that downloads the same GOOGLE_DRIVE_FILE_ID through the same check-tools/download_database.py requests drive.readonly and drive.file (docdb-regression-test.yaml:46-48). drive.file covers only files the app itself created or opened, not a pre-existing shared file, so files().get() 404s and the job dies at its first Drive step on every run.

MEDIUM — sync_kdoc_json_to_db.py:164

UPDATE Content SET content = ? writes the whole blob into a single row, bypassing the CHUNK_SIZE fragmentation that populate_db.py:159 documents as having to match WebServer.kt exactly. Existing fragment rows are then deleted: relative_target_path("k/kotlin-stdlib/x.html-1") doesn't end in .html, so no source ever matches it and line 170 removes it — with those paths also fed into cleanup_orphaned_tooltips.

MEDIUM — md_to_json.py:142

The quoted-vs-bare test checks '="' in (attr_str or "") across the whole attribute string instead of per match. Verified: parse_attrs('width=500 style="block"'){'width': '', 'style': 'block'}, and parse_attrs('kotlin-runnable="true" validate=false'){'kotlin-runnable': 'true', 'validate': ''}. width=500 on its own works, which is what makes this easy to miss; {width=700 style="block"} emits width="" and the image renders at intrinsic size.


Lower-severity, not itemized above

  • migrate_content_to_dictionary_brotli.py:125 — the docstring promises "a random sample drawn across the WHOLE Content table", but it's base_rows[:sample_size] over rows ORDER BY path — the first 300 alphabetically, i.e. effectively one directory. The dictionary is never retrained, so a narrow training set is permanent.
  • build-kotlin-docs.yaml:367 — "Grabbing baton" is unconditional but "Dropping baton" is gated on !inputs.dry_run with no always(). dry_run defaults to true, so a default run acquires the team's lock signal and never releases it; a mid-build failure does the same.
  • md_to_json.py:469_finalize_container keeps only type == "tab" children, so an intro paragraph before the first <tab> is dropped from the JSON with no warning.
  • build_nav.py:64 — unsorted rglob with last-wins, versus md_to_json.build_topic_index's sorted/first-wins, makes nav.json non-reproducible across machines when two stems collide.
  • build-stdlib-json-docs.sh:57 — under set -euo pipefail the DOKKA_VERSION="$(grep ... | sed ...)" assignment aborts on grep failure, making the if [ -z "$DOKKA_VERSION" ] diagnostic on lines 58-61 unreachable.
  • run_e2e_pipeline_test.sh:105require_path validates user-supplied paths against the invoking cwd, then the step-2 subshell cds to $PROCESS_DIR before passing them through; the cd buys nothing, since populate_db.py resolves its assets via Path(__file__).parent.
  • test_populate_db_dictionary.py:54 — asserts that a wrong dictionary decodes without raising, which the code's own docstring describes as nondeterministic. Brittle across brotli/zstd versions.
  • assets/sidebar.js:114fetch(navSrc) never checks response.ok, so a 404 body gets injected into the sidebar as nav markup and .catch never fires.

Comment thread docdb-studio/docdb_studio.py Outdated
Comment thread docdb-studio/docdb_studio.py

@davidschachterADFA davidschachterADFA left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed against the shipped 299 MB documentation.db rather than from the diff alone — two blockers, both reproduced.

Blocker 1: chunked rows are silently skipped and counted as successes

reassemble_content probes <path>-1 first. But 14 of the 19 chunked chains in that database number from -2 — the very defect renumber_misnumbered_fragments.py exists to repair. For those rows the probe misses immediately, the function returns just the first 1 MiB, brotli.decompress raises on the truncated stream, and _migrate_one_row returns None — which the caller records as already_migrated. The row is never migrated and the run reports success.

The PR's own numbers corroborate this. "29,748/29,751 brotli rows migrated" — and that database has exactly three chunked rows whose content type is brotli: the notification-permission SVG, j/html/api/index-all.html, and constraint-layout-chain.mov. All three start at -2. The 3 counted as already-dictionary-compressed in a first-ever migration were not; they are the three that failed to reassemble.

Two changes, both worth making:

  1. chain_fragments in the sibling script already discovers a chain correctly — LIKE query plus parsed suffix, agnostic to where the numbering starts. reassemble_content should use that approach rather than keeping a second, more fragile convention in the same PR.
  2. Document the ordering dependency (renumber before migrate) in both docstrings, since getting it wrong is silent.

Blocker 2: the delete-then-insert destroys the Bookshelf

Content carries triggers:

CREATE TRIGGER DeleteBook AFTER DELETE ON Content WHEN OLD.path LIKE '%.pdf'
  BEGIN DELETE FROM Bookshelf WHERE contentID = OLD.id; END
CREATE TRIGGER AddBook AFTER INSERT ON Content WHEN NEW.path LIKE '%.pdf'
  BEGIN INSERT INTO Bookshelf (contentID, title) VALUES (NEW.id, CURRENT_TIMESTAMP || NEW.id); END

15 .pdf rows are brotli-typed, so they are in scope, and all 7 Bookshelf rows point at brotli-typed content. Running the exact delete_content + insert_chunked_content pair on a throwaway copy:

before: (53507, category 5,    'Android Notes for Professionals')
after:  (53508, category NULL, '2026-08-21 22:37:5553508')

Curated title, description and category lost for all 7 books, replaced by timestamp junk, silently, with the migration reporting success. This is also what ADFA-5212/ADFA-5179 curated.

Fix: UPDATE the base row in place instead of delete-and-reinsert. That also removes a second-order hazard — delete+insert assigns a new Content.id, so any id-keyed reference dangles even where no trigger papers over it.

Should fix

"Already migrated" is inferred, never verified. A failed plain decode is taken as proof of dictionary compression, but it is equally what truncation or corruption looks like — which is exactly how Blocker 1 hides. Confirm with a dictionary decode, and treat a row that decodes neither way as an error rather than a success.

No round-trip check before writing. recompressed is stored without confirming it decodes back to plain. One extra decode per row converts a silent bad write into a loud failure, and the compress is already the expensive part.

docdb_studio.decompress_brotli has no plain fallback. With a dictionary present it decodes dictionary-only, so plain rows in a dictionary-carrying database fail — precisely the state a partially-completed migration leaves behind, and the permanent state of plugin-inserted rows. WebServer tries dictionary-first and falls back, and that fallback is load-bearing rather than defensive: attaching a custom raw dictionary displaces the distance space brotli's built-in static dictionary occupies, so a plain row that referenced static-dictionary words genuinely fails the dictionary decode. Measured at window 22, 16 and 10 — corrupt input in all three.

Nits

The DictionaryCompressor docstring contradicts the migration's idempotency argument. It says decoding with "none" when a dictionary was used can "just as easily succeed while silently returning different bytes". Measured on 400 real migrated rows: 398 hard errors, 2 identical output (the dictionary was never referenced), 0 wrong bytes. The silent-wrong-bytes behaviour belongs to a wrong dictionary — a same-length, locally-perturbed one gave 50% errors, 38% silent wrong bytes, 12% identical. Worth separating the two cases, because the migration's idempotency depends on the no-dictionary case being loud, and this docstring tells a future reader that it is not. (ADFA-5222 covers detecting the wrong-dictionary case, which nothing currently can.)

The training sample is not random. base_rows[:sample_size] over ORDER BY C.path is the first 300 paths alphabetically — all a/... — while both the docstring and the PR description say "random sample drawn across the WHOLE Content table". This matters more than a usual doc nit, since the dictionary is trained once and load_or_create_dictionary can never retrain it.

delete_content's LIKE has no ESCAPE. _ and % are wildcards and 1,489 of 30,649 paths contain one. I checked the real database: zero rows would be wrongly deleted today, so this is latent rather than live — but chain_fragments already shows the safer pattern.

Peak memory. SELECT C.path, C.content, ... with .fetchall() holds every brotli blob (~131 MB) in one list, and each worker then re-reads the same content through its own connection. Selecting LENGTH(content) and fetching blobs per row would cut that substantially.

Base branch. This targets fix/ADFA-4737 (open PR #21), so it cannot land until that does, and its diff will shift underneath.

Worth keeping as-is

renumber_chain's single ascending pass is correct — each target was vacated by the previous rename — and simpler than the two-pass parking I used in the app-side script; I plan to simplify mine to match. _thread_compressor's per-thread reuse is the right call, and the never-retrain guarantee in load_or_create_dictionary is the right invariant in the right place.

@davidschachterADFA davidschachterADFA left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Follow-up with measurements, plus reconciliation against @hal-eisen-adfa's review above. Our passes had different scopes — that review covered origin/main...HEAD (wider than #26, including #27's files); mine covered #26's own diff and ran against the shipped documentation.db. Three findings overlap, and one premise we both relied on turns out to be wrong in a way that moves a severity.

Blocker 1 is now confirmed, not inferred

I enumerated the corpus independently while running the experiment below: the brotli set in that database is 29,751 base items and exactly 3 continuation rows. This PR reports scanning 29,751 rows and migrating 29,748. So the 3 rows it counted as "already dictionary-compressed" in a first-ever migration are precisely the 3 items that have continuations — the ones reassemble_content fails to reassemble because it probes <path>-1 and those chains start at -2. Nothing was already migrated; three rows were silently skipped and counted as successes.

Nit #7 measured: the sampling issue is worth ~19%, and the obvious fix makes it worse

@hal-eisen-adfa flagged the same base_rows[:sample_size] selection under lower-severity. It is worth more than that. I trained four 256 KiB dictionaries from the same corpus with one harness — zstd --train-fastcover, --maxdict=262144 — varying only how training rows were chosen, then compressed the same 4,000 random items (seed 11) with each:

dictionary training material dict size stored vs plain brotli vs A'
A' current: first 300 by path 22.8 MiB, a/ only 262,144 B 10.5 MiB 36.2%
B stratified, 300 rows 12.7 MiB, 13 sets 165,069 B 11.0 MiB 33.2% +4.65% worse
C stratified, 32 MiB budget 32.2 MiB, 6 sets 262,144 B 8.5 MiB 48.3% -18.98%
D biased order, 32 MiB budget 32.1 MiB, a/ only 262,144 B 10.5 MiB 36.4% -0.34%

D and C together isolate the mechanism. D shows more training material alone buys nothing (-0.34%, noise). C shows the spread across doc sets is the entire win. B shows why the one-line fix is a trap: stratifying at a fixed row count pulls quotas from smaller doc sets, total material drops to 12.7 MiB, and zstd cannot even fill the 256 KiB cap (165,069 B) — 4.65% worse than doing nothing.

So the fix is both changes together: stratify by doc set and control the sample by plaintext bytes rather than row count. Either alone is neutral or harmful.

For scale: my own full-corpus run with the shipped dictionary went 126.7 MiB -> 83.4 MiB (34.2%, consistent with A' here). At C's 48.3% that is roughly 65.5 MiB, about 18 MiB more off the asset. That is an extrapolation from 4,000 of 29,751 items, and my trainer settings may not match this PR's exactly.

Context worth stating: load_or_create_dictionary never retrains, so whichever dictionary a database is first minted with is permanent for that database's content. Fixing the sampler is therefore only free before the next asset is generated.

The premise correction: "no dictionary" is loud; "wrong dictionary" is what is silent

populate_db.py's DictionaryCompressor docstring says decoding with "none" when a dictionary was used "can just as easily 'succeed' while silently returning different bytes". Measured against the real database:

  • No dictionary on 400 dictionary-compressed rows: 398 hard errors, 2 identical output (the compressor never referenced the dictionary), 0 wrong bytes.
  • A wrong dictionary (same length, one 16 KiB region perturbed, 3 real rows x 16 regions): 50% errors, 38% decoded with no error and wrong bytes, 12% identical.

Two consequences:

  1. This PR's own idempotency guarantee is sound — it rests on a plain decode failing on an already-migrated row, and that direction is reliable. The docstring currently tells a future reader otherwise, which is worth fixing precisely because someone will use it to justify tearing out the idempotency check.
  2. It moves the severity of the CRITICAL above. sync_kdoc_json_to_db.py writing plain-brotli rows into a dictionary database is a real problem and should be fixed — but not because stdlib pages get silently corrupted in the app. WebServer.decompressBrotli tries the dictionary, gets a hard IOException, and falls back to a plain decode, which returns the correct bytes. What actually breaks is narrower and still worth fixing: docdb-studio's read paths, which after this PR decode dictionary-only with no fallback, so they fail on exactly those rows; those rows never get the dictionary's compression benefit; and the invariant populate_db.py claims WebServer needs ("every brotli row uses the same dictionary") is not one WebServer actually depends on, since it carries that fallback deliberately for plugin-contributed content.

Also on that thread: @hal-eisen-adfa's note that test_populate_db_dictionary.py:54 asserting a wrong-dictionary decode does not raise is brittle — the numbers above are why. That assertion is a 50/50 coin flip across brotli versions and payloads.

Every finding from the three reviews on PR #26 that lands in files this PR
touches, plus the one dictionary-consistency problem outside it that this PR
itself creates.

Data loss, both silent:

* The migration deleted and re-inserted each base row. Content carries
  AddBook/DeleteBook triggers on '%.pdf' paths, so that cycle replaced every
  curated Bookshelf entry with 'CURRENT_TIMESTAMP || id' under a fresh
  Content.id -- verified on the real database: (53507, category 5, "Android
  Notes for Professionals") became (53508, category NULL, "2026-08-21
  22:37:5553508"). 15 brotli-typed .pdf rows and all 7 Bookshelf rows are in
  scope. Writes are now UPDATE in place, with continuation rows reconciled by
  exact path.

* delete_content interpolated a path straight into LIKE, where `_` is a
  wildcard and the `-%` suffix was not restricted to digits, so unrelated rows
  could be deleted permanently (hal-eisen-adfa). No write path goes through
  LIKE any more.

Rows silently skipped while the run reported success:

* reassemble_content probed "<path>-1", so an ADFA-5171 chain numbered from -2
  reassembled truncated, failed to decode, and was counted as "already
  dictionary-compressed". The corpus has 29,751 base rows and exactly 3 with
  continuations; the run reported 29,748 migrated and 3 already-migrated in a
  first-ever migration, which is precisely those 3. Chain discovery is now
  shared with the repair script (populate_db.fragment_chain), so the two
  cannot drift apart again.

* Any decode failure counted as "already migrated" (hal-eisen-adfa). Rows are
  now classified by decoding both ways: identical either way means the encoder
  never referenced the dictionary and there is nothing to gain (~0.5% of the
  real corpus, and the reason a second run used to re-migrate them --
  alexmmiller); plain-only means migrate; dictionary-only means done; neither
  is an error, never a success.

* Recompressed bytes are verified to round-trip before being written.

Concurrency and memory:

* Each worker opened its own read connection while the caller held one write
  transaction over the whole run, which deadlocks under journal_mode=delete --
  documentation.db's actual mode (alexmmiller). All database access is now on
  the calling thread; workers receive bytes. Commits are batched, so an
  interrupted run keeps finished batches and resumes.

* Blobs are no longer selected for every row up front (~130 MB held at once).

Dictionary training, measured on the real corpus with only the sampling varied:

    first 300 rows by path (all under "a/")        36.2% smaller than plain
    300 rows stratified across doc sets            33.2%  <- worse
    stratified, 32 MiB plaintext budget            48.3%  <- best
    first-by-path, same 32 MiB budget              36.4%  <- volume alone: nil

The docstring promised "a random sample drawn across the WHOLE Content table"
and delivered the first 300 paths alphabetically -- 299 of them under "a/",
while j/ (10,326 rows) and k/ (3,757) trained nothing (hal-eisen-adfa). Fixing
it by stratifying alone makes things worse: quotas drawn from smaller doc sets
starve the trainer, which then cannot even fill a 256 KiB dictionary. Both
halves are needed, so sampling is now stratified by stored bytes and bounded
by a plaintext budget, seeded for reproducibility since a stored dictionary is
never retrained.

renumber_misnumbered_fragments:

* A chain numbered from -0 passed the "starts at 1?" guard and renamed onto an
  occupied slot, tripping UNIQUE(path) and rolling back every other repair in
  the pass (hal-eisen-adfa). Such a chain is repaired rather than skipped -- the
  app probes "-1", finds it, and serves the chain with "-0" dropped -- via a
  parking pass that is correct in either shift direction.

docdb-studio:

* sqlite3.OperationalError covers "database is locked", and caching that as
  "no dictionary" downgraded the whole session to plain Brotli
  (hal-eisen-adfa). Only definitive answers are cached now.

* The new `brotli` CLI dependency raised RuntimeError/OSError out of paths that
  guard only `brotli.error` (hal-eisen-adfa). Missing-binary now raises a
  BrotliCliMissing subclass of brotli.error, with an actionable message.

* decompress_brotli decoded dictionary-only, so it could not read plain rows --
  which a dictionary database always contains: anything a plugin contributes
  on-device, anything written outside populate_db.py, and everything mid-
  migration. It now falls back to a plain decode, as WebServer.kt does.

sync_kdoc_json_to_db (outside this PR's diff, but this PR is what makes
documentation.db a dictionary database):

* compress_for used plain brotli.compress, leaving every k/kotlin-stdlib row
  plain inside a dictionary database (hal-eisen-adfa). It now compresses
  against the database's dictionary when there is one.

* "Source file missing => delete the row" had no floor: a Dokka layout change
  makes every lookup miss, and the script would delete every stdlib row plus
  its parent Tooltips and exit 0 (hal-eisen-adfa). Sources are resolved up
  front and a wholesale miss aborts.

Corrected in populate_db's DictionaryCompressor docstring, because two reviews
reasoned from it: the two mismatch directions are not alike. Decoding a
dictionary row with NO dictionary is loud (398 of 400 real rows raised, 2
returned identical bytes, none wrong), which is what makes both this script's
idempotency check and WebServer.kt's fallback sound. Decoding with the WRONG
dictionary is the silent case (50% raised, 38% returned different bytes with no
error, 12% identical). The test asserting a wrong-dictionary decode does not
raise was asserting that coin flip; it now asserts the invariant that holds.

Tests: 25 in ProcessKotlinWebsiteJSON (up from 21) and 173 in docdb-studio (up
from 170) pass. New coverage for the -2 chain, an undecodable row, Bookshelf
survival through the triggers, a never-referenced-dictionary row across two
runs, stratified sample determinism and spread, zero-based renumbering, one bad
chain not blocking other repairs, a locked database not being cached, a plain
row in a dictionary database, and a missing brotli CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Pushed 838ac44, which addresses every review finding that lands in files this PR touches, plus the two on sync_kdoc_json_to_db.py that this PR itself creates the conditions for. All seven inline threads are answered and resolved individually; this is the summary.

The two that were destroying data

The Bookshelf. The migration deleted and re-inserted each base row, and Content carries AddBook/DeleteBook triggers on '%.pdf' paths. Verified against the real database before the fix: (53507, category 5, "Android Notes for Professionals") became (53508, category NULL, "2026-08-21 22:37:5553508"). 15 brotli-typed .pdf rows are in scope and all 7 Bookshelf rows point at brotli-typed content, so a single run replaced the whole curated shelf with timestamp junk and reported success. Writes are now UPDATE in place, continuation rows reconciled by exact path. @hal-eisen-adfa's LIKE finding fixed this one too, since dropping delete+insert is what stops the triggers firing.

Silently skipped rows. reassemble_content probed <path>-1, so an ADFA-5171 chain numbered from -2 reassembled truncated, failed to decode, and was counted as "already dictionary-compressed". This was live, not theoretical: the corpus has 29,751 base rows and exactly 3 with continuations, and the pre-fix run reported "29,748 migrated, 3 already dictionary-compressed" on a first-ever migration. Chain discovery now lives in one place (populate_db.fragment_chain) shared with the repair script, so the two conventions cannot drift apart again.

Verified end to end

Against the real 256 MB database, journal_mode=delete, 20 workers: 29,677 rows scanned, 29,676 already dictionary-compressed, 1 migrated, 0 errors, Bookshelf still 7 rows with their titles and categories intact, integrity_check ok. That is the same run shape that previously destroyed the shelf and miscounted the chained rows.

Tests: 25 in ProcessKotlinWebsiteJSON (from 21) and 173 in docdb-studio (from 170). New coverage for the -2 chain, an undecodable row, Bookshelf survival through the real triggers, a never-referenced-dictionary row across two runs, stratified sample determinism and spread, zero-based renumbering, one bad chain not blocking other repairs, a locked database not being cached, a plain row in a dictionary database, and a missing brotli CLI.

Dictionary training

Measured with only the sampling varied, since @hal-eisen-adfa flagged the selection and the naive fix turns out to be a trap:

training sample vs plain brotli
first 300 rows by path (all under a/) 36.2%
300 rows stratified across doc sets 33.2% -- worse
stratified, 32 MiB plaintext budget 48.3%
first-by-path, same 32 MiB budget 36.4% -- volume alone buys nothing

Stratifying at a fixed row count draws quotas from smaller doc sets, so total material falls and the trainer cannot even fill a 256 KiB dictionary. Sampling is now stratified by stored bytes and bounded by a plaintext budget, seeded for reproducibility since a stored dictionary is never retrained.

One shared premise corrected

Both reviews reasoned from DictionaryCompressor's docstring, which said a no-dictionary decode can silently return wrong bytes. Measured: decoding a dictionary row with no dictionary is loud (398 of 400 real rows raised, 2 returned identical bytes, none wrong) -- which is what makes this script's idempotency check and WebServer.kt's fallback sound. Decoding with the wrong dictionary is the silent case (50% raised, 38% returned different bytes with no error, 12% identical). The docstring now says that, and the test asserting a wrong-dictionary decode does not raise -- which was asserting a coin flip -- now asserts the invariant that actually holds.

Deliberately not in this commit

@hal-eisen-adfa's remaining findings are in files this PR does not touch and in a pipeline I cannot exercise here, so they are filed rather than bundled: ADFA-5237 (md_to_json.py: the <table> boundary bug, the code_block fallthrough, the per-match attribute test, the dropped intro paragraph) and ADFA-5238 (optimize_media.py filename collisions, build_nav.py poisoning its own index, the drive.file scope, the leaked CI baton, the chunking bypass in sync_kdoc_json_to_db.py, and the shell/JS nits). Both link to ADFA-5153.

davidschachterADFA and others added 3 commits August 21, 2026 17:17
… brotli

Both from hal-eisen-adfa's follow-up review of 838ac44.

insert_optimized_media.delete_content still built the LIKE pattern the
migration script had stopped using: `path = ? OR path LIKE '<path>-%'`, where
`_` is a single-character wildcard and the suffix is not constrained to digits.
Rows matched that way are never re-inserted, so the loss is permanent. It now
deletes the base row by exact path and each continuation by the exact paths
populate_db.fragment_chain returns, which does the over-matching query once and
re-checks every candidate's parsed suffix. The claim "no write path constructs
a LIKE pattern any more" is now true of the whole tree, not just one file.

The `brotli` CLI became a required external binary in three independent paths
(populate_db's DictionaryCompressor, sync_kdoc_json_to_db, docdb_studio) and
nothing declared it. The Python `brotli` package the README asks for is a
different artifact and exposes no custom-dictionary parameter, which is exactly
why the CLI is unavoidable -- and what makes `pip install brotli` read as though
it covers this. Declared in the four places that would tell someone:

  * build-kotlin-docs.yaml's apt-get line -- it runs populate_db,
    insert_optimized_media and sync_kdoc_json_to_db.
  * docdb-regression-test.yaml's apt-get line -- it runs docdb-studio against
    the downloaded production database, which is now a dictionary database, so
    its reads need the binary too. (CI previously depended on whatever the
    runner image happened to ship.)
  * ProcessKotlinWebsiteJSON/README.md, beside the existing `pngquant on PATH`
    bullet, spelling out that this is the CLI and not the Python package.
  * docdb-studio/README.md, noting `uv sync` cannot install it and that a
    database with no CompressionDictionary needs nothing extra.

publish-doc-db.yaml is deliberately untouched: it runs the scripts/ingest.py
pipeline, which does not reach for the CLI.

Tests unchanged and passing: 25 in ProcessKotlinWebsiteJSON, 173 in
docdb-studio. Both workflow files still parse as YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base

remint_dictionary.py trains a new shared dictionary for an already-migrated
database and recompresses every 'brotli' row against it in one transaction,
replacing the CompressionDictionary row. verify_remint_dictionary.py is the
read-only gate: it decodes every row out of both databases and requires the
plaintexts to match, exiting non-zero otherwise.

These deliberately do what load_or_create_dictionary refuses to do, and the
refusal is right for the pipeline: replacing a stored dictionary without
recompressing the content orphans every row, since the dictionary decode fails
and the plain fallback fails too. The only safe way to change a dictionary is to
change the content with it, atomically, which is what this pair is for. Either
every row converts and the dictionary is replaced, or nothing is written.

Why it is worth having: the dictionary a database is first minted with is
permanent for its content, so a poorly-sampled one stays expensive forever.
Re-minting the 21-Aug database with the stratified, byte-budgeted sampler took
its brotli content from 83.4 MiB to 65.6 MiB and the vacuumed file from 268 MB
to 249 MB -- 18 MB -- with all 29,677 items verified byte-identical, and the
result confirmed on device: pages served at their original byte counts through
brotli4j, whose attachDictionary had never seen this dictionary before.

The verifier is not ceremony. A row recompressed against a mismatched
dictionary decodes with no error into *different* bytes 38% of the time
(50% raises, 12% is identical because the perturbed region was never
referenced), so nothing at runtime detects it and the check has to happen
against the original before the file is put in place.

collect_training_samples now takes an optional decoder, defaulting to plain
Brotli. A re-mint's rows are dictionary-compressed, so it passes one that reads
against the outgoing dictionary and falls back to plain -- the fallback is
required, not defensive, because a dictionary database always holds some plain
rows. read_item, write_item and load_base_rows are reused from the migration
script rather than copied, which is what keeps the in-place write (never
DELETE+INSERT on a base row, because of the '%.pdf' triggers) in one place.

Four tests. Two of them exist because writing them corrected me: re-minting
with the same seed and corpus reproduces the stored dictionary byte for byte,
so a test asserting the dictionary changed has to vary the seed -- and an
earlier assertion that the outgoing dictionary can no longer decode a re-minted
row was asserting a coin flip, the same mistake as asserting that a
wrong-dictionary decode raises. The remaining two cover the abort path leaving
the database untouched, and the verifier actually objecting to a corrupted
re-mint rather than passing vacuously.

29 pipeline tests (from 25) and 173 docdb-studio tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docdb-studio's README named `apt` and `brew` and left Windows users with
nothing, on the one dependency `uv sync` cannot install for them. It now has a
section of its own, following the per-OS shape the uv instructions already use:
winget, scoop and choco, each preceded by the matching `search` command so a
renamed package cannot strand the reader, plus MSYS2 for anyone who already has
Git for Windows. Then the two things that actually go wrong on Windows: a
changed PATH is only visible in newly-opened terminals, and a package manager
can install the binary somewhere that is not on PATH at all -- so `where.exe
brotli`, the usual shim directories, and where to edit PATH.

It also states plainly that the `brotli` in `uv sync` is a different artifact
from the `brotli` program, since `pip install brotli` succeeding is exactly what
makes this confusing, and doubly so on Windows where there is no `brotli.exe`
afterwards.

Writing that section exposed a real defect in the BrotliCliMissing handling from
838ac44. Subclassing brotli.error kept a missing binary from escaping as an
unhandled RuntimeError, which is what the review asked for -- but the two call
sites catch brotli.error and return []/None, so the failure became a blank
preview with nothing said anywhere. A corrupt row and a missing binary are not
the same event: one is a single bad row, the other means nothing in this
database will ever decode and is fixable in one command. The call sites now
catch BrotliCliMissing separately and print which path failed and why, and the
exception's message points at the README rather than listing two Unix package
managers. The README says what actually happens -- blank preview plus an
explanatory error in the launching terminal -- rather than claiming the UI
reports it.

174 docdb-studio tests pass (from 173); the new one asserts both call sites log
rather than swallow, and that the message names the path and points at the
README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@davidschachterADFA
davidschachterADFA merged commit 5a00e22 into fix/ADFA-4737 Aug 22, 2026
@davidschachterADFA
davidschachterADFA deleted the ADFA-5153-content-brotli-dictionary branch August 22, 2026 00:48
hal-eisen-adfa added a commit that referenced this pull request Aug 25, 2026
* Compress Kotlin-website Content rows against a shared Brotli dictionary

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.

(cherry picked from commit 26c6250)

* Add whole-database migration to shared-dictionary Brotli

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.

(cherry picked from commit 09ca170)

* Make docdb-studio's Content reads/writes dictionary-aware

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.

(cherry picked from commit 97755b1)

* Parallelize the whole-database migration's read+compress phase

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.

(cherry picked from commit 2827bfb)

* ADFA-5153: Address review findings on the dictionary migration

Every finding from the three reviews on PR #26 that lands in files this PR
touches, plus the one dictionary-consistency problem outside it that this PR
itself creates.

Data loss, both silent:

* The migration deleted and re-inserted each base row. Content carries
  AddBook/DeleteBook triggers on '%.pdf' paths, so that cycle replaced every
  curated Bookshelf entry with 'CURRENT_TIMESTAMP || id' under a fresh
  Content.id -- verified on the real database: (53507, category 5, "Android
  Notes for Professionals") became (53508, category NULL, "2026-08-21
  22:37:5553508"). 15 brotli-typed .pdf rows and all 7 Bookshelf rows are in
  scope. Writes are now UPDATE in place, with continuation rows reconciled by
  exact path.

* delete_content interpolated a path straight into LIKE, where `_` is a
  wildcard and the `-%` suffix was not restricted to digits, so unrelated rows
  could be deleted permanently (hal-eisen-adfa). No write path goes through
  LIKE any more.

Rows silently skipped while the run reported success:

* reassemble_content probed "<path>-1", so an ADFA-5171 chain numbered from -2
  reassembled truncated, failed to decode, and was counted as "already
  dictionary-compressed". The corpus has 29,751 base rows and exactly 3 with
  continuations; the run reported 29,748 migrated and 3 already-migrated in a
  first-ever migration, which is precisely those 3. Chain discovery is now
  shared with the repair script (populate_db.fragment_chain), so the two
  cannot drift apart again.

* Any decode failure counted as "already migrated" (hal-eisen-adfa). Rows are
  now classified by decoding both ways: identical either way means the encoder
  never referenced the dictionary and there is nothing to gain (~0.5% of the
  real corpus, and the reason a second run used to re-migrate them --
  alexmmiller); plain-only means migrate; dictionary-only means done; neither
  is an error, never a success.

* Recompressed bytes are verified to round-trip before being written.

Concurrency and memory:

* Each worker opened its own read connection while the caller held one write
  transaction over the whole run, which deadlocks under journal_mode=delete --
  documentation.db's actual mode (alexmmiller). All database access is now on
  the calling thread; workers receive bytes. Commits are batched, so an
  interrupted run keeps finished batches and resumes.

* Blobs are no longer selected for every row up front (~130 MB held at once).

Dictionary training, measured on the real corpus with only the sampling varied:

    first 300 rows by path (all under "a/")        36.2% smaller than plain
    300 rows stratified across doc sets            33.2%  <- worse
    stratified, 32 MiB plaintext budget            48.3%  <- best
    first-by-path, same 32 MiB budget              36.4%  <- volume alone: nil

The docstring promised "a random sample drawn across the WHOLE Content table"
and delivered the first 300 paths alphabetically -- 299 of them under "a/",
while j/ (10,326 rows) and k/ (3,757) trained nothing (hal-eisen-adfa). Fixing
it by stratifying alone makes things worse: quotas drawn from smaller doc sets
starve the trainer, which then cannot even fill a 256 KiB dictionary. Both
halves are needed, so sampling is now stratified by stored bytes and bounded
by a plaintext budget, seeded for reproducibility since a stored dictionary is
never retrained.

renumber_misnumbered_fragments:

* A chain numbered from -0 passed the "starts at 1?" guard and renamed onto an
  occupied slot, tripping UNIQUE(path) and rolling back every other repair in
  the pass (hal-eisen-adfa). Such a chain is repaired rather than skipped -- the
  app probes "-1", finds it, and serves the chain with "-0" dropped -- via a
  parking pass that is correct in either shift direction.

docdb-studio:

* sqlite3.OperationalError covers "database is locked", and caching that as
  "no dictionary" downgraded the whole session to plain Brotli
  (hal-eisen-adfa). Only definitive answers are cached now.

* The new `brotli` CLI dependency raised RuntimeError/OSError out of paths that
  guard only `brotli.error` (hal-eisen-adfa). Missing-binary now raises a
  BrotliCliMissing subclass of brotli.error, with an actionable message.

* decompress_brotli decoded dictionary-only, so it could not read plain rows --
  which a dictionary database always contains: anything a plugin contributes
  on-device, anything written outside populate_db.py, and everything mid-
  migration. It now falls back to a plain decode, as WebServer.kt does.

sync_kdoc_json_to_db (outside this PR's diff, but this PR is what makes
documentation.db a dictionary database):

* compress_for used plain brotli.compress, leaving every k/kotlin-stdlib row
  plain inside a dictionary database (hal-eisen-adfa). It now compresses
  against the database's dictionary when there is one.

* "Source file missing => delete the row" had no floor: a Dokka layout change
  makes every lookup miss, and the script would delete every stdlib row plus
  its parent Tooltips and exit 0 (hal-eisen-adfa). Sources are resolved up
  front and a wholesale miss aborts.

Corrected in populate_db's DictionaryCompressor docstring, because two reviews
reasoned from it: the two mismatch directions are not alike. Decoding a
dictionary row with NO dictionary is loud (398 of 400 real rows raised, 2
returned identical bytes, none wrong), which is what makes both this script's
idempotency check and WebServer.kt's fallback sound. Decoding with the WRONG
dictionary is the silent case (50% raised, 38% returned different bytes with no
error, 12% identical). The test asserting a wrong-dictionary decode does not
raise was asserting that coin flip; it now asserts the invariant that holds.

Tests: 25 in ProcessKotlinWebsiteJSON (up from 21) and 173 in docdb-studio (up
from 170) pass. New coverage for the -2 chain, an undecodable row, Bookshelf
survival through the triggers, a never-referenced-dictionary row across two
runs, stratified sample determinism and spread, zero-based renumbering, one bad
chain not blocking other repairs, a locked database not being cached, a plain
row in a dictionary database, and a missing brotli CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 838ac44)

* ADFA-5153: Route the last LIKE delete through fragment_chain, declare brotli

Both from hal-eisen-adfa's follow-up review of 838ac44.

insert_optimized_media.delete_content still built the LIKE pattern the
migration script had stopped using: `path = ? OR path LIKE '<path>-%'`, where
`_` is a single-character wildcard and the suffix is not constrained to digits.
Rows matched that way are never re-inserted, so the loss is permanent. It now
deletes the base row by exact path and each continuation by the exact paths
populate_db.fragment_chain returns, which does the over-matching query once and
re-checks every candidate's parsed suffix. The claim "no write path constructs
a LIKE pattern any more" is now true of the whole tree, not just one file.

The `brotli` CLI became a required external binary in three independent paths
(populate_db's DictionaryCompressor, sync_kdoc_json_to_db, docdb_studio) and
nothing declared it. The Python `brotli` package the README asks for is a
different artifact and exposes no custom-dictionary parameter, which is exactly
why the CLI is unavoidable -- and what makes `pip install brotli` read as though
it covers this. Declared in the four places that would tell someone:

  * build-kotlin-docs.yaml's apt-get line -- it runs populate_db,
    insert_optimized_media and sync_kdoc_json_to_db.
  * docdb-regression-test.yaml's apt-get line -- it runs docdb-studio against
    the downloaded production database, which is now a dictionary database, so
    its reads need the binary too. (CI previously depended on whatever the
    runner image happened to ship.)
  * ProcessKotlinWebsiteJSON/README.md, beside the existing `pngquant on PATH`
    bullet, spelling out that this is the CLI and not the Python package.
  * docdb-studio/README.md, noting `uv sync` cannot install it and that a
    database with no CompressionDictionary needs nothing extra.

publish-doc-db.yaml is deliberately untouched: it runs the scripts/ingest.py
pipeline, which does not reach for the CLI.

Tests unchanged and passing: 25 in ProcessKotlinWebsiteJSON, 173 in
docdb-studio. Both workflow files still parse as YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4d4f37d)

* ADFA-5153: Add the dictionary re-mint tooling used on the 21-Aug database

remint_dictionary.py trains a new shared dictionary for an already-migrated
database and recompresses every 'brotli' row against it in one transaction,
replacing the CompressionDictionary row. verify_remint_dictionary.py is the
read-only gate: it decodes every row out of both databases and requires the
plaintexts to match, exiting non-zero otherwise.

These deliberately do what load_or_create_dictionary refuses to do, and the
refusal is right for the pipeline: replacing a stored dictionary without
recompressing the content orphans every row, since the dictionary decode fails
and the plain fallback fails too. The only safe way to change a dictionary is to
change the content with it, atomically, which is what this pair is for. Either
every row converts and the dictionary is replaced, or nothing is written.

Why it is worth having: the dictionary a database is first minted with is
permanent for its content, so a poorly-sampled one stays expensive forever.
Re-minting the 21-Aug database with the stratified, byte-budgeted sampler took
its brotli content from 83.4 MiB to 65.6 MiB and the vacuumed file from 268 MB
to 249 MB -- 18 MB -- with all 29,677 items verified byte-identical, and the
result confirmed on device: pages served at their original byte counts through
brotli4j, whose attachDictionary had never seen this dictionary before.

The verifier is not ceremony. A row recompressed against a mismatched
dictionary decodes with no error into *different* bytes 38% of the time
(50% raises, 12% is identical because the perturbed region was never
referenced), so nothing at runtime detects it and the check has to happen
against the original before the file is put in place.

collect_training_samples now takes an optional decoder, defaulting to plain
Brotli. A re-mint's rows are dictionary-compressed, so it passes one that reads
against the outgoing dictionary and falls back to plain -- the fallback is
required, not defensive, because a dictionary database always holds some plain
rows. read_item, write_item and load_base_rows are reused from the migration
script rather than copied, which is what keeps the in-place write (never
DELETE+INSERT on a base row, because of the '%.pdf' triggers) in one place.

Four tests. Two of them exist because writing them corrected me: re-minting
with the same seed and corpus reproduces the stored dictionary byte for byte,
so a test asserting the dictionary changed has to vary the seed -- and an
earlier assertion that the outgoing dictionary can no longer decode a re-minted
row was asserting a coin flip, the same mistake as asserting that a
wrong-dictionary decode raises. The remaining two cover the abort path leaving
the database untouched, and the verifier actually objecting to a corrupted
re-mint rather than passing vacuously.

29 pipeline tests (from 25) and 173 docdb-studio tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4b19f14)

* ADFA-5153: Tell Windows users how to install the brotli CLI, and mean it

docdb-studio's README named `apt` and `brew` and left Windows users with
nothing, on the one dependency `uv sync` cannot install for them. It now has a
section of its own, following the per-OS shape the uv instructions already use:
winget, scoop and choco, each preceded by the matching `search` command so a
renamed package cannot strand the reader, plus MSYS2 for anyone who already has
Git for Windows. Then the two things that actually go wrong on Windows: a
changed PATH is only visible in newly-opened terminals, and a package manager
can install the binary somewhere that is not on PATH at all -- so `where.exe
brotli`, the usual shim directories, and where to edit PATH.

It also states plainly that the `brotli` in `uv sync` is a different artifact
from the `brotli` program, since `pip install brotli` succeeding is exactly what
makes this confusing, and doubly so on Windows where there is no `brotli.exe`
afterwards.

Writing that section exposed a real defect in the BrotliCliMissing handling from
838ac44. Subclassing brotli.error kept a missing binary from escaping as an
unhandled RuntimeError, which is what the review asked for -- but the two call
sites catch brotli.error and return []/None, so the failure became a blank
preview with nothing said anywhere. A corrupt row and a missing binary are not
the same event: one is a single bad row, the other means nothing in this
database will ever decode and is fixable in one command. The call sites now
catch BrotliCliMissing separately and print which path failed and why, and the
exception's message points at the README rather than listing two Unix package
managers. The README says what actually happens -- blank preview plus an
explanatory error in the launching terminal -- rather than claiming the UI
reports it.

174 docdb-studio tests pass (from 173); the new one asserts both call sites log
rather than swallow, and that the message names the path and points at the
README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 25d284f)

---------

Co-authored-by: David Schachter <davidschachter@appdevforall.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
alexmmiller pushed a commit that referenced this pull request Aug 25, 2026
Validated against ~/documentation.db (schema 2.0.0), now the source of truth.

populate_db.py
 - A failed .md conversion left its stem in topic_index_db, so nav rendered
   an ordinary, normally-styled link to a page the run had just deleted and
   not replaced. Drop the stem (matching the blacklist path, so references
   render as styled-broken) and refuse to modify the database at all unless
   --allow-conversion-failures is passed - CI uploads this database straight
   to production.
 - Two same-stem .md files in different topics/ subdirectories both mapped to
   k/html/<stem>, colliding on Content.path's UNIQUE constraint and aborting
   the transaction mid-run. Defer to the keep-first choice build_topic_index
   already makes and warns about.
 - image_index_db keyed on the full zip entry name while Converter looks
   images up by bare filename, so any nested zip entry would silently resolve
   as a missing image. Key on the basename, matching Converter and
   insert_optimized_media.py's own flattening, and warn on collisions.

insert_optimized_media.py
 - delete_unreferenced_media deleted every image no page referenced, with no
   floor check: run against a database whose k/html pages don't exist yet and
   it wiped the entire image corpus, including rows inserted seconds earlier
   in the same transaction. Raise instead when images are stored but nothing
   references any of them, and document that CSS/template references are not
   scanned.
 - Added --dry-run (the most destructive of the three scripts was the only
   one without one): does the whole run, then rolls back.
 - Moved the renamed-away delete loop above the insert loop. With inserts
   first, a rename whose new name equals another rename's old name deleted
   the row just written - the chain-rename hazard rewrite_pages already
   guards against for text substitution.
 - delete_content built a LIKE pattern from a path without escaping, so "_"
   and "%" acted as wildcards; NAV_CONTENT_PATH ("k/html/_nav.html") already
   contains one. Escape via a new like_escape() and ESCAPE '\'.

sync_kdoc_json_to_db.py
 - Wrote plain Brotli into a database whose every brotli row is compressed
   against the shared CompressionDictionary (schema 2.0.0, ADFA-5153),
   producing content the server cannot decode. Read the dictionary and
   compress against it, falling back to plain Brotli only for older
   databases; never create or retrain one. Needs the brotli CLI, now
   installed in both workflows.
 - Ignored the CHUNK_SIZE fragmentation contract: UPDATEd the full blob into
   one row and deleted existing fragments individually. Split oversized
   results into "<path>-N" continuations the way populate_db.py does, and
   treat existing fragments as part of their base row.
 - An unresolvable contentTypeID fell back to "uncompressed" and committed,
   writing bytes that contradict the row's declared type. Now fatal.
 - Backup used shutil.copy2; switched to VACUUM INTO, matching the other two
   scripts and safe against a live database.

Also: corrected the now-stale claims that documentation.db ships without an
image/webp ContentTypes row (it has one, id 26) and that scour/cairosvg are
absent from requirements.txt; gitignored the timestamped *.db.backup-*/
*.db.bak.* files the three scripts write.

CLAUDE.md records the one review finding NOT fixed here: populate_db.py and
insert_optimized_media.py are still plain-Brotli and so broken against a
2.0.0 database. That fix already exists on fix/ADFA-4737 via merged PRs #26
and #27; reconciling with that branch is the right way to pick it up rather
than hand-porting it into a conflict.

Adds 30 regression tests covering each fix, including a dictionary
round-trip. Verified end-to-end on a copy of ~/documentation.db: 3,238 rows
rewritten, 12/12 sampled rows decode against the dictionary, untouched rows
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alexmmiller pushed a commit that referenced this pull request Aug 25, 2026
…line)

Brings in the ADFA-5153/ADFA-5171 work merged to fix/ADFA-4737 via PRs #26
and #27, which this branch forked from #21 too early to receive. Without it
the pipeline cannot run against the current production database at all:
~/documentation.db is schema 2.0.0, every "brotli" Content row is compressed
against the shared 256 KiB raw LZ77 dictionary in CompressionDictionary, and
plain Brotli cannot decode any of it (measured: 0 of 24 sampled rows).

Conflict resolution - all twelve were add/add, so each was decided per file
rather than 3-way merged:

Took theirs (the dictionary lineage is strictly ahead on these three), then
re-applied this branch's review fixes on top:
 - populate_db.py: DictionaryCompressor, train/load_or_create_dictionary,
   fragment_chain, page_size pinning. Re-applied the conversion-failure
   abort, the same-stem dedupe, and the basename-keyed image index.
 - insert_optimized_media.py: dictionary-aware reads/writes. Re-applied the
   delete_unreferenced_media floor check, the delete-before-insert ordering,
   and --dry-run.
 - sync_kdoc_json_to_db.py: DictionaryBrotli, load_compression_dictionary,
   MAX_DELETE_FRACTION. Re-applied CHUNK_SIZE fragmentation, the fatal
   unknown-contentTypeID, and the VACUUM INTO backup.

Took ours (PR #23/#24 refined these after the split): md_to_json.py,
find_missing_assets.py, optimize_media.py, assets/docs.css, README.md,
run_e2e_pipeline_test.sh, .gitignore.

Hand-merged: build-kotlin-docs.yaml (our corrected requirements/webp comments
plus their brotli-CLI rationale); CLAUDE.md (ours, with the 2.0.0 blocker note
rewritten as a description of how the three writers now handle the dictionary,
since the merge resolves it).

Two of this branch's own fixes were dropped as superseded:
 - like_escape/ESCAPE '\' is replaced by fragment_chain, which does the
   over-matching LIKE once and re-checks each candidate's digit suffix. That
   also handles ADFA-5171 chains numbered from -2, which escaping does not.
   sync_kdoc_json_to_db.fragment_paths was rewritten to match rather than
   probing "-1" and stopping at the first gap.
 - The hand-rolled DictionaryCompressor added to the sync script last commit
   is replaced by theirs.

Tests updated for the merged APIs (collect_referenced_media and
delete_unreferenced_media now take a compressor; DictionaryBrotli is
compress-only, so its tests decode through the brotli CLI). 105 pass: 78 in
ProcessKotlinWebsiteJSON, 27 in scripts/sync_kotlin_stdlib_docs.

Verified against a copy of ~/documentation.db: 3,238 stdlib rows rewritten,
12/12 sampled decode against the dictionary, untouched trees unaffected, row
count unchanged at 30,649.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants