ADFA-5153: Compress Content table with a trained Brotli dictionary - #26
Conversation
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.
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>
|
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 The real pipeline is this branch's cc @alexmmiller since this builds on your |
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>
|
Found and fixed the identical bug in Rewritten on 17/17 local tests pass ( Note: this branch predates PR #25's |
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>
|
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:
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>
|
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>
|
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 |
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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'sbase_rows[:sample_size]over rowsORDER 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_runwith noalways().dry_rundefaults 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_containerkeeps onlytype == "tab"children, so an intro paragraph before the first<tab>is dropped from the JSON with no warning.build_nav.py:64— unsortedrglobwith last-wins, versusmd_to_json.build_topic_index's sorted/first-wins, makesnav.jsonnon-reproducible across machines when two stems collide.build-stdlib-json-docs.sh:57— underset -euo pipefailtheDOKKA_VERSION="$(grep ... | sed ...)"assignment aborts on grep failure, making theif [ -z "$DOKKA_VERSION" ]diagnostic on lines 58-61 unreachable.run_e2e_pipeline_test.sh:105—require_pathvalidates user-supplied paths against the invoking cwd, then the step-2 subshellcds to$PROCESS_DIRbefore passing them through; thecdbuys nothing, sincepopulate_db.pyresolves its assets viaPath(__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:114—fetch(navSrc)never checksresponse.ok, so a 404 body gets injected into the sidebar as nav markup and.catchnever fires.
davidschachterADFA
left a comment
There was a problem hiding this comment.
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:
chain_fragmentsin the sibling script already discovers a chain correctly — LIKE query plus parsed suffix, agnostic to where the numbering starts.reassemble_contentshould use that approach rather than keeping a second, more fragile convention in the same PR.- 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); END15 .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
left a comment
There was a problem hiding this comment.
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:
- 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.
- It moves the severity of the CRITICAL above.
sync_kdoc_json_to_db.pywriting 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.decompressBrotlitries the dictionary, gets a hardIOException, 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 invariantpopulate_db.pyclaims 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>
|
Pushed 838ac44, which addresses every review finding that lands in files this PR touches, plus the two on The two that were destroying dataThe Bookshelf. The migration deleted and re-inserted each base row, and Silently skipped rows. Verified end to endAgainst the real 256 MB database, Tests: 25 in Dictionary trainingMeasured with only the sampling varied, since @hal-eisen-adfa flagged the selection and the naive fix turns out to be a trap:
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 correctedBoth reviews reasoned from 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 ( |
… 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>
* 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>
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>
…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>
Summary
Contentrows against it via thebrotliCLI's-Dflag (the installed Pythonbrotlipackage has no dictionary API). Stored in a new single-rowCompressionDictionarytable insidedocumentation.dbitself, 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).migrate_content_to_dictionary_brotli.py: one-time, idempotent, whole-database migration for everyContentrow 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 aspopulate_db.py, with a graceful fallback for a database with no dictionary yet.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 viadocdb-studio) — all decode correctly.Companion PR: appdevforall/CodeOnTheGo#1677 (WebServer.kt read-side).
Note:
scripts/DocumentationDatabase.pylooks obsolete against the current schema (its own schema-conformance check would reject this database outright — notemplateId/Templatesawareness) — 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 unittestacross both new test files — 21/21 passmigrate_content_to_dictionary_brotli.pyagainst the realdocumentation.db, verified idempotent (second run: 0 migrated, all already-dictionary-compressed)docdb-studio's fixed read paths