Contents
Biscuit Index Extension – Changelog
Version 3.1.0
Adds regular-expression support for the subset of patterns that can be
rewritten exactly as a LIKE glob, fixes several correctness bugs that
predate this release, and replaces the previous test scripts with a
differential test suite.
No REINDEX is required. There is no on-disk format change: the rewrite
happens entirely at plan time and the existing structures answer the resulting
glob unchanged. Existing 3.0.0 indexes gain regex support as soon as the
extension is updated.
New Features
Regular-expression operators
~,!~,~*and!~*(strategies 5–8). Biscuit does not gain a regex engine. A regex qual is decomposed at plan time into an equivalentLIKEglob and then evaluated by the same positional bitmaps that already serveLIKE, so anchored regexes inherit Biscuit’s existing strengths rather than introducing a second matching path.The decomposition is required to be exact — the emitted glob must match precisely the same strings as the regex, no more and no less. Anything that cannot be proven exact is refused rather than approximated. The supported subset is:
Construct Rewritten as ^/$anchorsWhole-string anchoring; a missing anchor becomes %Literal characters, \Xescapes for non-alphanumericXLiteral, with %,_and\escaped._.*/.+%/_%.{n},.{n,},X{n}nrepetitions, with%for the open-ended formTrailing ?(non-greedy marker)Ignored; it never changes the match set Because
~is unanchored whileLIKEis whole-string,~ 'abc'becomesLIKE '%abc%'and~ '^abc$'becomesLIKE 'abc'.Safe handling of regexes outside the subset. Alternation, bracket expressions, groups, unbounded or optional repetition of a literal, bounded
{n,m}ranges,\d/\w/\yclass shorthands, backreferences and embedded-option directives are not decomposable. Such quals remain correct: the key is skipped, the scan reportsxs_recheckso the executor re-evaluates the original regex, andbiscuit_costestimate()disables the path so the planner chooses a sequential scan instead. An unsupported regex therefore costs nothing but the missed optimisation.Operator-class gating for regex, matching
LIKE/ILIKE.~and!~require the case-sensitive structures and are registered only forbiscuit_opsandbiscuit_like_ops;~*and!~*require the case-insensitive structures and are registered only forbiscuit_opsandbiscuit_ilike_ops. An index built with the narrower class is not considered by the planner for the operators it cannot answer.
Bug Fixes
Index builds no longer scan the heap directly.
biscuit_build()used its owntable_beginscan(heap, SnapshotAny, ...)loop; it now goes throughtable_index_build_scan()as every other access method does. The hand-rolled scan skipped four things core performs on an access method’s behalf:- HOT-root mapping. Each tuple version was indexed under its own TID
rather than its HOT chain’s root. A scan for a superseded value therefore
returned a TID whose chain resolves to the live tuple, so the index
reported rows that do not match the predicate. It needed only an
UPDATEperformed while the column was not yet indexed — that is, anyCREATE INDEXon an existing, updated table — andREINDEXdid not repair it, because it re-ran the same scan. - Tuple eligibility. Dead, recently-dead and aborted tuples were indexed
indiscriminately, and
ii_BrokenHotChain— hencepg_index.indcheckxmin— was never set. ii_Predicate. Partial indexes were built over every row, so they returned rows failing their ownWHEREclause. Nothing filtered them out, as the planner had already dropped that qual as implied by the predicate.pg_class.reltuples. The build reported its own slot count, which undercounts on a nullable column because the single-column path allocates no slot for a row whose key is NULL. This skewed every subsequent plan on the table.
- HOT-root mapping. Each tuple version was indexed under its own TID
rather than its HOT chain’s root. A scan for a superseded value therefore
returned a TID whose chain resolves to the live tuple, so the index
reported rows that do not match the predicate. It needed only an
Fixed a use-after-free and double free in the pending-list snapshot.
pendlog_expand_touched()grew the snapshot’s bitmap in whatever memory context the caller happened to be in. On the read path that is the executor’s per-query context, whichExecutorEnd()deletes — while the snapshot itself lives on underCacheMemoryContextin the process-wide slot cache. The next query read the dangling pointer, and the eventual snapshot free released it a second time, typically surfacing as a glibc “double free or corruption” abort duringVACUUM.Fixed anchored patterns losing rows written since the last checkpoint, after crash recovery. An exact-length lookup consulted the base length bitmap but skipped the pending log whenever that bitmap was absent, treating “no base bitmap at this length” as “no row has this length”. Those are not the same statement: a row’s length membership lives in the pending log until a drain folds it in. The base bitmap is only ever absent after the in-memory index is rebuilt from disk, since the length arrays are reconstructed from the last persisted directory and nothing else — so any length first seen after that snapshot came back missing.
-- every value is exactly 5 characters
INSERT INTO t SELECT g, 'row' || lpad((g%50)::text, 2, '0')
FROM generate_series(1, 2000) g;
CREATE INDEX ON t USING biscuit (v);
CHECKPOINT;
INSERT INTO t VALUES (1, 'zzzzzz'); -- new value, new length
-- crash (pg_ctl -m immediate stop), restart, then:
SELECT count(*) FROM t WHERE v LIKE 'zzzzzz'; -- returned 0, correct answer is 1
SELECT count(*) FROM t WHERE v LIKE 'zzzzzz%'; -- returned 1, correct
Prefix and infix patterns were unaffected, because they never consult an
exact-length bitmap — which is why the symptom looked like anchored matching
breaking rather than a length being missing. Nothing errored; rows quietly
stopped being returned. Fixed at all eight exact-length call sites:
single-column and multi-column, LIKE and ILIKE.
- Fixed anchored
LIKE/ILIKEsilently dropping its length constraint. A fully-anchored pattern (one with no%) is evaluated as a positional match intersected with the exact-length bitmap. When the table contained no value of the pattern’s length, that bitmap was absent, and the absent case fell through both branches of the surrounding condition — so the length constraint was skipped entirely and the anchored pattern degraded into a bare prefix match, returning rows that do not match the predicate:
CREATE TABLE t (v text);
INSERT INTO t VALUES ('abc'), ('abcd');
CREATE INDEX ON t USING biscuit (v);
SELECT count(*) FROM t WHERE v LIKE 'a'; -- returned 2, correct answer is 0
An absent length bitmap means no row has that length, so the correct result
is empty. This affects LIKE and ILIKE directly and predates regex
support; it is listed here because it was found while testing the new path,
which reaches the same code through any ^...$ pattern. It only manifests
when the table happens to contain no value of the pattern’s exact length,
which is why it survived earlier testing. The two multi-column code paths
already handled the absent case correctly and were unaffected.
Fixed disabled index paths still being chosen on PostgreSQL 18. Biscuit refuses a qual it cannot serve — a scan with no usable keys, an unusable glob shape, and now a non-decomposable regex — by assigning the path an astronomical cost. Through PG 17 that was sufficient, because a GUC-disabled path simply had
disable_costfolded into its own cost and path choice remained a pure cost comparison. PG 18 (commite2225346) replaced that with adisabled_nodescounter compared before cost, so underenable_seqscan = offthe disabled sequential path lost to the index path no matter how the costs compared. Such paths now incrementdisabled_nodesas well as setting the cost, which composes with the GUC rather than overwriting it. PG 16 and 17 are unaffected and continue to rely on the cost alone.Fixed
ambuildempty()being a no-op, which left unlogged indexes unreadable after a crash. Recovery resets an unlogged relation by copying itsINITfork over the main fork, so an empty init fork produced a zero-length index and the first scan afterwards failed with “could not read block 0 … read only 0 of 8192 bytes”. A valid empty metapage is now written there. See Known Limitations for what this does not yet cover.Fixed
-DHAVE_ROARINGbeing silently dropped from the build.PG_CPPFLAGSwas appended to afterinclude $(PGXS), but PGXS folds it intoCPPFLAGSwith immediate expansion at include time, so the definition never reached the compiler.SHLIB_LINKis expanded lazily and was still honoured, so the build produced an extension that linked against CRoaring while being compiled with the fallback bitmap — it built and ran correctly, but silently without the performance CRoaring was meant to provide. The flags are now set before the include, and the build fails loudly if the two ever disagree again. CRoaring is no longer auto-detected: link it explicitly withmake WITH_ROARING=1.
Internal Changes
- New
biscuit_regex.c/biscuit_regex.hmodule holding the decomposer and the strategy-number helpers. The decomposition is a single left-to-right pass with one atom of lookahead and no backtracking, structured so that every construct outside the subset reaches one rejection point. QueryPredicategainedeffective_strategy,is_lossyandneeds_recheck. Evaluation sites now switch oneffective_strategyrather thanScanKey.sk_strategy, so the rewrite happens once per key inbiscuit_build_query_plan()and no downstream code is regex-aware.BiscuitScanOpaque.needs_recheckthreads through toscan->xs_recheckand the bitmap-scan recheck flag. It is recomputed on everybiscuit_rescan(), so a scan node reused across many outer rows cannot latch the flag on or off.- Regex operators are recognised by looking up the operator in the index
column’s opfamily via
get_op_opfamily_strategy(), rather than by comparing againstOID_TEXT_*macros. The catalog is the same data the SQL script populates, so there is no second operator list to drift out of step. amstrategiesraised from 4 to 8 for the new operators.- A scan whose keys are all non-decomposable regexes seeds its candidate set
from the reconciled live non-NULL row set rather than a raw
[0, num_records)range, which would include never-populated free slots.
Testing
The previous check-sql / check-stress scripts are replaced by a
differential suite under tests/, run with make test. Every case is
evaluated twice against the same rows: once with index paths disabled —
PostgreSQL’s own matching over a sequential scan, used as the oracle — and once
with sequential scans disabled. The two must agree on the row count and on a
fingerprint of which rows came back, since two scans can agree on COUNT(*)
and still return different rows.
Each case also declares whether the access method must serve it, must not, or either, so both failure directions are caught: refusing a supported pattern is a silent performance regression, accepting an unsupported one is a silent wrong answer.
| Category | Covers |
|---|---|
01_like, 02_ilike |
Anchoring, _ placement, escapes, wildcards as data, NULLs, case folding |
03_regex |
The decomposable subset, the rejected constructs, and the rewrite identities |
04_composition |
AND/OR/NOT over globs and regexes, mixed operator families, set algebra |
05_multicolumn |
Multi-column indexes cross-checked against three single-column ones |
06_opclass |
Operator-class gating in both directions, plus catalogue and storage checks |
07_dml_mvcc |
Insert/update/delete after build, savepoints, rollback, VACUUM, TOAST |
08_unicode |
Character-versus-byte positions, 1–4 byte characters, combining sequences |
09_wal |
Crash recovery: pg_ctl -m immediate stop, WAL replay, unlogged relations |
10_stress |
Generated patterns from a fixed seed, straddling the decomposable boundary |
The suite is not wired up as a pg_regress target. Each file asserts
internally and raises on failure, so exit status is the result and there is no
expected-output file to regenerate when a fixture changes. None of the .sql
files contain psql-specific syntax, so they also run through pgAdmin, DBeaver,
JDBC or a migration runner; the only step needing a shell is the crash in the
middle of 09_wal.
make check-wal runs that category alone. It stops and restarts the server
with no clean shutdown, so do not point it at anything you care about.
make test excludes it.
Known Limitations
Only the decomposable subset is accelerated. Alternation, character classes and the other constructs listed above fall back to a sequential scan. Where regular-expression matching is central to a workload,
pg_trgmextracts trigrams from an arbitrary regex and remains the better choice; the two indexes are complementary and can be used together.~*requires a recheck, and!~*is not accelerated. PostgreSQL’s regex case folding andILIKE’slower()-based folding are not the same relation, and they disagree in both directions on characters such asİ,ßand theDž/Lj/Njtitlecase family.~*is therefore decomposed only for pure-ASCII patterns under a collation Biscuit judges safe (see below), which confines the remaining disagreement to the direction whereILIKEover-matches, and those scans setxs_recheckso the executor removes the surplus.!~*is never decomposed, because the complement of an over-matching set omits rows and no recheck can restore them.~and!~are unaffected and remain exact.~*/!~*decomposition is refused under collations it cannot prove safe. Nondeterministic collations are refused outright — PostgreSQL’s regex engine itself does not support them, so this only guards against a future core change. More narrowly, an ICU collation refuses decomposition of any pattern shape that depends on character position (one containing regex., emitted as_): ICU’slower()mapsİ(U+0130) to two characters where the database’s default/libclower()maps it to one, and that length change can shift a_-aligned match out from under a row that~*would otherwise have matched — an under-match thatxs_recheck, being a pure filter, cannot repair. Unanchored, non-positional patterns (LIKE '%literal%'shapes with no.) remain decomposable under ICU, since substring containment does not depend on any other character’s folded length. Deterministic non-ICU collations (the default/libc case covered above) are unaffected.Unanchored regexes inherit the unanchored
LIKEcost model.~ 'abc'decomposes toLIKE '%abc%'and is priced as an infix pattern, which the planner will often decline in favour of a sequential scan. This is existing cost-model behaviour rather than anything specific to regex.An unlogged index must be
REINDEXed after crash recovery.ambuildempty()now writes a valid metapage into theINITfork, so the reset index is readable rather than a zero-length file, but loading it also requires a persisted header blob, and a blob lives in pages the init fork does not contain. A scan therefore fails cleanly with “no on-disk snapshot found for index” and a hint to reindex, rather than failing in the storage layer.REINDEX INDEXfully restores it. Logged indexes are unaffected.
Upgrade Notes
From 3.0.0, no rebuild is needed:
ALTER EXTENSION biscuit UPDATE TO '3.1.0';
Install the new shared library first, since strategies 5–8 begin dispatching to
it as soon as the operators are registered. The upgrade script adds the regex
operators to the existing operator families with ALTER OPERATOR FAMILY, so
indexes built under 3.0.0 pick up regex support immediately and without being
rebuilt.
This release ships only the 3.1.0 install script and the 3.0.0 → 3.1.0 upgrade
script. Upgrading from 2.x therefore goes through a 3.0.0 installation first,
and still requires a REINDEX; see the 3.0.0 notes below.
Rebuilding existing 3.0.0 indexes is not required, but is worth considering on tables that were updated before the index was created: the HOT-root fix above corrects how rows are indexed at build time, and an index built under an earlier version carries whatever that scan recorded until it is rebuilt.
Version 3.0.0
First release integrated with WAL logging. This is a breaking on-disk
format change: indexes built under 2.x must be REINDEXed.
The focus of this release is durability: index state now lives in the index relation’s own WAL-logged pages, participating in PostgreSQL’s ordinary recovery machinery.
New Features
WAL-logged, crash-safe on-disk storage. Replaces the external-file snapshot mechanism from 2.5.0 with in-relation,
GenericXLog-protected page storage covering per-character and length bitmaps, the TID array, tombstones, the free-slot list, and per-record string caches. Index state now survives a crash and replicates correctly.Pending-list write path with opportunistic draining. Steady-state
INSERT,UPDATEandDELETEappend a small delta record to a structure’s own pending chain instead of rewriting a whole snapshot. Once a structure’s pending chain passes a threshold (biscuit.delta_compaction_slots, a new GUC), it is re-serialized into a fresh compacted blob.VACUUMalso performs a full drain pass and tracks lifetime drain counters.Read-time pending-list reconciliation. Queries transparently merge not-yet-drained pending records into the results they read, so a backend sees a consistent view regardless of whether another backend’s writes have been drained yet.
Cross-backend cache coherency. Each session’s cached copy of the index carries the generation it was loaded at; every scan compares that generation against the metapage and reloads on mismatch, so a backend always sees other backends' committed writes.
Candidate-mask threading across scan keys. Conjunctive queries evaluate their most selective key first and restrict later keys to the surviving rows, instead of computing each key independently and intersecting. Queries combining an anchored predicate with an unanchored one benefit substantially.
Rewritten cost model. Costs are now derived from pattern shape, column statistics and relation size, letting the planner weigh Biscuit against
gin_trgm_opspg_trgm and atext_pattern_opsB-tree. Costing for unanchored patterns continues to be refined; verify withEXPLAINwhere a specific plan matters.Length-predicate support. Patterns consisting only of
_wildcards ('______','______%') are recognised as length predicates and answered from the length bitmaps in a single lookup, rather than being treated as unusable.New
biscuit_like_ops/biscuit_ilike_opsoperator classes. In addition to the defaultbiscuit_ops, which builds both case-sensitive and case-insensitive structures, a column may be indexed withbiscuit_like_ops(LIKE and NOT LIKE only) orbiscuit_ilike_ops(ILIKE and NOT ILIKE only), skipping the build and maintenance cost of the structure set it will never be queried with. The mode is derived from the column’s opfamily at build and load time and is never persisted, so it cannot go stale across aREINDEXunder a different opclass.
Internal Changes
- Removed the background preload worker; index loading is now synchronous.
- Rewrote the on-disk persistence layer to use the new directory and blob/pending-chain storage; the 2.5.0 flat-file snapshot format is no longer read.
- Deletes and updates now remove index entries individually and durably, replacing the previous bulk in-memory sweep.
Known Limitations
These follow from the design and should be planned for. Figures observed during testing will vary with hardware, data and workload.
Write amplification. Because one indexed string touches many per-character structures,
INSERTandUPDATEagainst a live index generate considerably more WAL than the corresponding heap writes alone. Substantial WAL is characteristic of maintaining any secondary text-search structure, and in testing Biscuit’s WAL volume per row was comparable to apg_trgmGIN index on the same data. WAL per row also grows as the index grows, so measurements taken on a small index will understate a large one.DELETEis much cheaper, recording a tombstone rather than rewriting structures.Size
pg_walaccordingly, monitor free space, and where replication slots are in use consider settingmax_slot_wal_keep_size. Allow for the corresponding effect on crash-recovery duration when planning restart windows.Bulk-load before indexing. Creating the index after a load is substantially faster, and generates far less WAL, than inserting the same rows into an already-indexed table.
Per-connection memory. Each backend holds its own copy of the index in session-local memory for the life of the connection, loaded lazily as patterns are queried. Memory therefore scales with concurrent connections;
biscuit_index_memory_size()reports the current session’s copy.Cache reload on invalidation. A committed write by any backend invalidates cached copies, which are then reloaded in full rather than refreshed incrementally. Read latency rises for a period after each write, and the effect is more pronounced with many concurrent readers. Incremental refresh is planned.
Build cost and index size. Biscuit indexes are larger and slower to build than comparable
pg_trgmor B-tree indexes on the same column.VACUUMdoes not reduce index size; useREINDEX. Build memory scales with row count.Unanchored query cost grows with the square of string length, so a small number of unusually long values can affect query cost across the table.
No ordered, backward, index-only or unique scans, and Biscuit indexes are not clusterable.
Upgrade Notes
This is a breaking on-disk format change. Indexes built under 2.x must be
REINDEXed after upgrading; there is no automatic migration and no dual-format
reader. Until an index is rebuilt, its first cold load under the new version
fails with an error referring to this note.
Plan a maintenance window sized for the rebuild: index build is slower than for
pg_trgm GIN on the same data.
Version 2.5.0
New Features
Persistent on-disk snapshots for fast index reload.
BiscuitIndexstate can now be saved to disk and restored at server startup without rebuilding from the heap. Snapshots persist index metadata, record bookkeeping (tids, tombstones, free list), cached string data, and per-character and length-based bitmap structures for both single- and multi-column indexes. Bitmaps are serialized using CRoaring’s portable format when available, with a raw bitmap fallback otherwise. Snapshots are written atomically (temp file →fsync()→ rename) to prevent partial writes, and magic/version validation rejects incompatible formats on load. This significantly reduces restart time by avoiding a full heap rebuild.Generation-based staleness detection for snapshots. A monotonic generation counter (
gen) is now incremented after successful inserts and vacuum deletions and persisted to the metapage. Each snapshot records the generation it was written at; on load, this is compared against the live metapage value, and stale snapshots are discarded with aWARNING, falling back to a normal heap rebuild. Snapshots are automatically rewritten after a configurable number of mutations, duringVACUUMcleanup when needed, and at backend shutdown if unsaved changes remain. The generation is only advanced after a successful save, so failed writes are retried.
Bug Fixes
Fixed wildcard handling for substring
LIKE/ILIKEmatching. The%needle%fast path usedstrstr(), which treated_as a literal character rather than a SQL wildcard, and always seeded candidate bitmaps from the first pattern byte — causing patterns like%_lex%to search on_instead of the first concrete character. Substring verification now uses the wildcard-awarebiscuit_wildcard_contains(), and candidate seeding usesbiscuit_part_seed_byte(), which skips leading wildcards (falling back to length-based candidates when no concrete seed exists). This restores correct results forLIKE,ILIKE,NOT LIKE,NOT ILIKE, and compound predicates involving_.Fixed single-column
ILIKElength bitmap allocation. The single-column build path computedmax_length_lowerincorrectly, causing all single-columnILIKEqueries to fail their length bounds checks. This was a regression from the precedinglibroaringcrash fix, which removed a per-record update the allocation had been silently relying on.max_length_loweris now computed directly fromdata_cache_lower, matching the multi-column implementation.Fixed out-of-bounds access in bitmap lookups. Tightened boundary checks to prevent invalid bitmap array accesses that could trigger crashes in CRoaring.
Fixed NULL row handling during index build.
biscuit_build()previously skipped an entire row if any indexed column wasNULL, omitting the row’s TID from the index and making its non-NULL columns unsearchable. The row-level NULL check has been removed in favor of the existing per-column handling, matchingbiscuit_insert().Fixed off-by-one bounds in length “≥” bitmap lookups. Requests where the minimum length equaled the maximum indexed length could read past the end of the bitmap arrays; bounds checks are now tightened while preserving correct behavior for valid lengths.
Internal Changes
Replaced background preload with synchronous cache loading. Removed the skeleton preload pipeline, background worker, associated shared-memory state, and fallback scan path. Indexes are now built synchronously on cache miss and cached fully initialized, simplifying the codebase and eliminating much of warm-up latency.
biscuit_persist_save()now accepts anOidinstead of aRelation.Reserved padding in snapshot and metapage headers for future metadata.
Version 2.4.2
Bug Fixes
- Fixed a use-after-free in the index cache.
BiscuitIndexobjects were inadvertently owned by bothbiscuit_cacheand PostgreSQL’srd_amcache, allowing relcache invalidation to leave stale pointers in the session cache.biscuit_cacheis now the sole owner ofBiscuitIndexobjects.
Build
- Fixed compiler warnings for unused parameters. Contributed by Devrim Gündüz.
- Fixed signed/unsigned comparison warnings.
- Fixed an unused-variable warning in
biscuit_rescan_multicolumn.
Biscuit
- Version bumped to 2.4.2.
Version 2.4.1
Build
- Fix hardcoded
PG_CONFIGpaths that prevented builds on non-Debian distributions.
Biscuit
- No functional changes.
Version 2.4.0
New Features
Expression index support: Biscuit now correctly evaluates arbitrary index key expressions during index builds, enabling indexes such as:
sql CREATE INDEX idx ON table USING biscuit (lower(column_1), (column_2::text));Multi-version build support (PG 16, 17, 18, 19beta1): Biscuit can now be compiled and installed against PostgreSQL 16 and 17, in addition to the already supported PG 18 and PG 19 Beta. All version-specific API differences are handled at compile time via
#if PG_VERSION_NUMguards.
Bug Fixes
Multi-column parallel scan returned duplicate rows: In the multi-column fallback scan path, every Gather participant was calling
biscuit_collect_sorted_tids_single()unconditionally, causing each worker to return the full TID set and the Gather node to assemble N× the expected rows. The call site now mirrors the single-column path by resolving the shared-memory parallel scan descriptor and dispatching throughbiscuit_collect_sorted_tids_parallel(), so each participant claims a disjoint slice of the pre-partitioned TID array.biscuit_operatorsview no longer breaks when additional operator classes are added: The view previously filtered on a hardcodedopfname = 'biscuit_text_ops'. It now joins throughpg_amand filters onam.amname = 'biscuit', staying correct without edits if new opclasses or opfamilies are later added. The view also surfaces the opfamily name per row.
Internal Changes
Parallel scan callbacks are conditionally compiled for PG 18+:
amcanparallel,amestimateparallelscan,aminitparallelscan, andamparallelrescanare only registered whenPG_VERSION_NUM >= 180000. On PG 16 and 17 the parallel fields are set tofalse/NULL.Cross-version compatibility macros added to
biscuit_common.h:BISCUIT_PARALLEL_AM_OFFSET(ps)abstracts the rename ofps_offset→ps_offset_amin PG 18.BISCUIT_COUNT_INDEX_SEARCH(scan)abstracts the index search counter, which moved fromxs_numIndexSearches(PG 17) toscan->instrument->nsearches(PG 18+) and did not exist in PG 16.biscuit_estimateparallelscanis declared with the correct signature for each major version (voidon PG 16,int nkeys, int norderbyson PG 17,Relation indexRelation, int nworkers, int nchunkson PG 18+).
Version string updated to
2.4.0 - Donut.
Notes
- CHAR(n) /
bpcharnative operator class is not yet available. PostgreSQL defines LIKE/ILIKE operators only over(text, text), so a dedicatedbiscuit_bpchar_opsoperating directly on paddedbpcharvalues would require new C-level operator implementations. As a supported workaround, CHAR(n) columns can be indexed today via an expression index on the text cast:sql CREATE INDEX idx ON table USING biscuit ((char_col::text));This is documented inbiscuit.sqland reflected in the updatedbiscuit_operatorsview comment.
Version 2.3.0
New Features
Parallel index scan support: Biscuit now integrates with parallel query execution in PostgreSQL, allowing Gather plans to distribute work across workers without duplicate results.
Pre-lowercased cache for multi-column indexes: Added
column_data_cache_lowerto accelerate ILIKE queries by eliminating repeated string normalization during scans.LIKE / ILIKE matching: Pattern matching now correctly handles
%,_, escape sequences, and complex wildcard combinations.Version updated to
2.3.0 - Bagel.
Bug Fixes
Fixed a crash that could occur when INSERT operations followed SELECT queries on partially loaded indexes.
Fixed an issue where newly inserted rows could become invisible to subsequent queries due to stale session cache entries.
Fixed multi-column indexes failing to update length-based bitmap structures during inserts.
Fixed several memory initialization issues during cache growth that could cause incorrect results or instability.
Fixed insert operations losing in-memory changes after relcache invalidation.
Prevented the planner from selecting Biscuit for unqualified scans where no index predicates are present.
Fixed single-column scans using incorrect query paths for LIKE and ILIKE operations.
Fixed an issue where indexes could remain in a cold state even after background preloading had completed.
Improved cache update behavior to avoid unnecessary remove-and-reinsert cycles during inserts.
Performance Improvements
Eliminated per-row allocations during ILIKE fallback scans by using pre-lowercased caches.
Simplified TID collection by consolidating scan paths into a single parallel-aware implementation.
Internal Changes
Reworked the parallel scan infrastructure around a shared-memory descriptor model and added support for PostgreSQL’s parallel index scan callbacks.
Removed unused LIMIT-tracking logic that was ineffective with the PostgreSQL access method API.
Version 2.2.3
Structural Changes
Monolith split into modules. The single
biscuit.cfile has been decomposed into focused translation units, each with its own header: | Module | Responsibility | |—|—| |biscuit.c| AM handler, SQL-callable functions,_PG_init| |biscuit_bitmap.{c,h}| Roaring bitmap abstraction + fallback bitset | |biscuit_cache.{c,h}| Session-scoped index cache | |biscuit_index.{c,h}| Index build, load, disk I/O, CRUD helpers | |biscuit_pattern.{c,h}| LIKE/ILIKE pattern parsing and bitmap matching | |biscuit_preload.{c,h}| Background preload worker and skeleton loader | |biscuit_scan.{c,h}| Scan lifecycle (beginscan/rescan/gettuple/getbitmap/endscan) | |biscuit_tid.{c,h}| TID sorting (radix + qsort) and parallel collection | |biscuit_utf8.{c,h}| UTF-8 character utilities and Datum→text helpers |All shared types, constants, and macros have been consolidated into
biscuit_common.h.No SQL-level API changes.
Version bumped to
2.2.3(BISCUIT_LIBRARY_VERSION).
New Features
- PostgreSQL 19 Beta 1 support.
PG_MODULE_MAGIC_EXT(introduced in PG 19) is now used when available, with a fallback toPG_MODULE_MAGICfor older versions. The extension can now be built and loaded against PG 19 development builds without modification.
Improvements
Memory context correctness. The session cache (
biscuit_cache.c) now explicitly switches toCacheMemoryContextbefore allocating cache list nodes, ensuring index structures survive transaction boundaries without relying on caller context. Thebiscuit_cleanup_indexstub correctly avoids double-freeing memory owned by the context.biscuit_complete_preload_local()added as a fast in-process upgrade path: rebuilds bitmaps from the already-resident string cache without reopening the relation or re-scanning the heap. Used bybeginscanwhen it detects the worker has finished between queries.TID collection refactored into
biscuit_tid.c. The unified entry pointbiscuit_collect_tids_optimized()selects parallel vs. single-threaded collection automatically and supports an optionallimit_hintto avoid collecting more TIDs than the executor needs.Fallback scan in
biscuit_preload.csupports NOT LIKE and NOT ILIKE during warm-up via a hash-map TID→record-index lookup, maintaining correct inversion semantics without bitmaps.UTF-8 helpers isolated in
biscuit_utf8.{c,h}, removing scattered inline character-length and lowercase conversion code from the pattern and index modules.biscuit_columnindex_memory_usage()now validatesmax_length >= 0before iterating length bitmap arrays and emits aWARNINGon corrupt state rather than reading out-of-bounds.
Bug Fixes
biscuit_cache_remove()no longer callspfreeon list nodes; they are owned byCacheMemoryContextand must not be freed manually.
Version 2.2.2
Performance Improvements
Refined TID sorting implementation
Replaced the previous hybrid dense/sparse block radix sorter with a uniform 4-pass radix sort covering the full 32-bit BlockNumber.
Sorting is now performed using four 8-bit passes, eliminating assumptions about block number density or range.
Correctness & Stability
Aligned TID comparison with PostgreSQL core
Replaced custom TID comparison logic with PostgreSQL’s native comparison routine to ensure consistent ordering behavior.
Version 2.2.1
Bug Fixes
Fixed recursive pattern matching
Resolved incorrect behavior when evaluating nested or repeated wildcard patterns during recursive matching.
Corrected underscore (
_) handling in single-column indexing_now correctly operates on character-based offsets (not byte offsets), in accordance with SQLLIKE/ILIKEsemantics, eliminating false matches in multi-byte UTF-8 text.
Correctness & Stability
- Improved internal consistency between single-column and multi-column pattern evaluation paths.
- Resolved observed edge cases that could lead to incorrect matches under complex wildcard patterns.
Version 2.2.0
Major Changes
Switched from byte-based to character-based indexing
- Biscuit now indexes Unicode characters instead of raw UTF-8 bytes.
- Eliminates incorrect behavior caused by multi-byte UTF-8 sequences being treated as independent index entries.
- Index structure now aligns with PostgreSQL’s character semantics rather than byte-level representation.
UTF-8 & Internationalization Improvements
Enhanced UTF-8 compatibility
- Improved handling of multi-byte UTF-8 characters (e.g., accented Latin characters, non-Latin scripts).
- Index lookups, comparisons, and filtering now operate on logical characters rather than byte fragments.
Correct UTF-8 support for ILIKE
ILIKEnow works reliably with UTF-8 text, including case-insensitive matching on multi-byte characters.- Fixes previously incorrect matches and missed results in non-ASCII datasets.
CRUD Correctness Fixes
Resolved multiple CRUD-related bugs
- Fixed inconsistencies during INSERT, UPDATE, and DELETE operations that could leave the index in an incorrect state.
- Ensured index entries are properly added, updated, and removed in sync with heap tuples.
- Improved stability under mixed read/write workloads.
Correctness & Planner Consistency
- Improved alignment between Biscuit’s index behavior and PostgreSQL’s text semantics.
- Reduced false positives during pattern matching and eliminated character-splitting artifacts.
- More predictable planner behavior due to improved index consistency.
Internal Refactoring
- Refactored index layout and lookup logic to support character-aware traversal.
- Hardened UTF-8 decoding paths and edge-case handling.
- Simplified internal invariants for better maintainability and debugging.
Version 2.1.5
Improvements
Removed arbitrary limits on multi-column indexes
- Biscuit no longer enforces hard-coded limits when creating indexes over multiple columns, allowing more flexible index definitions.
Safety & Correctness
Restricted indexing to text-based datatypes
- Support for non-text datatypes has been removed. Biscuit now explicitly enforces text-only columns to ensure correct operator semantics, planner behavior, and index consistency.
Explicit error for expression indexing
- Biscuit now raises a clear error when users attempt to create an index on an expression (e.g.,
lower(col)), which is not currently supported. This prevents silent misconfiguration and enforces Biscuit’s column-based indexing semantics.
Note: Biscuit currently indexes base columns only. This may be revisited in future versions.
Version 2.1.4
Build & Packaging
- Improved Makefile detection logic for CRoaring bitmap support by checking multiple common installation paths, increasing portability across systems and build environments.
New Features
Build and configuration introspection
Added SQL functions to inspect Biscuit build-time configuration, useful for debugging, reproducibility, and deployment verification.
biscuit_version() → text
Returns the Biscuit extension version string.
biscuit_build_info() → table
Returns detailed build-time configuration information.
biscuit_build_info_json() → text
Returns build configuration as a JSON string for automation and scripting.
Roaring Bitmap support introspection
Added built-in SQL functions to inspect CRoaring bitmap support in Biscuit.
biscuit_has_roaring() → boolean
Checks whether the extension was compiled with CRoaring bitmap support.
biscuit_roaring_version() → text
Returns the CRoaring library version if available.
Diagnostic views
Added a built-in diagnostic view for quick inspection of Biscuit status and configuration.
biscuit_status
A single-row view providing an overview of:- extension version
- CRoaring enablement
- bitmap backend in use
- total number of Biscuit indexes
- combined on-disk index size
Version 2.1.3
New Features
Added Index Memory Introspection Utilities
Added built-in SQL functions and a view to inspect Biscuit index in-memory footprint.
biscuit_index_memory_size(index_oid oid) → bigintLow-level C-backed function returning the exact memory usage (in bytes) of a Biscuit index currently resident in memory.
biscuit_index_memory_size(index_name text) → bigintConvenience SQL wrapper accepting an index name instead of an OID.
biscuit_size_pretty(index_name text) → textHuman-readable formatter that reports Biscuit index memory usage in bytes, KB, MB, or GB while preserving the exact byte count.
biscuit_memory_usageviewA consolidated view exposing:
- schema name
- table name
- index name
- Biscuit in-memory size
- human-readable memory size
- on-disk index size (via
pg_relation_size)
This allows direct comparison between in-memory Biscuit structures and their persistent disk representation.
SELECT * FROM biscuit_memory_usage;
Notes
- Memory accounting reflects Biscuit’s deliberate cache persistence design, intended to optimize repeated pattern-matching workloads.
- Functions are marked
VOLATILEto ensure accurate reporting of live memory state. pg_size_pretty(pg_relation_size(...))reports only the on-disk footprint of the Biscuit index. Since Biscuit maintains its primary structures in memory (cache buffers / AM cache), the reported disk size may significantly underrepresent the index’s effective total footprint during execution. Hence, we recommend the usage ofbiscuit_size_pretty(...)to view the actual size of the index.
Performance improvements
Removed redundant bitmaps
Separate bitmaps for length-based filtering for case-insensitive search were removed. Case insensitive searches now use the same length-based filtering bitmaps as case-sensitive ones.
Version 2.1.2 (2025-12-11)
New Features
ILIKE Operator Support (Case-Insensitive Matching)
Biscuit now provides full support for the ILIKE operator, enabling efficient case-insensitive wildcard searches directly through the index.
Capabilities:
- Optimized execution path for
ILIKEandNOT ILIKE - Works seamlessly in mixed predicate chains alongside
LIKE/NOT LIKE - Fully compatible with multi-column Biscuit indexes
Examples:
-- Case-insensitive suffix search
SELECT * FROM users WHERE name ILIKE '%son';
-- Combination queries
SELECT * FROM users
WHERE name ILIKE 'a%' AND email NOT ILIKE '%test%';
#
Removed Length Constraint for Indexing
The previous hardcoded 256-character indexing limit has been removed. Biscuit now indexes values of any length, including very long strings.
Impact:
- All text values—short or arbitrarily long—are now included in bitmap generation
- More consistent query coverage for fields like descriptions, logs, and message bodies
Version 2.1.0 - 2.1.1
Contain build issues. Fixed in version - 2.1.2.
Version 2.0.1 (2024-12-06)
Bug Fixes
Fixed Incorrect Results with Multiple Filter Predicates
Issue: Queries with multiple LIKE or NOT LIKE predicates on the same column could return incorrect results.
Root Cause: When executing queries with multiple filter predicates (e.g., name LIKE '%a%' AND name NOT LIKE '%3%'), the bitmap inversion logic for NOT LIKE was being applied globally instead of per-predicate, causing the wrong result set to be returned.
Example of Affected Query: ```sql – Query with multiple filters SELECT COUNT(*) FROM users WHERE name LIKE ‘%a%’ AND name NOT LIKE ‘%3%’;
– v2.0.0: Returned incorrect count (e.g., 252,167) – v2.0.1: Returns correct count (e.g., 251,482) ✅ – Verified against sequential scan ```
Fix: Implemented per-predicate bitmap inversion logic that correctly handles each filter independently before combining results.
Impact:
- Affected Queries: Any query with 2+ predicates using LIKE and/or NOT LIKE on indexed columns
- Severity: HIGH - Results were incorrect but deterministic
- Data Safety: No data corruption - index structure unchanged
Verification: ```sql – All these patterns now return correct results:
– Pattern 1: LIKE + NOT LIKE WHERE name LIKE ‘%abc%’ AND name NOT LIKE ‘%xyz%’
– Pattern 2: Multiple NOT LIKE WHERE name NOT LIKE ‘%a%’ AND name NOT LIKE ‘%b%’
– Pattern 3: Complex combinations WHERE col1 LIKE ‘A%’ AND col2 NOT LIKE ‘%test%’ AND col1 LIKE ‘%end’ ```
NOT LIKE Operator Support
- Full support for
NOT LIKEpattern matching (Strategy #2) - Efficient bitmap negation for exclusion queries
- Example:
WHERE name NOT LIKE '%test%'
Upgrade Notes
Compatibility: - Fully backward compatible with v2.0.0
Recommended Actions:
1. Update extension: ALTER EXTENSION biscuit UPDATE TO '2.0.1';
2. Re-run any critical queries that used multiple predicates to verify corrected results
Version 2.0.0 (2024-11-05)
Major Features
Multi-Column Index Support
- Create Biscuit indices on multiple columns simultaneously
- Per-column bitmap optimization for efficient filtering
- Example:
CREATE INDEX idx ON table USING biscuit(name, email, description);
Query Optimization Engine
- Intelligent predicate reordering based on selectivity analysis
- Executes most selective filters first to minimize candidate set
- Supports exact, prefix, suffix, and substring pattern detection
Performance Enhancements
- TID sorting for sequential heap access (5000+ results)
- Parallel bitmap collection for large result sets (10K+ matches)
- Direct Roaring bitmap iteration without intermediate arrays
- Skip sorting for bitmap scans (COUNT/aggregate queries)
- LIMIT-aware early termination
Memory Management Improvements
- Persistent caching in CacheMemoryContext
- Automatic cache invalidation on index drop/ALTER
- Batch cleanup with configurable threshold (1000 tombstones)
🔧 Technical Improvements
Pattern Matching:
- Fast-path optimizations for pure wildcard patterns (%, _)
- Exact length matching for underscore-only patterns
- Optimized single-part and two-part pattern execution
- Recursive windowed matching for complex multi-part patterns
Type Support: - Text, VARCHAR, CHAR (native) - Integer types (INT2, INT4, INT8) with sortable encoding - Float types (FLOAT4, FLOAT8) with scientific notation - Date/Timestamp types with microsecond precision - Boolean type
Index Statistics:
- biscuit_index_stats(index_oid) function for diagnostics
- CRUD operation tracking (inserts, updates, deletes)
- Tombstone and free slot monitoring
Full Documentation: See README.md or visit ReadTheDocs for complete usage guide and examples.