All findings · Lucene finding · LFV-009 · tryupdatedocvalue-loses-to-buffered-update

tryUpdateDocValue loses to an earlier doc-values update that is still buffered

Update a document's doc value by term, then change the same document with tryUpdateDocValue before the writer flushes or refreshes: the earlier update silently wins, although the later call succeeded and returned the larger sequence number. It needs only default settings and is in every release since 7.4.0.

Summary

Priority
Medium, rank 15Durable 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
data corruption
Since
7.4.0 (LUCENE-8297, 2018)introduced 2018-05-07 · about 8 years
Affected
7.4.0 to 10.5.2 (all 67 releases) and main
Status
documented
Found by
model-based randomized testing, re-found by Alloy and TLA+

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Two updates of the same document on one timeline: updateDocValues(id:1, n=2) gets sequence number 5 but waits in the buffer without a delete generation; tryUpdateDocValue(r, doc 0, n=3) gets sequence number 6 and delete generation 2 at once; at the next refresh or commit the buffered update gets generation 3, the larger generation wins, and the reader and the commit show n=2 instead of n=3.
The two clocks on one timeline: the sequence numbers the calls return (5, then 6) and the delete generations that decide which update survives (2 for the try call, 3 for the buffered update). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index two documents with the default IndexWriterConfig, id:1 (doc 0, n=1) and id:2, and open an NRT reader r from the writer.
  2. Call updateDocValues(id:1, n=2), which is buffered and returns sequence number 5, then tryUpdateDocValue(r, 0, n=3), which succeeds and returns 6.
  3. You expect n=3 in a fresh NRT reader and in the commit: sequence numbers give the effective order, so 6 should win over 5.
  4. Instead both show n=2. The try call 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.
  5. No exception, CheckIndex is clean, and the wrong value is committed. A flush() between the two calls gives n=3. With the opt-in soft-deletes field, a soft delete by term followed by a try un-delete loses the un-delete (1 live document instead of 2).

Who is affected

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.

What you do

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.

A tiny example

stepcallreturnsvalue you expect
1add id:1 with n=1 (doc 0) and id:2n=1
2r = DirectoryReader.open(writer)
3updateDocValues(id:1, n=2)sequence number 5n=2
4tryUpdateDocValue(r, 0, n=3)sequence number 6 (success)n=3
5open a fresh NRT reader, then commitn=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.

