try call, 3 for the buffered update). Select the still to open it at full size. Generated with AI, checked against the code.- Index two documents with the default
IndexWriterConfig,id:1(doc 0,n=1) andid:2, and open an NRT readerrfrom the writer. - Call
updateDocValues(id:1, n=2), which is buffered and returns sequence number 5, thentryUpdateDocValue(r, 0, n=3), which succeeds and returns 6. - You expect
n=3in a fresh NRT reader and in the commit: sequence numbers give the effective order, so 6 should win over 5. - Instead both show
n=2. Thetrycall took delete generation 2 at once; the buffered update got generation 3 only when the fresh reader froze the buffer, and the larger generation wins. - No exception, CheckIndex is clean, and the wrong value is committed. A
flush()between the two calls givesn=3. With the opt-in soft-deletes field, a soft delete by term followed by atryun-delete loses the un-delete (1 live document instead of 2).
Applications that call the expert tryUpdateDocValue on a document that got a term-based doc-values update after its reader was opened, before anything (a flush, reopen or commit) froze the buffer. Default settings suffice; un-deletes need the opt-in soft-deletes field. A 4-thread stress test left 21-68 of 200 documents with an older value. No callers found in Elasticsearch, OpenSearch or Solr.
Mechanism, terms and numbers663 words
Update a document's doc value by term with updateDocValues, then update the same document again with tryUpdateDocValue before the writer flushes or refreshes: the earlier update wins. In the repro the term update sets n=2 (sequence number 5), the try call sets n=3 and succeeds (sequence number 6), and a fresh NRT reader and the commit both show n=2. Lucene raises no exception and CheckIndex is clean. It needs only default settings and two documents, and every release since the API appeared in 7.4.0 has it; with a soft-deletes field the same mechanism loses an un-delete.
Lucene has two ways to change a document's doc value without re-indexing it:
updateDocValues(Term, Field...)changes every document that matches a term. It is buffered: the writer keeps it in memory and resolves it later, for example at the next flush, NRT reopen or commit, when any segment is flushed, or when the buffer uses too much RAM.tryUpdateDocValue(IndexReader, int docID, Field...)(an "Expert" method) changes one document, addressed by its doc ID in an NRT reader of the same writer. It looks the document up right away and queues the change on that segment's pending updates, tagged with a delete generation taken at call time (see below). It returns a sequence number (or -1, meaning "retry").
Every change returns a sequence number. The IndexWriter javadoc says it "expresses the effective order in which each change was applied". So when two changes touch the same document, the one with the larger number should win.
| step | call | returns | value you expect |
|---|---|---|---|
| 1 | add id:1 with n=1 (doc 0) and id:2 | n=1 | |
| 2 | r = DirectoryReader.open(writer) | ||
| 3 | updateDocValues(id:1, n=2) | sequence number 5 | n=2 |
| 4 | tryUpdateDocValue(r, 0, n=3) | sequence number 6 (success) | n=3 |
| 5 | open a fresh NRT reader, then commit | n=3 |
What you get: n=2, in the fresh reader and in the commit. The later update is gone, silently. If you call flush() between steps 3 and 4, you get n=3.
With a soft-deletes field, step 3 soft-deletes id:1 by term and step 4 un-deletes it with tryUpdateDocValue(r, 0, soft=null), the way its javadoc suggests. You expect 2 live documents and get 1.
Inside the writer, conflicting doc-values updates are not ordered by sequence number but by a second counter, the delete generation. The two methods read that counter at different times:
- Step 3 waits without a generation.
updateDocValuesonly appends the update to the writer's global buffer (IndexWriter.java:2074->DocumentsWriter.java:149-151->DocumentsWriterDeleteQueue.java:135-139). It gets sequence number 5, but no generation yet. - Step 4 takes the next generation at once.
tryUpdateDocValuerunslong nextGen = bufferedUpdatesStream.getNextGen();(IndexWriter.java:1729) and adds the update to the segment's pending updates (rld.addDVUpdate(updates),:1775). In the repro that is generation 2. Nothing intryModifyDocument(:1791-1829) first gives the buffered update a generation. - Step 5 gives the buffered update the generation after that. When the buffer is frozen and published (here by the new NRT reader or the commit; any flush, in any thread, or a RAM-triggered apply does it too),
packet.setDelGen(nextGen++)(BufferedUpdatesStream.java:75) gives it generation 3. It still applies to the old segment (FrozenBufferedUpdates.java:191). - The larger generation wins. Pending updates of one document are merged "favoring the largest delGen" (
DocValuesFieldUpdates.java:153-168, used atReadersAndUpdates.java:427). Generation 3 (n=2) beats generation 2 (n=3). The same merge feeds the soft-deletes count (ReadersAndUpdates.java:429), so the un-delete loses the same way.
Sequence numbers say 5 then 6; generations say 3 after 2; the generation decides. A flush between the two calls gives the term update its generation first, which is why the outcome depends on timing. Fix option C (below) shows that the order of the generations alone decides: it gives the buffered update its generation before the try call without applying it first, and the later update then wins.
Exposure and likelihood440 words
- Who is hit. Applications that call
tryUpdateDocValueon a document that also got a term-based doc-values update (updateDocValues,updateNumericDocValue,updateBinaryDocValue, or a soft delete by term) after the NRT reader used for thetrycall was opened, with nothing in between that froze the buffer (a flush, reopen, commit, a segment flush in any thread, or a RAM-triggered apply).updateSortedNumericDocValueand the sorted-numeric branch oftryUpdateDocValue(both added by #16581) take the same path (code reading, not run). Two natural patterns: a bulk update by term followed by a per-document override, and a fast path that falls back toupdateDocValues(Term)whentryUpdateDocValuereturns -1, as the stress test does. That fallback is legal, though on -1 the javadoc itself says to resolve the document again and retry; it recommendsupdateDocValues(Term)only for documents indexed after the reader was opened, which alone cannot hit this window. - Default settings. The numeric case needs only the default
IndexWriterConfig, codec and merge policy;tryUpdateDocValueis a public "Expert" API. The un-delete case needs the opt-in soft-deletes field. - No scale needed; frequent in one concurrent workload. Two documents and a handful of calls reproduce it, with the same failure on all 5 seeds tried (they ran with different randomized test codecs). In a 4-thread, update-heavy stress test where each thread owns its documents and mixes term updates and
tryupdates 50/50 (200 documents, 2,000 updates), 21 to 67 of the 200 documents end with an older value in each of 10 runs on main (5 with assertions on, 5 off), and 45 on 10.5.2; over all 14 stock-main runs the range is 21-68. With term updates only: 0. Other workloads were not measured. - Silent and durable. Lucene raises no exception and no internal assertion fires under
-ea; CheckIndex is clean; the wrong value is committed. A lost un-delete leaves the document soft-deleted, so a merge can reclaim it unless a retention policy keeps it. - Looks intermittent. Anything that freezes the buffer between the two calls hides it: a flush, an NRT reopen, a commit, a segment flush that a document-count or RAM limit triggers in another thread, or a RAM-triggered apply.
- Reach. A code search found no
tryUpdateDocValuecallers in Elasticsearch, OpenSearch or Solr, so the known exposure is custom applications using the expert API (the search says nothing about private code). - Security. No direct security impact identified: the trigger is the application's own correct API calls. The downstream impact depends on how the fields are used; where a doc value or a soft delete drives access control, tenant visibility or retention, a lost update would affect that too.
The test shows that IndexWriter#tryUpdateDocValue (n=3) on a document that has an earlier, still-buffered updateNumericDocValue by term (n=2) succeeds, but the earlier update wins.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestTryUpdateDocValueAfterBufferedUpdate extends LuceneTestCase {
public void testLaterTryUpdateDocValueWins() throws Exception {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
Document doc = new Document();
doc.add(new StringField("id", "1", Field.Store.NO));
doc.add(new NumericDocValuesField("n", 1));
w.addDocument(doc);
try (DirectoryReader r = DirectoryReader.open(w)) {
w.updateNumericDocValue(new Term("id", "1"), "n", 2);
assertNotEquals(-1, w.tryUpdateDocValue(r, 0, new NumericDocValuesField("n", 3)));
}
try (DirectoryReader r = DirectoryReader.open(w)) {
NumericDocValues n = r.leaves().get(0).reader().getNumericDocValues("n");
assertTrue(n.advanceExact(0));
// expected 3 (the later tryUpdateDocValue); actual 2 (the earlier, still buffered update)
assertEquals(3, n.longValue());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-009/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestTryUpdateDocValueAfterBufferedUpdate
- Expected vs actual
- Expected the next NRT reader to show the later value
n=3; actual: the earliern=2(expected:<3> but was:<2>), with-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<3> but was:<2>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.index.TestTryUpdateDocValueAfterBufferedUpdate.testLaterTryUpdateDocValueWins(TestTryUpdateDocValueAfterBufferedUpdate.java:46)- Sequence numbers express "the effective order in which each change was applied" (
IndexWriterjavadoc); in the single-threaded repro thetrycall gets 6, after 5, yet loses. - The
tryUpdateDocValuejavadoc's conditions hold: the call succeeds and doc 0 is visible. TestMixedDocValuesUpdatesasserts last-write-wins across both APIs; it passes only because it opens a fresh reader before eachtrycall.
The full argument276 words
- The ordering contract.
IndexWriterclass javadoc (IndexWriter.java:117-118): each change returns a sequence number "which expresses the effective order in which each change was applied".CHANGES.txt:8925-8927(LUCENE-7302, which introduced them): the numbers indicate "the effective equivalent single-threaded execution order". The repro is single-threaded. - The method's own contract is met. The
tryUpdateDocValuejavadoc (IndexWriter.java:1708-1720) promises that for an NRT reader of this writer whose segment was not merged away "the update succeeds and this method returns a valid (> 0) sequence number"; its only other limit is that it "can only update documents visible to the currently open NRT reader". Both hold: it returns 6 and doc 0 is visible. It also says: "This can be used to un-delete a soft-deleted document" (andCHANGES.txt:7390-7393, LUCENE-8298: "This allows to undelete a soft-deleted document"). - Lucene's own test assumes last-write-wins.
TestMixedDocValuesUpdates.testTryUpdateMultiThreaded(:499) mixesupdateDocValues(:531) andtryUpdateDocValuethrough its helperdoUpdate(:534) on the same documents, records the last value written to each (:539) and asserts it at the end (:578). It passes only becausedoUpdate(:589-599) opens a fresh NRT reader, which freezes the buffer, right before everytrycall;TestSoftDeletesRetentionMergePolicy.doUpdate(:781-792) does the same. So no upstream test reaches the window. - History. The API arrived with LUCENE-8297 (#9344, by @s1monw, reviewed by @mikemccand; 2018) taking its generation with
getNextGen()at call time from its first commit; the thread does not discuss ordering. #8355 (LUCENE-7301, 2016) fixed an older doc-values update winning over a newer one inside one batch as data loss. #8398 (LUCENE-7344, open) is the same family for delete-by-query.
The devil's-advocate check248 words
Strongest case for "intended": tryUpdateDocValue is an Expert, point-in-time API, and the NRT reader it needs comes from DirectoryReader.open(IndexWriter), which is marked @lucene.experimental. It updates the document as reader r sees it, and a term update buffered after r was opened is "newer than the reader", so resolving it later is consistent with r. The javadoc promises success and visibility, not last-write-wins against pending term updates, and Lucene's own test helpers open a fresh reader before every try call.
Why it fails: the call returns a larger sequence number than the term update, and sequence numbers are defined as the effective order, with no ambiguity in one thread. The visibility note is about which documents can be addressed, and this one is visible; the javadoc does not ask for a fresh reader before each call. The same two calls give n=3 when any flush happens in between, so the result follows hidden buffer state, not the reader's snapshot. Lucene's own multi-threaded test asserts last-write-wins across the two APIs. An independent reviewer from another model family (OpenAI GPT-5.6) built a similar argument and rated it weak: "Expert" and "experimental" are stability labels, "not permission for undocumented ordering reversal". Verdict: not intended.
The un-delete case, separately: IndexWriterConfig.java:557-559 says "Currently there is no API support to un-delete a soft-deleted document." That contradicts the method javadoc and the 7.4.0 CHANGES entry, so the un-delete case carries a documentation conflict of its own; it does not affect the numeric case.
Recommended C: publish the buffered updates first, otherwise return -1
Before taking its generation, tryUpdateDocValue publishes the buffered updates so they get theirs first; if anything is still unpublished it returns the documented -1 ("retry"). The repro and the 4-thread stress test pass, but there 73-94% of try calls return -1. Option A (flush first) never declines but makes 1,001 segments instead of 2 in a cost probe.
public long tryUpdateDocValue(IndexReader readerIn, int docID, Field... fields) throws IOException {
if (docWriter.anyUnpublishedDeletesOrUpdates()) {
docWriter.freezeGlobalBuffer(); // like DocumentsWriter#applyAllDeletes
publishFlushedSegments(true); // gives the frozen packet its delete generation
}
return tryUpdateDocValueInternal(readerIn, docID, fields); // synchronized; returns -1 if
} // anything is still unpublished
// DocumentsWriter
boolean anyUnpublishedDeletesOrUpdates() {
return anyDeletions() || ticketQueue.hasTickets() || pendingChangesInCurrentFullFlush;
}All options and trade-offs5 options ยท 936 words
The root cause is that a try update takes a generation before earlier buffered updates have one. A fix either gives the earlier updates their generation first, or declines the try path while they have none. Two options were built and validated on a scratch worktree at ea57360912ed (removed afterwards); the others are untested ideas. Every option changes only tryUpdateDocValue; tryDeleteDocument is unchanged, and whether it has an analogous ordering issue was not examined.
Before taking its generation, tryUpdateDocValue freezes the global buffer into a flush ticket, as the RAM-triggered apply does (DocumentsWriter.java:168-186), and force-publishes the ticket queue outside the IndexWriter lock (as the onDeletesApplied event does, IndexWriter.java:458-466). Under the lock, if anything is still unpublished (a segment flush still running ahead in the queue, a full flush, or a new update from another thread) it returns -1, which the javadoc already tells callers to handle. +40/-1 lines in IndexWriter.java and DocumentsWriter.java.
public long tryUpdateDocValue(IndexReader readerIn, int docID, Field... fields) throws IOException {
if (docWriter.anyUnpublishedDeletesOrUpdates()) {
docWriter.freezeGlobalBuffer(); // like DocumentsWriter#applyAllDeletes
publishFlushedSegments(true); // gives the frozen packet its delete generation
}
return tryUpdateDocValueInternal(readerIn, docID, fields); // synchronized; returns -1 if
} // anything is still unpublished
// DocumentsWriter
boolean anyUnpublishedDeletesOrUpdates() {
return anyDeletions() || ticketQueue.hasTickets() || pendingChangesInCurrentFullFlush;
}Trade-offs
- Single-threaded callers: no extra segments. In a cost probe (1,000 rounds of add + term update +
try, NoMergePolicy) all 1,000trycalls succeed and 2 segments remain, as on stock. The timed loop took 0.83-0.87 s against 0.65-1.09 s on stock (3 runs each; too few to rank them). The timer stops before the final NRT open that applies the queued packets, so the end-to-end cost, the memory of the extra small packets and the next-refresh latency were not measured. - Concurrent writers: the call declines much more often: 73-94% of
trycalls returned -1 in 17 runs of the 4-thread stress test (stock: 0%). The fallback keeps the data right, but thetryfast path is mostly lost under heavy concurrent updates. The check also fires on unrelated buffered hard deletes, delete-by-query and flushes, so mixed workloads may decline more (not measured). - The public method becomes an unsynchronized wrapper around a synchronized one, because the forced publish must run without the
IndexWriterlock; it can wait on the ticket-queue purge lock, and code that synchronizes on the writer no longer excludes the publish step. The publish also runs before the fields and the reader's segment are validated, so a call that then throws or returns -1 may already have published. The new -1 cause should be documented. - Not tested: a concurrent update of the same document by another thread, issued between the check and the sequence number, can still be ordered by generation. Callers that serialize per document are not affected.
- Does not fix the sister bug LFV-010 (later term ops ignoring a
tryun-delete).
What the checks validated
- The repro passes 3/3 with assertions on and off; the stress test has 0 wrong documents in 5 runs with
-eaand 5 with-da. :lucene:core:teston 17 targeted suites (the two new tests,TestMixedDocValuesUpdates,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestSortedNumericDocValuesUpdates,TestIncrementalDocValuesUpdates,TestDocValuesFieldUpdates,TestSoftDeletesRetentionMergePolicy,TestSoftDeletesDirectoryReaderWrapper,TestPendingSoftDeletes,TestTryDelete,TestRollingUpdates,TestIndexWriterDelete,TestIndexWriterReader,TestIndexWriter,TestStressNRT): 321 tests, 0 failures.--tests 'org.apache.lucene.index.*': 216 classes, 1,398 tests, 0 failures; the new tests andTestMixedDocValuesUpdatesalso pass with-Ptests.asserts=false -Ptests.iters=5.:lucene:core:check -x testpasses (google-java-format, ecjLint, forbiddenApis) aftertidyre-wrapped a few lines of the new tests.
When anything is unpublished, call flush(false, false) (a full flush that neither applies deletes nor merges) before the synchronized part. The same idea as a patch experiment by the study's Alloy verifier. +21/-1 lines.
Trade-offs
- Measured cost: the cost probe makes 1,001 segments instead of 2 and takes about 25 s instead of about 0.5-0.6 s (3 runs each): every
trycall that follows any buffered delete or update, even of another document, runs a full flush, which writes a new segment whenever documents are buffered (the probe adds one every round). - No new -1 results, but callers can see the change: the extra full flush creates segments, takes time, can surface flush exceptions and changes writer state. Like C, it moves
synchronizedoff the public method and flushes before validating the fields and the reader. Same untested concurrent-race caveat as C.
What the checks validated
- The repro passes 3/3 with
-eaand-da; the stress test has 0 wrong documents in 10 runs, with everytrycall succeeding. - 16 targeted suites (as for C, without the stress test): 320 tests, 0 failures;
org.apache.lucene.index.*: 215 classes, 1,397 tests, 0 failures.
Return -1 whenever anything is unpublished, without publishing first. Smallest patch.
Trade-offs
In single-threaded code the call would decline after every buffered update until something freezes and publishes the buffer (a new NRT reader, a flush, a commit, a segment flush or a RAM-triggered apply) (inferred from C's check; not run).
Route the doc-ID-addressed update through the delete queue so its generation follows queue order, as the study's TLA+ verifier suggested.
Trade-offs
Would also close the concurrent race, but it is a larger change to the update machinery.
State that try* updates can be overridden by earlier buffered updates until the next refresh.
Trade-offs
Contradicts the sequence-number contract; the cheapest change.
Model-based randomized testing: an AI-written harness compared every reader with an executable reference model of the writer and flagged this about 4 s into a smoke run. Alloy and TLA+ re-found it later. The study was pointed at this area by an earlier bug, not at this one.
The method in full145 words
Method: model-based randomized testing (the study's non-formal baseline), with blind re-finds by two formal tools.
- A 2026-10-22 study of Lucene by AI tools covered the IndexWriter/NRT doc-values layer because an earlier bug there (now #16900) was the study's starting point. So the area was hinted; this bug was not.
- An AI tool wrote a model-based testing harness: an executable reference model of the writer, random operation sequences, and a comparison of every reader against the model. Its model-value comparison flagged this bug in a smoke run, about 4 s in.
- Alloy (SAT4J solver) and TLA+ (the TLC model checker) re-found it later. Later P, Dafny, JBMC and OpenJML checks were told about it.
- Independent AI verifiers reproduced it and voted "new bug"; this hunt re-verified it on
ea57360912ed, ran the release history and the prior-art search, had it reviewed adversarially, and validated two fixes.
No report found: 151 queries over GitHub issues and PRs, mailing lists, JIRA, commits, downstream code and the web, and no open PR on this path (2026-10-25); upstream has not fixed it. Related: #8398 (open, the same family for delete-by-query) and #8355 (a 2016 fix of an older update winning).
Searches6 searches
No report of this defect was found, and upstream has not fixed it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), with migrated JIRA issues | 2026-10-25 | 70 queries from the scripted prior-report search (class and method names such as tryUpdateDocValue(s), tryModifyDocument, getNextGen, mergedIterator, BufferedUpdatesStream; symptoms such as "update lost", "last write wins", "undelete lost"), plus manual gh search phrasings | no report; closest: #8398 (open, related family), #8355 (fixed 2016, precedent), #9344 and #9345 (the API and null-reset, no ordering discussion), #7444 (open, about try* rarely returning -1), #16900 (a different bug in the same area) |
| Open PRs | 2026-10-25 | the 28 open PRs (of 412) touching any of eleven relevant index files, diffs grepped for tryModifyDocument, tryUpdateDocValue, getNextGen, mergedIterator, setDelGen, nextGen | none on this path (#13486 adds an unrelated setDelGen) |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-25 | 48 queries, plus a direct full-text search for tryUpdateDocValue(s) | only JIRA, GitHub, commit and release-candidate notifications |
| JIRA archive (LUCENE, SOLR) | 2026-10-25 | 12 queries | LUCENE-8297 and LUCENE-9164 only |
| Commits, downstream code, web | 2026-10-25 | 6 commit queries, 5 downstream (Elasticsearch, OpenSearch, Solr), 10 web | no fix on main; no downstream callers of tryUpdateDocValue |
| Study searches | 2026-10-22 | GitHub, JIRA JQL, lists.apache.org, web (verifier README section 4) | the same related items, no report |
Introduced by LUCENE-8297 (#9344, 2018, first released in 7.4.0), which took the generation at call time from its first commit (pinned by commit-level runs); LUCENE-8298 added the un-delete form in the same release. All 67 releases from 7.4.0 to 10.5.2 and main are affected; 7.3.1 lacks the API.
Versions tested11 rows
Introduced by LUCENE-8297 "Add IW#tryUpdateDocValues(Reader, int, Fields...)" (#9344, @s1monw), committed 2018-05-07 as 48a2138e899 on the 7.x line (first released in 7.4.0) and b0b32931b28 on master; both take the generation with getNextGen() at call time from the first commit. The un-delete form became possible with LUCENE-8298 (0c65af048b3 / a3c86373e47, 2018-05-09, also in 7.4.0). Every release since is affected, and main is not fixed.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.3.1 | API absent | the test does not compile: tryUpdateDocValue, setSoftDeletesField and SoftDeletesDirectoryReaderWrapper do not exist |
| 7.4.0 | first affected | 2 of 3 fail (also with -da); seq 6 and 7 |
| 7.5.0-7.7.3 (6 releases) | affected | 2 of 3 fail |
| 8.0.0-8.11.4 (27 releases) | affected | 2 of 3 fail |
| 9.0.0-9.12.3 (21 releases) | affected | 2 of 3 fail; 9.12.3 also with -da |
| 10.0.0-10.5.2 (12 releases) | affected | 2 of 3 fail; 10.5.2 with -ea and -da; stress test 45 of 200 wrong |
main ea57360912ed | affected | 2 of 3 fail with -ea and -da, 5 seeds; stress test 21-67 of 200 wrong |
7.x 0600a58f005 / master 6521d86dded (parents of LUCENE-8297) | API absent | do not compile |
7.x 48a2138e899 / master b0b32931b28 (LUCENE-8297) | commit-level | numeric bug test fails, control passes |
7.x 0c65af048b3 (LUCENE-8298) | commit-level | both bug tests fail, control passes |
fix C / fix A on ea57360912ed | fixed | repro 3/3 pass; stress 0 wrong |
Study verifiers voted "new bug"; a hunt re-verification and an adversarial cross-model review with its own repro confirmed it. Three checks of the brief, this doc and its diagrams (a fact check that re-ran the repro, a cross-model fact check and a diagram check) confirmed the bug and its numbers; every error they found in the text was fixed.
Checks and verdicts402 words
Every claim on this page was re-run or re-read on ea57360912ed in this hunt; the study's checks came first, and independent checks of this page followed.
| Check | Verdict | What it found |
|---|---|---|
| Study verifiers | new bug | model-based testing verifier, Alloy verifier, TLA+ verifier, Dafny verifier, JBMC verifier, JML verifier and P-model verifier each reproduced it or modelled it and voted new. |
| Public-list rerun | reproduces | The study repro on main b83101cdd2: Tests run 8, Failures 3. |
| Hunt re-verification | real | Minimal 3-test JUnit fails on main with -ea and -da, 5 seeds, the default codec forced, 10.5.2 and 9.12.3; the flush control passes. |
| Cross-model refute review | real, high confidence | OpenAI GPT-5.6 wrote its own test (term update to 41, then a successful try update to 99; a fresh reader shows 41), ran it with -ea and -da, rated the intended-behaviour argument weak, and said "data corruption" overstates the structural impact: the index stays readable. |
| History | since 7.4.0 | All 67 releases 7.4.0-10.5.2 affected; pinned to LUCENE-8297 by commit-level runs. |
| Prior art | new | 151 tool queries plus manual searches; no report, no fix, no open PR. |
| Fix validation | 2 fixes validated | Options C and A pass the repro, the stress test and the index test package; C also passes :lucene:core:check; their costs were measured. |
| Security screen | not security | Out of scope, public. No direct security impact identified; the downstream impact depends on how the application uses the fields. |
| Independent fact check | pass, 9 minor items | A separate AI tool re-ran the brief's gradle command (3 tests, 2 failures, with assertions on and off) and fix C (3/3 pass), and checked every cited line, commit and count. Its 9 wording and staleness items were fixed. |
| Cross-model fact check | real; 6 errors fixed | An independent reviewer from another model family (OpenAI GPT-5.6) confirmed the repro, the generation trace, the versions and the line references, found 6 factual errors in the brief (the largest: the study's control 2 also applies the update, so it is not freeze-only), 8 overclaims and 7 missing caveats, and rated the intended-behaviour case weak. All were fixed or added. |
| Diagram check | pass, 5 polish items | Every value, API line and order of events in the card matches the code and the runs. Four polish items were applied and the diagrams re-rendered; the fifth concerns the registry. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 426 KB
Finding record, Lucene and upstream sources, repro files and diagram notes568 words
- Registry title
- tryUpdateDocValue is overwritten by an earlier, still-buffered doc-values update
- Area
- index / doc-values updates
- Priority
- Medium, rank 15: Durable wrong doc-values value (or lost un-delete) whenever an expert tryUpdateDocValue follows a buffered term update of the same document within one refresh, a wide window that hurts correctly written apps, but the population mixing these APIs is small and nothing was reported since 7.4.
- Impact class
- data corruption
- Impact note
- Default configuration, but only applications that mix the expert tryUpdateDocValue with an earlier buffered term-based doc-values update on the same document; released since 7.4.0.
- Summary
- You update a doc-values field by term and, before that buffered update is resolved, call tryUpdateDocValue on the same document. You expect the later tryUpdateDocValue to win, as sequence numbers say. Instead it is applied in place with the next delete generation, the earlier term update resolves later and wins, and the wrong value (or a lost un-delete) is durable.
- Introduced
- LUCENE-8297 (#9344; 48a2138e899 on branch_7x, b0b32931b28 on master) [exec: commit-level runs] (2018-05-07)
- First affected version
- 7.4.0 [exec; first release with tryUpdateDocValue]
- Affected versions
- 7.4.0 to 10.5.2 (all 67 releases) and main [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- non-formal baseline: randomized/MBT; also reached by TLA+/TLC, Alloy
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
ea57360912ed(commit 2026-10-24; tested 2026-10-25, re-run 2026-10-26), assertions on and off, 5 seeds; the Maven Central jars of all 67 releases from 7.4.0 to 10.5.2 and of 7.3.1; the LUCENE-8297 and LUCENE-8298 commits and their parents (commit-level runs); fix options A and C in a scratch worktree atea57360912ed(repro, a 4-thread stress test and theorg.apache.lucene.indextest package; C also:lucene:core:check).
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-118, 1708-1720, 1729, 1775, 1791-1829, 2070-2079Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/BufferedUpdatesStream.java:67-75, 258-260; DocumentsWriter.java:149-151; DocumentsWriterDeleteQueue.java:135-139Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:429 (every segment flush freezes the global buffer); DocumentsWriter.java:168-186 (RAM-triggered apply); IndexWriter.java:2928-2945 (publishing only queues the packet's apply)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:191; DocValuesFieldUpdates.java:153-168; ReadersAndUpdates.java:153, 427, 429
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1711-1712, 1717-1719 (what the javadoc says to do on -1 and for newer documents), 1739, 1755, 2037-2052 (sorted-numeric path); DocumentsWriterPerThread.java:429
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-118, 1708-1720; IndexWriterConfig.java:557-559; DirectoryReader.java:120-122 (@lucene.experimental)Lucene main ea57360912ed: lucene/CHANGES.txt:7384-7393 (LUCENE-8297, LUCENE-8298), 8925-8927 (LUCENE-7302)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestMixedDocValuesUpdates.java:499, 531, 534, 539, 578, 589-599; TestSoftDeletesRetentionMergePolicy.java:781-792github.com/apache/lucene/issues/9344 (migrated LUCENE-8297: author @s1monw, comments @mikemccand), read 2026-10-25
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:168-186 (applyAllDeletes); IndexWriter.java:458-466 (onDeletesApplied)
- TestTryUpdateDocValueAfterBufferedUpdate.java (JUnit test):
repro/LFV-009/src/org/apache/lucene/index/TestTryUpdateDocValueAfterBufferedUpdate.java(50 lines)
A picture of the two clocks: the sequence numbers the calls return, and the delete generations that actually decide which update survives. The still is the summary card. An independent check compared every value, API line and order of events with the code; after its polish notes the diagram was re-rendered and re-checked.
assets/LFV-009/bug.png(426 KB)
