# Changelog All notable changes to pg_fts are documented here. ## 1.16.0 - 2026-11-04 A minor release: correctness fixes on the on-page decode path, found by the first formal proofs of pg_fts's code (CBMC, `test/cbmc`), and the two changes held back from 1.15.2 because they change execution (the collect-path skip for an operand of plain-term readings, Codeberg #6) and maintenance (`fts_vacuum` now rewrites a single packed segment that carries tombstones). Also a per-backend memo that takes most of the per-scan cost out of ranked queries on tombstoned segments, and sparsemap 5.8.4. No SQL object changes, no on-disk format change, no REINDEX (`ALTER EXTENSION pg_fts UPDATE TO '1.16.0'`). ### Fixed - **A corrupt FOR width byte in a posting block made a ranked query read past the block** (every release; found by the new CBMC proofs, `test/cbmc`). The loader under every ranked traversal (`wand_load_block`) checked that a block's declared lengths fit the page, then decoded columns whose sizes come from their own width bytes, never compared with the block's length. `count(*)` (`bm25_decode_term`) had checked this since 0.3.5. Reproduced on PG 17.10 by setting the docid width byte of a full block from 8 to 9 or 64 with the server down (`t/003_corruption.pl`): width 9 decoded garbage docids from the block's own bytes, so the ranked answer disagreed with `count(*)` (an assert-enabled ASan build aborted on an invalid TID instead); width 64 read 879 bytes past the copy and failed with "could not read blocks" (12 and 20 failed the same way). The block now ends the cursor's chain, as the page-fit check already did, and both paths agree. The same class, fixed the same way: - the doclen sidecar readers (`bm25_doclen_build_slots`, `bm25_doclen_cursor_load_page`, since 1.5.0) checked that a block fits the page but not that its gap column fits its declared `gapbytes`; - `bm25_decode_term`'s own column check read the tf column's width byte one byte past the block when the docid column filled it exactly; - five dictionary walks (the posting-chain head lookup when a segment is freed, the tombstone rebuild in VACUUM, both `fts_anomalous_docs` walks, the trigram-ordinal walk) did not call `bm25_dict_entry_fits`, and the trigram walk read `pd_lower` without `bm25_page_data_end`. One shared check (`bm25_for_cols_fit` in `pg_fts_for.h`) now sizes every column from its width byte before any decode, and reads each width byte only after the bytes before it are proved to fit. On every layout the writer produces (all counts 1-128 and all column widths, 35.7M layouts) it accepts the block and detects the inline doclen column exactly as the old check did. The ranked loader now also detects that column per block, as `bm25_decode_term` always did: a two-column block in a segment with no doclen sidecar (merged by 1.5.0-1.5.2, CHANGELOG 1.5.3) used to read a "doclen" past the block; it now scores each posting with the block's minimum length, the length its block bound assumes, so pruning stays sound. 1.5.3's advice to REINDEX such segments stands. Also: the trigram directory walks size each entry and the trigram query walk validates `pd_lower`; the fuzzy dead-prefix lookahead checks the next dictionary entry fits before comparing it. - **`fts_vacuum` now drops the tombstones of an index that is one packed segment** (ROADMAP I25; the 1.15.2 known issue, and the 2026-11-02 report's steps E and F). It treated "one front-packed segment" as fully compacted, so after a bulk DELETE + VACUUM it returned false, rewrote nothing, and the tombstones stayed until a REINDEX. Under `fts_vacuum`'s AccessExclusiveLock, a segment that carries tombstones now gets one rewrite, which drops every tombstoned document, and `fts_vacuum` returns true. VACUUM's own cleanup does not do this: it would rewrite a whole segment for a few deletes on every cycle. `fts_merge` is unchanged. Measured on c6id.metal, PG 18.6: - the report's repro (1.88M rows, 23,313 moves; this run also had the memo below): `fts_vacuum` returned t in 69 s, against 136 s for REINDEX; the index went from 735 to 728 MB; top-200 21.7 -> 13.9 ms (fresh 13.9), `bestfirst = off` 9.6 -> 2.0 ms (2.1); - the churn harness (1.9M rows, 23,750 moves): `fts_vacuum` 88.6 s, against 183.7 s for REINDEX; top-200 63.8 -> 53.6 ms (fresh 54.1), top-10 3.54 -> 0.34 ms (0.34). `ranked_tombstones` checks that it returns t on such an index and f on a second call (f on 1.15.2), and that the ranked results match the docid-order walk with no deleted row ranked. - **Page guards no longer form a pointer past the page from an on-disk integer.** `stream + bytelen + posbytelen > pend`, `t + termlen <= end` and `p + sizeof(header) <= end` after a MAXALIGN step are undefined behaviour once the integer is large, even when the comparison would reject it (AGENTS.md rule 16). They now compare lengths (`bm25_fits`, `bm25_dict_entry_fits`). Found by CBMC's pointer-overflow check; no misbehaviour was observed. - **The block-max WAND bound could be one ulp below the score it bounds** (every release). `wand_block_max_contrib` computed `idf * mtf * (k1 + 1)` where the score is `(idf * (k1 + 1)) * tf`; floating-point multiplication is not associative, so for a block's own (max tf, min length) posting the bound was one ulp low in about 0.02% of random (corpus, block) shapes (462 of 3M), found while writing `test/cbmc/bound_harness.c`. A skip needs the threshold to sit exactly on that bound, and no such tie was found in 20,000 random corpora, so no wrong top-k has been observed; the bound now uses the score's operation order, and the term-wide bound too. The best-first bound already did. ### Added - **CBMC proofs** (`test/cbmc`, `bash test/cbmc/run.sh`): bounded model checking of the real code over every input within a bound, each with a planted historical bug that must be caught. See `test/cbmc/README.md` for what each proves and its bound: `fts_search_bmw` (copied verbatim) returns the brute-force top-k (`fts_search_maxscore` is harnessed, and its 1.10.0 bug is caught, but the shipped code had no verdict after 2 hours); BM25 block bounds are >= every score they cover in IEEE doubles (an exhaustive native sweep; the symbolic proof is opt-in); the on-page guards keep every later read in bounds; the query-shape shortcuts (I19a/b, 1.14.3) never drop a match; the FOR codec and doclen byte round-trip; the sparsemap read cursor never changes an answer; the shared-doclen slot protocol has no use after free, double unpin or leak. `check_verbatim.py` fails the run when a copied function drifts from the source. - `t/003_corruption.pl`: the corrupt-width-byte case above (fails on 1.15.2), and a two-column segment without a sidecar (the 1.5.0-1.5.2 merge layout): ranked finds every match. ### Changed - **sparsemap 5.8.2 -> 5.8.4** (`vendor/sm.c`, `vendor/sm.h`), a drop-in source swap (no API, ABI or wire-format change; serialized maps are byte-identical). The vendored copy already carried the 5.8.3 `sm_validate` / `sm_open` bounds fixes and the 5.8.4 cursor gap resume (ROADMAP I25); what is new here: `sm_add_range` no longer reads 8 bytes past its scratch buffer (5.8.4), `sm_union` / `sm_intersection` emit chunk-aligned RLE and no longer re-emit a sparse chunk's tail (5.8.3), and small-set maps and the SIMD kernels are alignment-safe on a misaligned buffer (5.8.3). pg_fts calls neither set operation nor `sm_add_range`. Its bulk insert (`sm_add_many_grow`) reaches the coalescing code the 5.8.4 read fix is in, but builds into a buffer with slack, and the over-read needed an exactly full one, so no pg_fts effect is known. One unused helper the old copy carried is gone. The `__pg_bm25_` symbol prefix is unchanged. - **Tombstoned segments: a per-backend memo of blob validation and seek checkpoints** (ROADMAP I25). After 1.15.2, most of a ranked top-10 on a segment with 23k scattered tombstones was per-scan setup: validating the tombstone blob and building its checkpoints. Both depend only on the stored blob, which does not change while the metapage generation stands, so a backend now does that work once per (index, generation, segment). Each scan still reads its own copy of the blob. Measured on c6id.metal, PG 18.6, 1.9M rows, 23,750 scattered tombstones on one segment, 12 interleaved samples per build against an A/A pair (`bench/RESULTS_CHURN_2026-11-02.md` section 5): | query | 1.15.2 | with the memo | A/A | |---|---|---|---| | top-10, term in 0.5% of rows | 3.490 ms | 2.020 ms (-42%) | +0.01% | | top-200, 1% term | 14.41 ms | 12.95 ms (-10%) | -0.08% | | top-200, 6% term | 63.7 ms | 62.2 ms (-2.4%) | -0.07% | The results are identical. The first query in a backend pays the setup as before. - **The collect path skips a segment when an AND operand of plain-term readings is all absent there** (ROADMAP I19b, Codeberg #6). 1.15.0 skipped a segment only for a single plain required term with no posting in it; an operand that is an OR of plain terms, the shape an analyzer gives a word with two lemmas (`(zzzq | zzzx) & kur:*`), still built the other operands. The reporter measured 17.7 ms for that against 0.35 ms for `zzzq & kur:*` on 1.15.1. Now an operand made only of plain terms joined by OR is empty in a segment when every term is absent; the lookups stop at the first present term. Measured on c6id.metal, ranked top-10, warm, 12 samples per build (`bench/RESULTS_I23_2026-11-01.md`): `(zzzq | zzzx) & (apple | ban:*)` on the reporter's 400k-row corpus 3.792 -> 0.076 ms (PG 17.10) and 3.794 -> 0.078 ms (PG 18.6); the same shape on a 640k-row, 60-token-per-row index 618.3 -> 0.076 ms (buffers 3,695 -> 22), results identical. Not changed: an operand with a present reading (`(zzzq | build) & ...`, 621 ms), which still builds the group. Regression checks in `ranked_driven_and` compare index buffers with the single-term form and check results against the seq-scan oracle, including a reading present only in later segments and pending rows, and two shapes (a NOT, a prefix) the skip must not take. ### Not reproduced - **A pure OR 5-8% slower than on 1.14.3** (Codeberg #6: `build | (10 terms)` on a 640k-row corpus, 519-547 ms on 1.14.3 and 557-582 ms on 1.15.1). On c6id.metal, with the `.so` swapped under one index as the reporter did, a 10-term OR differs from 1.14.3 by +0.04% (400k rows, PG 17.10), +0.25% (PG 18.6), -0.45% and +0.28% (640k rows, 98 and 41 ms queries, PG 18.6), against an A/A spread (the same tree built twice) of up to 0.21%; index buffers on the 400k corpus are 1,779 on both (`bench/RESULTS_I23_2026-11-01.md`). 1.15.x adds no per-posting work to a pure OR (the I19a check needs a gate, which a pure OR does not build). ROADMAP I23 stays open for the reporter's environment. ## 1.15.2 - 2026-11-03 Two fixes for ranked queries. One is a reported 60-300x slowdown after a bulk delete (ROADMAP I25). The other is a wrong top-k from the block-max WAND path that every release since 1.3.0 has had (ROADMAP I26), found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork. **No on-disk format change; no REINDEX; no new or changed SQL objects** (`pg_fts--1.15.1--1.15.2.sql` is a no-op). Measured on PostgreSQL 18.6 and gated on 17.10 and 18.6 (`bench/RESULTS_CHURN_2026-11-02.md`, `bench/RESULTS_152_2026-11-03.md`). ### Fixed - **Ranked top-k became 60-300x slower after deletes scattered over a large heap, until a REINDEX** (reported 2026-11-02 from a production index, 1.88M rows, after moving 23,313 rows and an `fts_vacuum`). The heap VACUUM that follows tombstones the deleted rows on the packed segment, and `fts_vacuum` keeps a single packed segment as it is, so the tombstones stay until a merge rewrites it. Each scored posting is checked against the segment's tombstone set, a sparsemap that holds about one chunk per tombstone when the deleted rows are spread across the heap. Two lookup costs grew with the chunk count: - The sparsemap cursor ignored a cached chunk that starts after the looked-up index, which is where a lookup lands when it falls in the gap before that chunk -- nearly every live docid. Every such lookup walked the chunk list from the start (fixed in the vendored `sm.c`; the cursor now resumes from the chunk before, after checking it is that chunk's neighbour). - The best-first paths visit posting blocks out of docid order and reset the cursor per block, so each visited block walked the chunk list from the start. They now seek from per-scan checkpoints: up to 1,024 tombstones spaced evenly by docid, each with a cursor on it. - `fts_anomalous_docs` checked tombstones with no cursor at all (one walk per posting); it now keeps one per term. Measured on c6id.metal, PG 18.6, a 1.9M-row `doclen_sidecar = off` index (about 150 words a row) after moving every 80th row and the same fts_vacuum / VACUUM order (`bench/RESULTS_CHURN_2026-11-02.md`): ranked top-200 of a term in 6% of rows 3,640 -> 64 ms (fresh build 54 ms), top-200 of a 1% term 238 -> 14.5 ms (10.3), top-10 of a 0.5% term 62 -> 3.5 ms (0.35); the top-200 ids of the common term are identical across builds at every step. What is left of the top-10 cost is per scan: validating the tombstone blob (25% of its profile) and building the checkpoints (about a third). The reporter's own repro script (1.88M rows, 23,313 moves, the same order), run unchanged on c6id.metal: `fts_search(..., 'remov', 200)` after the VACUUM 7,719 -> 23 ms (fresh 14 ms), `'vacuum'` 3,308 -> 13 ms (7.0), with `pg_fts.bestfirst = off` 7,552 -> 10 ms (2.1), and with three segments and no pack 7,942 -> 23 ms; a 4-term OR top-200 through the executor 111.6 s -> 162 ms. Workaround on 1.15.1 and earlier: REINDEX after a bulk delete (measured). Known issue in this release too: `fts_vacuum` and `fts_merge` return false and rewrite nothing when the index is one packed segment, so its tombstones stay until a REINDEX or a later merge (ROADMAP I25); they now cost about 1.7x a fresh build rather than 300x. New regression test `ranked_tombstones`: scattered deletes, the freed slots reused by rows without the query terms, ranked single-term / AND / OR shapes against a seq-scan oracle and the docid-order walk (6 of its 12 shapes fail when the WAND paths skip the tombstone check). - **Ranked queries could return the wrong top-k from the block-max WAND path** (since 1.3.0). When the current blocks of the cursors at or before the pivot could not beat the threshold, the skip moved them past the pivot: 1.3.0-1.10.0 jumped to `pivot + 1`, and 1.11.0 jumped to the earliest current block end but clamped that to `pivot + 1` "to make progress". When a cursor's current block ends before the pivot, a later block of that term can score the pivot higher than the current bound, so the pivot was skipped unscored. The jump now stops at that block end; the cursor sits at or below it, so it still moves. Found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork (e4bba08, never submitted upstream); his regression test `bmw_block_end` returns id 1 as the top-1 of `aye | bee` (the exact answer is 5000) on 1.3.0, 1.10.0, 1.11.0, 1.13.0 and 1.15.1, and passes here (PG 17.10, 18.6). The path is taken by a ranked query whose WAND walk has two or three cursors (one per term per segment) and that takes none of the best-first or driven paths: for example a two- or three-term OR on one segment, or such a query with a phrase / prefix / fuzzy / regex filter. On a 640k-row index, pure 2- and 3-term ORs are 0.3-2.4% slower (A/A up to 1.7%); filtered queries unmeasured. ### Retracted - 1.13.0 said of its block-max WAND fixes: "No query found a wrong top-k on 1.12.1, whose best-first paths already avoid both cases." The docid-order block-max WAND path still skipped a top-k document in the `safe_until` case (above): 1.3.0 through 1.15.1 return a wrong top-1 for `bmw_block_end`. 1.3.0 described the skip that introduced it as "removing a provably-unsound (though not field-reachable) over-skip". ## 1.15.1 - 2026-10-29 One fix, for a defect in 1.15.0's new configured-analyzer path (ROADMAP I9a), found the day 1.15.0 shipped by comparing its output with an unchunked parser. **No on-disk format change; no new or changed SQL objects** (`pg_fts--1.15.0--1.15.1.sql` is a no-op). Probe comparison on PostgreSQL 17.10; gate on 17.10 and 18.6 (`bench/RESULTS_151_2026-10-29.md`). ### Fixed - **The chunked re-parse split HTML markup at chunk boundaries** (ROADMAP I9a). Affects `to_ftsdoc(config, text)` on documents over 16,383 positions, since 1.15.0. The re-parse cut the text into chunks of at most 16,383 bytes, each after a whitespace, so a tag, comment or `