Why: two clocks

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:

  1. Step 3 waits without a generation. updateDocValues only 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.
  2. Step 4 takes the next generation at once. tryUpdateDocValue runs long 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 in tryModifyDocument (:1791-1829) first gives the buffered update a generation.
  3. 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).
  4. The larger generation wins. Pending updates of one document are merged "favoring the largest delGen" (DocValuesFieldUpdates.java:153-168, used at ReadersAndUpdates.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 tryUpdateDocValue on 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 the try call 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). updateSortedNumericDocValue and the sorted-numeric branch of tryUpdateDocValue (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 to updateDocValues(Term) when tryUpdateDocValue returns -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 recommends updateDocValues(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; tryUpdateDocValue is 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 try updates 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 tryUpdateDocValue callers 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.

Minimal repro

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.

TestTryUpdateDocValueAfterBufferedUpdate.java (JUnit test)50 lines
/*
 * 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());
      }
    }
  }
}
Command
# 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 earlier n=2 (expected:<3> but was:<2>), with -ea and -da.

All files of this repro: repro/LFV-009/

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • Sequence numbers express "the effective order in which each change was applied" (IndexWriter javadoc); in the single-threaded repro the try call gets 6, after 5, yet loses.
  • The tryUpdateDocValue javadoc's conditions hold: the call succeeds and doc 0 is visible.
  • TestMixedDocValuesUpdates asserts last-write-wins across both APIs; it passes only because it opens a fresh reader before each try call.
The full argument276 words
  • The ordering contract. IndexWriter class 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 tryUpdateDocValue javadoc (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" (and CHANGES.txt:7390-7393, LUCENE-8298: "This allows to undelete a soft-deleted document").
  • Lucene's own test assumes last-write-wins. TestMixedDocValuesUpdates.testTryUpdateMultiThreaded (:499) mixes updateDocValues (:531) and tryUpdateDocValue through its helper doUpdate (:534) on the same documents, records the last value written to each (:539) and asserts it at the end (:578). It passes only because doUpdate (:589-599) opens a fresh NRT reader, which freezes the buffer, right before every try call; 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.

Suggested fix

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.

C: publish the buffered updates first, otherwise return -1 (validated by the checks) (recommended)

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,000 try calls 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 try calls returned -1 in 17 runs of the 4-thread stress test (stock: 0%). The fallback keeps the data right, but the try fast 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 IndexWriter lock; 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 try un-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 -ea and 5 with -da.
  • :lucene:core:test on 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 and TestMixedDocValuesUpdates also pass with -Ptests.asserts=false -Ptests.iters=5.
  • :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis) after tidy re-wrapped a few lines of the new tests.

A: flush before the try update (validated by the checks)

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 try call 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 synchronized off 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 -ea and -da; the stress test has 0 wrong documents in 10 runs, with every try call 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.

B: only decline (untested)

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).

D: order the try update through the delete queue (untested)

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.

E: document the behaviour (untested)

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), with migrated JIRA issues2026-10-2570 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 phrasingsno 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 PRs2026-10-25the 28 open PRs (of 412) touching any of eleven relevant index files, diffs grepped for tryModifyDocument, tryUpdateDocValue, getNextGen, mergedIterator, setDelGen, nextGennone on this path (#13486 adds an unrelated setDelGen)
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2548 queries, plus a direct full-text search for tryUpdateDocValue(s)only JIRA, GitHub, commit and release-candidate notifications
JIRA archive (LUCENE, SOLR)2026-10-2512 queriesLUCENE-8297 and LUCENE-9164 only
Commits, downstream code, web2026-10-256 commit queries, 5 downstream (Elasticsearch, OpenSearch, Solr), 10 webno fix on main; no downstream callers of tryUpdateDocValue
Study searches2026-10-22GitHub, JIRA JQL, lists.apache.org, web (verifier README section 4)the same related items, no report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
7.3.1 API absentthe test does not compile: tryUpdateDocValue, setSoftDeletesField and SoftDeletesDirectoryReaderWrapper do not exist
7.4.0 first affected2 of 3 fail (also with -da); seq 6 and 7
7.5.0-7.7.3 (6 releases) affected2 of 3 fail
8.0.0-8.11.4 (27 releases) affected2 of 3 fail
9.0.0-9.12.3 (21 releases) affected2 of 3 fail; 9.12.3 also with -da
10.0.0-10.5.2 (12 releases) affected2 of 3 fail; 10.5.2 with -ea and -da; stress test 45 of 200 wrong
main ea57360912ed affected2 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 absentdo not compile
7.x 48a2138e899 / master b0b32931b28 (LUCENE-8297) commit-levelnumeric bug test fails, control passes
7.x 0c65af048b3 (LUCENE-8298) commit-levelboth bug tests fail, control passes
fix C / fix A on ea57360912ed fixedrepro 3/3 pass; stress 0 wrong

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifiers new bugmodel-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 reproducesThe study repro on main b83101cdd2: Tests run 8, Failures 3.
Hunt re-verification realMinimal 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 confidenceOpenAI 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.0All 67 releases 7.4.0-10.5.2 affected; pinned to LUCENE-8297 by commit-level runs.
Prior art new151 tool queries plus manual searches; no report, no fix, no open PR.
Fix validation 2 fixes validatedOptions 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 securityOut 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 itemsA 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 fixedAn 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 itemsEvery 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes568 words

Finding record

LFV-009, snapshot of 2026-11-06

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 at ea57360912ed (repro, a 4-thread stress test and the org.apache.lucene.index test package; C also :lucene:core:check).

Lucene and upstream sources

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-2079
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/BufferedUpdatesStream.java:67-75, 258-260; DocumentsWriter.java:149-151; DocumentsWriterDeleteQueue.java:135-139
  • Lucene 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-792
  • github.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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.