Incremental doc-values updates - #16418
Open
jimczi wants to merge 6 commits into
Open
Conversation
jimczi
force-pushed
the
agent/dv-incremental-update
branch
from
July 25, 2026 14:58
d1eed2f to
e852fa8
Compare
jimczi
force-pushed
the
agent/dv-incremental-update
branch
from
July 25, 2026 17:02
e852fa8 to
1a6acac
Compare
setIncrementalDocValuesUpdates (default true; opt out for the classic full-column rewrite) and setMaxDocValuesDeltaGenerations (default 16), plus the CHANGES.txt entry.
Format-agnostic read primitive: DocValuesOverlayMerger does a min-heap
k-way merge over layered generations (newest wins, base last), driving
Overlay{Numeric,Binary}DocValues. Advancing only re-positions the
layers sitting on the current doc. Includes unit tests.
Store the per-field sparse overlay generations ({baseGen, deltas...})
as first-class SegmentCommitInfo state, serialized in the segments
file. A commit carrying an overlay is written at a bumped segments
version (VERSION_11_0), so a Lucene version that predates the feature
rejects it before any codec runs rather than misreading a delta as the
whole column; commits without an overlay stay at the previous version
and remain readable by older versions.
jimczi
force-pushed
the
agent/dv-incremental-update
branch
3 times, most recently
from
July 25, 2026 20:57
f5c742e to
b77d41e
Compare
Each flush writes only the updated docs as a sparse delta generation overlaid at read time (via SegmentDocValuesProducer), turning per-update amplification from O(column) into O(updated docs). Deltas fold into one sparse generation past maxDocValuesDeltaGenerations, and fold to a dense column once they cover it or stack on a dense base; removals fall back to the dense rewrite. The overlay generations live on SegmentCommitInfo and are carried over by addIndexes. Threads IndexWriterConfig through ReaderPool to ReadersAndUpdates.
Randomized set-only updates with reopens and merges, merge-flatten, removal-to-dense fallback, continuing a feature-off index, fold-to-dense, multiple fields, updates interleaved with deletes, sorted index, addIndexes, binary, the skip-index rejection, and the older-reader segments-version fence. The classic delete-unused-files test pins the feature off to keep exercising the dense path.
Standalone benchmark (a main, not a JUnit test, run outside the test framework so the JVM is production-like) that applies the same updates three ways - full document reindex, dense column rewrite, sparse delta - over docs carrying a vector, reporting update throughput, bytes written per update against the raw value size, live file and generation count, and an aggregation scan; plus a maxDocValuesDeltaGenerations sweep and optional concurrent query threads. To be removed before merge; here so reviewers can reproduce.
jimczi
force-pushed
the
agent/dv-incremental-update
branch
from
July 25, 2026 21:35
b77d41e to
25b7c80
Compare
Contributor
|
I like the idea, it would make updatable DV more appealing. quick Q: I read the proposal but not all the changes. Does it cover all the DocValues, or just Numeric? What about multi-valued (SortedNumeric I think it's called)? |
Contributor
Author
|
Yeah, builds on the existing update support so numeric and binary only. For multi-valued you'd have to pack it into a binary field with a custom format today, there's no native SortedNumeric update path. SortedNumeric could be added (it's ordinal-free, so tractable), but that's really about extending the core update support, which is out of scope here: this PR just builds sparse deltas on top of what's already updatable. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incremental doc-values updates
When you update a doc-values field today, Lucene rewrites the whole column for that field, even if you only touched a
few docs. So a tiny change writes a lot, and it gets worse as the segment grows.
The idea is simple: when an update only sets values (no unset), write just the changed docs as a sparse "delta"
generation, and stack the deltas on top of the base column at read time, newest wins. Updating a field becomes
O(changed docs)instead ofO(column).Deltas would pile up, so there's a small lifecycle per field:
Numeric and binary only, set-only (a value unset falls back to the current dense rewrite). No doc-values format
change, the deltas use the codec's existing sparse encoding.
An old Lucene can't read the overlay. So a commit that carries one is written at a bumped segments version, and an old
reader rejects it instead of reading a delta as if it was the whole column. A commit with no overlay is written
exactly like before, so it's safe to backport (off by default there; on
mainit's on by default, disable withIndexWriterConfig#setIncrementalDocValuesUpdates(false)).This is a proposal, I'd like your opinion on the approach before polishing it more. Two things I'm not sure about:
where the overlay bookkeeping should live (I put it on
SegmentCommitInfo), and the compaction, which right nowre-folds all the deltas every time. A size-tiered policy would be better, left as a TODO.
Numbers
There are three ways to change one field on a doc, so I compared them head to head. The benchmark is in the last
commit, a plain
main, not a test, run as a normal Java app (assertions off, real JIT, mmap directory) so the JVMbehaves like production and not the test harness. It'll go before merge, it's just there so you can reproduce. It
indexes 5M docs, each with an 8-byte field and a 512-dim vector, lets the natural merges finish, then applies updates
in random order (not ingestion order, so the overlay actually gets stressed) from 4 threads, refreshing an NRT reader
every second. It reports update throughput, the bytes written during the update phase, write amplification, and a scan
that reads the field back. All three arms end on the same values.
updateDocument, what you do today when the field isn't doc-values-updatable. Rewrites the whole doc.The thing that matters is cadence. Write amplification isn't really a property of the feature, it's a property of how
many docs you change between commits. So I ran every arm two ways: a batch that updates as fast as it can (lots of docs
per refresh), and a throttled stream at 5k updates/s (a few thousand per refresh, closer to a steady trickle on a live
index).
Numeric 8-byte field:
Amplification is bytes written over the 8 bytes actually changed. In batch, dense amortizes: thousands of updated docs
share one full-column rewrite, so it lands at 10×. Throttle it and that same full-column rewrite now covers only a few
thousand docs, so it jumps to 900×. Sparse doesn't care about cadence, it writes the changed docs and nothing else, so
it stays flat: 2.2× vs 900× at 5k/s, about 400× less written.
32-byte binary field, same two runs:
Same shape, bigger column so dense costs more (27 KB/update throttled against 55 bytes for sparse).
Soft deletes come along for free, because a soft delete is a numeric doc-values update on the soft-deletes field, so it
takes the same path. The arms here are a hard delete (liveDocs, no column), and the soft-delete mark with the feature
off (dense) and on (sparse):
The soft-delete column only holds the marked docs, so the absolute numbers are small, but the shape is the same: batch
amortizes and both are basically free, throttle it and dense rewrites the growing column every commit (33 bytes/update)
while sparse writes just the newly marked docs (6 bytes/update).
Reads are the tradeoff. The sparse scan merges the overlay at read time, so it's a few times slower than a plain
column (numeric 118 ms vs 35 ms, binary 327 ms vs 29 ms over 5M docs). It's bounded by
setMaxDocValuesDeltaGenerations(default 16, more generations means cheaper writes but more layers to merge on read), and a merge flattens it back. I
also ran the batch with 4 threads querying while the updates landed and everything stayed correct with the write
numbers holding.
The benchmark is only here so you can look at the numbers for this review, it isn't meant to land. Here's the line I
ran:
(add
-Ddvbench.rate=5000to throttle,-Ddvbench.type=binaryor-Ddvbench.type=softdeletefor the other fields,-Ddvbench.queryThreads=4to query under load.)Commits
Split so each piece is easy to look at on its own: