How the full-text index is kept valid across every update operation, why in-place changes were ruled out, and what the pieces on disk and in memory do.
UpdatableDiskValues reads and rewrites the complete ID list of every key it touches. For a text index that is one key per node and the lists are short unless the value is frequent.
Every node has many tokens, and the common ones have lists proportional to the database. A single edit would cost the sum of those lists.
Immutable segments, deletion marks and merging: Lucene and everything built on it, SQLite FTS5, InnoDB, Oracle Text. BaseX already had the machinery, because FTBuilder writes partial indexes when memory runs out and merges them at the end.
FTXSEGS present means numbered segments, node IDs mapped through data.pre(id), an updatable index and storage version 13.0. Absent means the 12.0 layout, PRE values, OLDSTORAGE, and the first update invalidates the index as before.
A 12.0 database opened with UPDINDEX simply carries a non-updatable index until the next OPTIMIZE rebuilds it segmented. Databases without UPDINDEX stay byte-identical (pinned by IndexFormatTest).
File s lists the units this segment re-indexed or excluded. Deleted units need no mark: IdPreMap.pre returns −1 for them.
data.pre(id).A reference for unit i in segment s is live iff pre(i) ≠ −1 and no segment newer than s, the buffer included, supersedes i.
FTSegment.newer is the union of the supersede sets of all newer segments and of the buffer. It is recomputed from the files at open and after every merge; a put adds its ID to the union of every existing segment.
put(id, tokens, positions) bumps the ID's generation, appends the triples under it, and writes a log record. An empty token list records an exclusion (a rename out of the included names).
An editor saving the same document repeatedly, or many changes below one included element, re-puts the same units. Filtering by generation at query time and at segment write costs nothing; a forward map and deletion would.
The count of references appended since the last segment write is checked after every unit. Past 1 million (SPLITSIZE × 1 000 000), the buffer becomes segment n, the log is deleted, and the count restarts. This bounds memory, the log and its replay.
Data.finishUpdate; Optimize.finish runs before it, so optimize() processes the touched set first as well.If the lexer cannot be built or an I/O error hits the log, a segment or a merge, the index is dropped and ftindex cleared. An update never fails because of the full-text index.
FTBuilder.merge(inputs, output, live) is the same k-way merge that joins the builder's partial files, with one predicate per input: pre(id) ≠ −1 and id ∉ newer(input). Tokens whose references all died are skipped; surviving references are sorted by ID and position.
OPTIMIZE and db:optimize: write the buffer, merge everything into one segment. 22 s for the 568 MB XMark index.AUTOOPTIMIZE: the same, but only when superseded units exceed 10 % of lastid.Merges run inside finishUpdate under the write lock; a background merger would change the locking model. Inputs are deleted as soon as the output is open.
Callers still receive PRE values grouped with their match positions. The 12.0 path with a single unnumbered segment goes through the same code with the ID mapping switched off.
costs, size and the counts of ft:tokens and INFO INDEX sum the segments' entries. Superseded and deleted references are counted until the next merge; a token present in several segments appears once, because entries is a k-way merge of the per-segment iterators by (length, token).
On XMark 1 GB with 712 buffered references, ft:search for will took 156 ms warm, the same as before the change; fuzzy 265 ms.
FTBuilder gained a range build and a static merge with liveness predicates; its partial-file splitting is the builder route for a 1 GB insert.FTSegmentWriter extracts the x/y/z writing that writeIndex and the old merge each had inline.ValueIndex hooks were unified beforehand, so Data has no full-text branches.ValueIndex.optimize(boolean auto) distinguishes AUTOOPTIMIZE from an explicit optimize; IndexBuilder.splitFactor(type) is public so the threshold shares the builder's constant.
| Measurement | Before (no full-text index or non-updatable) | After (updatable full-text index) |
|---|---|---|
| CREATE DB with FTINDEX | 128–134 s, 67 s of which full-text | 112 s with UPDINDEX, index 568 MB, one segment |
| 200 single-text replacements, old values frequent (text-index posting-list cost) | 41 s | 40 s, median 15 ms, max 593 ms |
| 200 single-text replacements, old values unique | 4.6 s, median 25 ms | 3.4 s, median 15 ms, max 31 ms |
| ft:search will, 190 k hits, warm | 140–220 ms | 156 ms with 712 buffered refs; 140 ms after merge |
| ft:search will, fuzzy, 392 k hits, warm | 265–360 ms | 265 ms |
| OPTIMIZE (full merge of the index) | rebuild: 67 s of lexing | 22 s streaming merge |
| db:add of the 1 GB document into an empty database | not possible with a kept index | 135 s through the builder route, same hit counts as CREATE DB |
Ryzen 7 PRO 5850U, F: drive, SPLITSIZE 0, 2.4 GB heap. Update rows measured with the database held open; the first row's 465 ms outliers are the text index rewriting the ID list of a frequent key, which the full-text design avoids by construction. FTIndexBaselineTest in basex-tests reproduces every row.
OPTIMIZE rebuilds it segmented if UPDINDEX is onOLDSTORAGE; 12.0 can open itH_DB_FORMATEqual to the other UPDINDEX structures: segments, log and meta data are written in finishUpdate after the table. Unlisted files are removed at open; an incomplete log record is truncated on replay.
FTMIXED at the 1 GB scale, and query latency at 4 and 8 segments on a large database. Both are exercised functionally by FTIndexUpdateTest with a lowered threshold.
finish, buffer-to-segment writes, merge policy, optimize(auto), orphan cleanup, INFO INDEX output (segments, superseded, buffered)build(first, last, prefix), static merge(data, inputs, output, live), IDs under UPDINDEXftsegments (FTXSEGS), update() keeps ftindex iff set, legacy() false iff setauto from Optimize.finish into ValueIndex.optimize(boolean)FTIndexUpdateTest (18 cases), IndexUpdateConcurrencyTest, UpdIndexTest, UpdIndexRandomTest, FTIndexBaselineTest; IndexFormatTest guards the 12.0 layout.claude/Observations/fulltext-updindex.md: rationale, defaults, deviations, baseline and validation numbers