All findings · Lucene finding · LFV-1075 · tryupdatedocvalue-skips-reader-pool-ram-check

IndexWriter.tryUpdateDocValue never checks the RAM buffer, so pending updates grow the heap until the next refresh

Every successful IndexWriter.tryUpdateDocValue call parks about 334 bytes of pending updates per updated numeric field in the writer, and no RAM check ever writes them out: with a 1 MB buffer, 10,000 calls leave 3,340,000 bytes pending. Without a refresh, flush() or commit the heap keeps growing until OutOfMemoryError.

Summary

Priority
Low, rank 999In every release since 7.4.0, bulk use of the Expert tryUpdateDocValue without a refresh, flush or commit piles up updates on the heap that the RAM buffer never counts, until OutOfMemoryError. Periodic refreshes bound it and no major search server calls the API, so it is Low.
Impact
minor
Since
7.4.0 (LUCENE-8297, 2018-05-05: 48a2138e899 on branch_7x, in 7.4.0; b0b32931b28 on master)introduced 2018-05-05 · about 8 years
Affected
7.4.0-10.6.0 and main; Expert tryUpdateDocValue callers that make many calls between refreshes
Status
documented
Found by
non-formal: a heap-versus-RAM-buffer sweep over IndexWriter workloads, then one JUnit test and a bounded exhaustive check

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: where doc-values updates wait before they reach disk

  1. An update by term, such as updateNumericDocValue(new Term("id", "7"), "counter", 5), is first buffered, then resolved to document ids in a batch (a packet). The resolved per-document values wait in the writer's per-segment state, the reader pool, until they are written to disk.
  2. After every packet the writer checks the reader pool: if it holds more than half the RAM buffer (16 MB by default), it writes the largest segments' pending updates. A refresh, flush() or commit writes them too.
  3. tryUpdateDocValue (Expert) skips the batch: it takes a document id from an NRT reader and adds its resolved update to the reader pool directly.
Diagram of the bug: an IndexWriter with a 1 MB RAM buffer and an NRT reader on one segment. Each tryUpdateDocValue call adds one 334-byte doc-values update object to the segment's pending updates in the reader pool. The check that writes pending updates once the pool exceeds half the buffer (524,288 bytes) runs only after term-based update packets, so it never runs here; after 10,000 calls 3,340,000 bytes are pending and IndexWriter.ramBytesUsed() reports 0. A refresh, flush() or commit writes them; the fix calls the check after each successful try-update.
The repro on one segment: each tryUpdateDocValue call adds a 334-byte update to the writer's pending list, the half-buffer check never runs on this path, and 3,340,000 bytes are left after 10,000 calls. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open a writer with a 1 MB RAM buffer, add and commit one document with a numeric doc-values field counter, and open an NRT reader on the writer.
  2. Call writer.tryUpdateDocValue(reader, 0, new NumericDocValuesField("counter", i)) 10,000 times. Every call succeeds.
  3. You expect the writer to write the pending updates to disk once they pass half the buffer (524,288 bytes, after 1,570 calls), as it does for the same updates made through updateNumericDocValue(Term, ...).
  4. Instead 3,340,000 bytes stay pending: 10,000 separate update objects of 334 bytes, none written, and IndexWriter.ramBytesUsed() does not count them.
  5. Keep going with the default 16 MB buffer and no refresh: 73, 138 and 202 MiB of heap after 187,500, 375,000 and 562,500 calls, then OutOfMemoryError in a 256 MB heap. The term-based path stays at 13 to 24 MiB for 1.5 million calls.

Who is affected

Applications that call the Expert tryUpdateDocValue (counters, soft-deletes, un-deletes) many times between NRT refreshes, flush() or commit, with default settings otherwise. Each call keeps about 334 bytes per numeric field (about 530 for an 8-byte binary value); a soft-delete pass over a 200,000-document segment leaves 66.8 MB pending. Any refresh, flush() or commit bounds it again.

Mechanism, terms and numbers710 words

IndexWriter.tryUpdateDocValue (Expert) updates a doc value of a document found through an NRT reader. Each successful call builds a new per-field update object and adds it straight to the segment's pending updates in the writer's reader pool. The writer's rule for those pending updates, "If the reader pool is > 50% of our IW buffer, then write the updates", runs only after a packet of buffered deletes or updates has been applied, which the try path never creates. So nothing ever compares the try-updates with the RAM buffer: they stay on the heap, about 334 bytes per numeric field update and about 530 per binary one (8-byte values), until an NRT refresh, flush(), commit, a merge of the segment or an unrelated term-based update writes them. With a 1 MB buffer, 10,000 calls on one document leave 3,340,000 bytes pending; with the default 16 MB buffer and a reader that is never refreshed, the heap grows linearly to OutOfMemoryError. IndexWriter.ramBytesUsed() does not count this memory. The same updates through updateNumericDocValue(Term, ...) stay bounded. Every release since the method arrived in 7.4.0 is affected; calling the existing rule after a successful try-update fixes it.

Terms

  • Reader pool / pending updates: the writer keeps one ReadersAndUpdates per segment. Resolved doc-values updates wait there as DocValuesFieldUpdates objects, one list per field, until they are written as a new doc-values generation of the segment. ReadersAndUpdates.addDVUpdate documents the plan: "We buffer these in RAM and write to disk when too much RAM is used or when a merge needs to kick off, or a commit/refresh" (ReadersAndUpdates.java:148-151).
  • The rule: IndexWriter.writeSomeDocValuesUpdates sums the reader pool and, "If the reader pool is > 50% of our IW buffer", writes the largest segments' pending updates (IndexWriter.java:807-884, test at :816; skipped when RAM flushing is disabled, :812). Its only call site runs after a frozen packet is applied: "Since we just resolved some more deletes/updates, now is a good time to write them" (:6599-6600).

The bug (main e1faeba08bde)

  1. public synchronized long tryUpdateDocValue(IndexReader readerIn, int docID, Field... fields) (IndexWriter.java:1721) builds the updates (:1724) and calls tryModifyDocument (:1725, defined at :1791-1829), which finds the segment's ReadersAndUpdates and runs the closure.
  2. The closure takes a fresh generation (:1729), creates a new NumericDocValuesFieldUpdates or BinaryDocValuesFieldUpdates per field (:1740, :1743), adds the one document, then updates.finish(); rld.addDVUpdate(updates); (:1773-1776) and changed() (:1782). Nothing runs after it.
  3. addDVUpdate appends the object (ReadersAndUpdates.java:163) and adds its size to the segment's counter (:161). It compares nothing.
  4. A try-update creates no packet, so it never reaches the rule. Pending try-updates are written only by an NRT refresh, flush(), commit, a merge of the segment, or the packet apply of an unrelated term-based delete or update. An automatic RAM-triggered segment flush from adding documents, with no buffered term-based deletes or updates, does not write them (a flush that also publishes such a packet runs the rule through that packet's apply).
  5. IndexWriter.ramBytesUsed() returns docWriter.ramBytesUsed() only (IndexWriter.java:795-799), so it reports 0 for all of this memory. It leaves out the reader pool on every path, so this is context, not a second bug.

The numbers

workloadpending or retainedbound
10,000 try-updates of one document, 1 MB buffer3,340,000 bytes pending (334 per call)524,288 (half the buffer)
20,000 try-updates of 20,000 different documents, 1 MB buffer6,680,000 bytes pending (334 per call)524,288
one try soft-delete per document, 200,000-document segment, default 16 MB buffer66.8 MB pending, ramBytesUsed() 08 MB
try-updates, 16 MB buffer, reader never refreshed, 256 MB heap73 / 138 / 202 MiB after 187,500 / 375,000 / 562,500 calls, then OutOfMemoryError-
the same values through updateNumericDocValue(Term, ...)13 to 24 MiB for 1.5 million callsbounded
try-updates with the reader refreshed every 10,000 calls9 to 12 MiB for 1.5 million callsbounded
tryDeleteDocument, reader never refreshed9 MiB for 3 million calls (fixed-size bitset)bounded

Binary try-updates cost about 530 bytes per call with 8-byte values (522 with 2-byte values). Releases 7.4.0 to 8.11.4 account 302 bytes per numeric call (3,020,000 for the repro), an accounting difference only.

Exposure and likelihood253 words
  • Who is hit. Applications that call tryUpdateDocValue on a numeric, binary or soft-deletes field many times between NRT refreshes, flush() or commit, with no term-based delete or update in between: for example one try soft-delete or un-delete per document over a large segment (the javadoc names un-deleting a soft-deleted document as a use), or a stream of per-document counter updates while refresh is paused. Inside Lucene only two tests call it; a code search found no call in Elasticsearch, OpenSearch or Solr (2026-11-02).
  • Default vs opt-in. The API is Expert and opt-in; everything else is the default IndexWriterConfig (16 MB buffer). The bug needs RAM-based flushing, which is the default: with RAM flushing disabled (DISABLE_AUTO_FLUSH, flushing by document count instead) the rule skips its check (IndexWriter.java:812), so no path has it and the try path no longer differs.
  • Scale. Growth is per call, not per document. Between 562,500 and 750,000 numeric calls fill a 256 MB heap when the reader is never refreshed. One long bulk pass over a single reader, the natural way to resolve the doc ids, is enough.
  • What bounds it. Any NRT refresh, flush(), commit, a merge of the segment, or an unrelated term-based delete or update. NRT applications that refresh every few seconds will rarely notice.
  • What does not happen. No wrong search results, no index damage, no data loss. With assertions enabled each call also gets slower (an assertion scans the growing list); that is test-only and not part of the claim.

Minimal repro

One JUnit test, one method: ByteBuffersDirectory, a 1 MB buffer, one committed document, an NRT reader, 10,000 tryUpdateDocValue calls, then one assertion on the segment's pending bytes, read through package-private members the way TestReaderPool does. Deterministic, single thread, about a second; assertions on and off fail the same way.

TestTryUpdateDocValueRamBuffer.java (one test)46 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.NumericDocValuesField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTryUpdateDocValueRamBuffer extends LuceneTestCase {

  public void testTryUpdateDocValueRespectsRamBuffer() throws Exception {
    try (ByteBuffersDirectory dir = new ByteBuffersDirectory();
        IndexWriter writer =
            new IndexWriter(dir, new IndexWriterConfig().setRAMBufferSizeMB(1))) {
      Document doc = new Document();
      doc.add(new NumericDocValuesField("counter", 0));
      writer.addDocument(doc);
      writer.commit();
      try (DirectoryReader reader = DirectoryReader.open(writer)) {
        SegmentCommitInfo info =
            ((SegmentReader) reader.leaves().get(0).reader()).getOriginalSegmentInfo();
        for (int i = 1; i <= 10_000; i++) {
          writer.tryUpdateDocValue(reader, 0, new NumericDocValuesField("counter", i));
        }
        long pending = writer.getPooledInstance(info, false).ramBytesUsed.get();
        // Expected: pending updates stay within the 1 MB RAM buffer; actual: 3,340,000 bytes.
        assertTrue("pending doc-values updates use " + pending + " bytes", pending <= 1024 * 1024);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at e1faeba08bde
cp $REPRO/TestTryUpdateDocValueRamBuffer.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestTryUpdateDocValueRamBuffer -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); the test uses no random values
Expected
At most about half the 1 MB buffer (524,288 bytes) of updates pending; the test allows the whole 1 MB.
Observed
AssertionError: pending doc-values updates use 3340000 bytes on main, assertions on and off. An earlier variant of the test (its message adds ", more than the 1 MB RAM buffer") fails with 3,340,000 bytes on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.0.0 and with 3,020,000 bytes on 8.11.4, 8.0.0, 7.7.3 and 7.4.0.

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

More files, environment, full output296 words

The smallest input: one segment with one document, and a loop of updates to that document. The oracle is the pooled segment's own byte counter (ReadersAndUpdates.ramBytesUsed), the value the writer's rule sums; it is a measured quantity read through internal API (an internal oracle), not a Java assert, and with assertions off the test fails identically. The test must sit in org.apache.lucene.index because IndexWriter.getPooledInstance and the counter are package-private, as in TestReaderPool. The assertion allows the whole 1 MB buffer, twice the rule's threshold, so it is lenient. The public symptom, OutOfMemoryError, needs about 600,000 calls per 256 MB of heap and is not part of the repro. The same test is the regression test. The test uses no random values, and every seed tried gives the same result: DEADBEEF, 5EED1234 and 1075FC01 give 3,340,000 bytes, and so does an earlier variant (it differs only in newDirectory(), a per-call return-value check and a longer message) under three more seeds and in 20 iterations.

Environment

lucenemain e1faeba08bde (12.0.0-SNAPSHOT); an earlier variant of the test fails on nine releases from 7.4.0 to 10.6.0 (see History)
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock e1faeba08bde, seed DEADBEEF, assertions on (assertions off prints the same)
TestTryUpdateDocValueRamBuffer > testTryUpdateDocValueRespectsRamBuffer FAILED
    java.lang.AssertionError: pending doc-values updates use 3340000 bytes
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:34D5FB49BB02DD94]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.index.TestTryUpdateDocValueRamBuffer.testTryUpdateDocValueRespectsRamBuffer(TestTryUpdateDocValueRamBuffer.java:42)

# with fix A or fix B: the test passes, assertions on and off

# supplementary heap probe (not the repro): 16 MB buffer, reader never refreshed, -Xmx256m, heap after GC
mode=try    after 187500 calls: heap=73MiB | after 375000: 138MiB | after 562500: 202MiB | then java.lang.OutOfMemoryError: Java heap space
mode=update after 187500 calls: heap=13MiB | ... | after 1500000: 23MiB | all 1500000 calls accepted

Why it looks unintended

  • ReadersAndUpdates.addDVUpdate, called on this path, says these updates are written "when too much RAM is used"; the writer's rule is "If the reader pool is > 50% of our IW buffer, then write the updates".
  • The same updates through updateNumericDocValue(Term, ...) are bounded by that rule.
  • The rule (2017) predates the try method (2018), which never added the check.
The full argument344 words
  • Contract and design statements. ReadersAndUpdates.addDVUpdate: "Adds a new resolved (meaning it maps docIDs to new values) doc values packet. We buffer these in RAM and write to disk when too much RAM is used or when a merge needs to kick off, or a commit/refresh." (ReadersAndUpdates.java:148-151). writeSomeDocValuesUpdates: "If the reader pool is > 50% of our IW buffer, then write the updates" (IndexWriter.java:811). setRAMBufferSizeMB: "Determines the amount of RAM that may be used for buffering added documents and deletions before they are flushed to the Directory." (LiveIndexWriterConfig.java:158-159). The IndexWriter class javadoc: "These changes are buffered in memory and periodically flushed to the Directory" (IndexWriter.java:123-124). The tryUpdateDocValue javadoc (:1707-1720) says nothing about memory and does not ask callers to refresh for it.
  • The sibling paths are bounded. Term-based updates are held first by the buffered-updates RAM trigger and, once a packet apply resolves them into the pool, by the rule: 200,000 term updates with a 1 MB buffer leave 371,596 bytes in the pool, below half the buffer. tryDeleteDocument creates no doc-values updates; it writes into a fixed-size live-docs bitset. A merge also adds resolved updates to the merged segment (IndexWriter.java:4526-4542), but only the ones that resolved on its source segments while it ran, remapped once per merge, so it adds no growth of its own. Upstream TestNumericDocValuesUpdates.testUpdatesAreFlushed sets a tiny RAM buffer and asserts that getFlushDeletesCount() goes up after each term-based update; it does not look at the reader pool or the 50% rule. No upstream test covers memory on the try path or relies on the unbounded behaviour.
  • History: no record of a decision to exempt this path. LUCENE-7868 (58105a203a1, 2017-06-21, 7.0.0) added the rule. LUCENE-8297 (b0b32931b28, 2018-05-05, 7.4.0) added tryUpdateDocValue on tryModifyDocument, shared with tryDeleteDocument, and added no check; #9344 never mentions RAM.
  • Precedent. LUCENE-5248 (#6312, 2013, fixed in 4.6) fixed the same symptom on the then term-based path, where resolved updates would "just keep accumulating there, until RAM is exhausted".
The devil's-advocate check230 words

The strongest case for "works as designed" (built by the devil's-advocate verifier; the cross-model reviews and the adjudication rated it moderate): the RAM buffer names only "added documents and deletions", "is just an indicator when to flush" (LiveIndexWriterConfig.java:166-172) and may release only part of the buffer, depending on the FlushPolicy (:179-181); ReadersAndUpdates is package-private, so its javadoc states design intent, not a public contract; the API is Expert and tied to an NRT reader the caller is expected to refresh, and the same javadoc already tells delete-by-Query users to "compensate by either calling commit() or refresh() periodically yourself" (:174-177).

Why it fails: the "indicator" and FlushPolicy caveats allow overshoot from flushes that run while other threads keep adding documents (:169-171) and partial flushes (:179-181), not memory that grows with every call and is never checked; the refresh-yourself advice is scoped to delete-by-Query, whose RAM "Lucene currently has no way to measure", while these bytes are measured (ReadersAndUpdates.java:161) and then never compared; the same resolved updates through the term path are bounded by exactly this rule; and the commit and issue records show no decision to exempt this path. What stays true: no public javadoc promises in so many words that tryUpdateDocValue honours the RAM buffer, so it is Low and a maintainer may call the fix an improvement.

Suggested fix

Recommended A. Run the existing RAM rule after a successful try-update (validated)

Call the writer's existing RAM rule, writeSomeDocValuesUpdates(), after a successful try-update (+8/-1 in IndexWriter.java). The test passes (assertions on and off) and 104 tests of the seven closest suites pass (assertions on). Open question for maintainers: this writes doc-values files while holding the writer's monitor; option B makes the same call outside it and passes the same tests.

-    return tryModifyDocument(
+    final long seqNo =
+        tryModifyDocument(
         readerIn,
         docID,
         (leafDocId, rld) -> { ... });
+    if (seqNo != -1) {
+      // The resolved updates went straight into the reader pool: apply the same RAM rule as after
+      // applying a frozen packet, else they are only written on the next refresh or commit:
+      writeSomeDocValuesUpdates();
+    }
+    return seqNo;
All options and trade-offs5 options ยท 901 words

Two options were built and run through gradle at e1faeba08bde; the others are untested ideas. Both validated options are sketches: no concurrent stress test was run. Formatting: with A, ./gradlew :lucene:core:checkGoogleJavaFormat fails on exactly one hunk, the closure re-indented under tryModifyDocument( (the check's own message says to run ./gradlew tidy); B was not format-checked.

A. Run the existing RAM rule after a successful try-update (validated) (recommended)

The hunt's diff, unchanged: keep tryModifyDocument's sequence number and, when it is not -1, call writeSomeDocValuesUpdates(), so each successful call attempts the same best-effort 50% rule that runs after a packet apply (the attempt is skipped while another thread holds writeDocValuesLock and is itself writing, IndexWriter.java:808). No API change.

-    return tryModifyDocument(
+    final long seqNo =
+        tryModifyDocument(
         readerIn,
         docID,
         (leafDocId, rld) -> { ... });
+    if (seqNo != -1) {
+      // The resolved updates went straight into the reader pool: apply the same RAM rule as after
+      // applying a frozen packet, else they are only written on the next refresh or commit:
+      writeSomeDocValuesUpdates();
+    }
+    return seqNo;

Trade-offs

  • Writes under the monitor. tryUpdateDocValue is synchronized, so doc-values files are written while the IndexWriter monitor is held. writeSomeDocValuesUpdates was designed to take that monitor once per segment write ("Only acquire IW lock on each write, since this is a time-consuming operation. This way other threads get a chance to run in between our writes", IndexWriter.java:842-843), and LUCENE-7868 first asserted that it runs without the monitor (the assert was removed in LUCENE-8260, 8975692953). Other threads that need the writer's monitor (for example other try-updates and tryDeleteDocument, both synchronized) wait while these files are written. No new lock-order edge that can block: the only edge A adds is the writer's monitor -> writeDocValuesLock, which is taken only with tryLock() (:808) and never waits; the monitors the method then takes (ReaderPool, the synchronized ReadersAndUpdates.writeFieldUpdates) are already taken under the writer's monitor on the packet path (synchronized (this), :844-860). No run deadlocked, but no concurrent stress test was run.
  • Per-call cost. Each successful call also sums the reader pool (ReaderPool.ramBytesUsed(), synchronized, one pass over the pooled segments).
  • More files. Heavy try-update streams write new doc-values generations more often instead of holding memory.
  • Untested corner (a cross-model review note): the write covers only generations up to the completed delete generation (:857), so while older packets are still being applied the call may write nothing until those packets' own apply runs the rule.
  • Error semantics. The update is already in the pool when writeSomeDocValuesUpdates runs, so if that write throws an IOException the caller gets an exception for an update that took effect, and a retry adds another update.

What the checks validated

  • This doc (gradle, scratch worktree of e1faeba08bde, 2026-11-02): the minimal test fails on stock and passes with A, assertions on and off; TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestMixedDocValuesUpdates, TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestReaderPool and TestDocValuesFieldUpdates (104 tests, 2 skipped) pass on stock and with A, assertions on (only the minimal test also ran with assertions off). The fact check re-ran the suites with A and with B under its own seeds (105 tests with the minimal test, 0 failures).
  • The hunt (on 645d4fe14a20): the minimal test and 86 upstream doc-values update and soft-deletes tests pass; the bounded exhaustive check over 11,110 operation sequences finds 4,922 violations on main and 0 with A (max pending 441,548 bytes against 4,240,000); the independent reproducer's and the cross-model reviewers' tests pass with it; the 7.4.0 port passes with the same call.

B. The same call, outside the monitor (validated)

Drop synchronized from tryUpdateDocValue and make the same call after tryModifyDocument returns. tryModifyDocument stays private synchronized and runs the closure under the monitor, so the update itself is serialized as before; building the update objects then runs unsynchronized, as it already does in updateDocValues(Term, ...). This matches the packet-apply call site, which also runs outside the monitor ("don't hold IW monitor lock here", IndexWriter.java:6590).

Trade-offs

Changes the locking of a public method: callers no longer hold the writer's monitor for the whole call. Otherwise as A (per-call sum, more files, the same untested corner, the same error semantics).

What the checks validated

The same gradle runs as A: the minimal test passes with B, assertions on and off, and the same 104 tests pass with assertions on. Only this doc and the fact check ran B, and it was not format-checked; the hunt's exhaustive check, its 86 upstream tests and the reviewers covered A only.

C. Merge per-call updates (not run)

Keep one update object per segment and field and add to it, so memory grows with documents, not calls.

Trade-offs

Each call has its own delete generation, which orders it against buffered term-based updates; merging generations touches the ordering that LFV-009 (a try-update overwritten by an earlier buffered update) is about. A larger change.

D. Count the reader pool in flush decisions or in IndexWriter.ramBytesUsed() (not run)

Let the existing flush control see the pending updates.

Trade-offs

Broader: changes accounting for every path, not only this one.

E. Documentation only

Say in the tryUpdateDocValue javadoc that its pending updates are held until the next refresh, flush() or commit.

Trade-offs

Cheapest; leaves memory that is measured but never checked.

Downstream note

An application that lets its users trigger unlimited try-updates (for example per-document counters or soft-deletes) without refreshing can exhaust its own heap, so such applications should refresh, flush or commit on a schedule until a fix ships.

How it was found

Non-formal: a sweep that compared the retained heap of 11 IndexWriter workloads with the RAM buffer flagged try-updates on a never-refreshed reader, and their soft-delete variant, as growing without bound. The hunt reduced it to one JUnit test and checked it with a bounded exhaustive run over all 11,110 short sequences of writer operations.

The method in full165 words

Method: non-formal (the first signal was a heap probe).

  • The hunt swept 11 single-threaded IndexWriter workloads with the default config in a 256 MB heap and compared the heap retained after garbage collection with the 16 MB RAM buffer and IndexWriter.ramBytesUsed(). tryUpdateDocValue with a reader that is never refreshed, and its soft-delete variant, grew without bound to OutOfMemoryError; the same workload with a refresh every 10,000 calls, tryDeleteDocument and the term-based update workloads stayed bounded.
  • A hunt unit reduced the lead to the one-test repro and ran a bounded exhaustive model-based check on the real jar: all 11,110 sequences of 1 to 4 operations over 10 writer operations (try numeric, binary and soft-delete bursts, term updates, tryDeleteDocument, addDocument, an automatic flush, flush(), refresh, commit), comparing the pooled segment's pending bytes after every operation with the documented rule. 4,922 sequences break it on main and 0 with the fix; an abstract model of the code predicts all 33,570 byte values exactly.

Prior reports, history and checks

Prior reports

No matching public report found in the recorded searches of GitHub issues, PRs and commits, JIRA, the mailing lists, downstream projects and the web (2026-11-02). LUCENE-5248 (#6312) fixed the same symptom on the then term-based path in 2013: the precedent.

Searches4 searches

Context only: #9344 (LUCENE-8297, added the method; no RAM discussion), #8919 (LUCENE-7868, added the 50% rule a year earlier), #6312 (LUCENE-5248, the 2013 precedent), #9402 (LUCENE-8355) and #9405 (LUCENE-8358) (other defects inside the rule's method), #16570 (merged 2026-09-01; removed a different, merge-time buffer that IndexWriter#ramBytesUsed did not count) and a 2020 comment by dnhatn on elastic/elasticsearch#34553 (suspected unaccounted ReadersAndUpdates memory on the bounded term path).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr, Lucene.NET; dev@, java-user@, issues@, commits@; JIRA; the web2026-11-02two scripted prior-report searches (51 and 53 queries, 0 high hits) and about 30 manual GitHub phrasings: tryUpdateDocValue(s), tryModifyDocument, writeSomeDocValuesUpdates, reader pool / ReadersAndUpdates / DocValuesFieldUpdates RAM, soft deletes heap, IndexWriter#ramBytesUsed, getReadersByRam; git log -S over the full historyno report; context #9344, #8919, #6312, #9402, #9405, #16570, elastic/elasticsearch#34553
apache/lucene recent and open PRs2026-11-02every PR updated since 2026-10-31 and every issue and PR created since 2026-10-29, with their file listsnone touches tryUpdateDocValue or writeSomeDocValuesUpdates
apache/lucene main2026-11-02refresh: last commits on the fix-site filesnothing new; the last commit to both IndexWriter.java and ReadersAndUpdates.java is #16581 (2026-09-21; it touched tryUpdateDocValue only for SORTED_NUMERIC case labels, not its RAM handling); the one before is #16570 (2026-09-01)
apache/lucene issues and PRs; Elasticsearch, OpenSearch and Solr code2026-11-02fact-check refresh: GitHub search for tryUpdateDocValue, tryUpdateDocValues, writeSomeDocValuesUpdates and five RAM, reader-pool and soft-delete phrasings; PRs updated since 2026-11-01; code search for tryUpdateDocValue downstreamonly #9344, #9402 and #9405 (context); no PR touching this code; no downstream call

History

Introduced with the method itself by LUCENE-8297 (2018-05-05; 48a2138e899 on branch_7x, first released in 7.4.0; b0b32931b28 on master, first in 8.0.0); the rule it skips dates from LUCENE-7868 (2017). Every release from 7.4.0 through 10.6.0 and main is affected. Not a regression, not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-8297 "Add IW#tryUpdateDocValues(Reader, int, Fields...)" (#9344, Simon Willnauer): b0b32931b28 on master and the same-day branch_7x commit 48a2138e899 (author date 2018-05-05; committed from a JIRA patch, no GitHub PR found). It built the method on tryModifyDocument, the helper shared with tryDeleteDocument, whose deletes go into a fixed-size bitset, and added no RAM check. The rule, writeSomeDocValuesUpdates, came a year earlier with LUCENE-7868 (58105a203a1, 2017-06-21, 7.0.0); later commits moved its call but never added a call site. Later edits to the method (a reformat, enum cases, SORTED_NUMERIC support in #16581, a javadoc edit) do not touch RAM. The release runs used the earlier variant of the test, which differs only in newDirectory(), a per-call return-value check and a longer message.

All 59 release tags from 7.4.0 to 10.6.0 in the history clone contain the method with no RAM check, flush or merge call in it (8.8.2 to 8.11.4 are not in the clone; 8.11.4 was executed).

Versions and branches executed, with results
Version or commitVerdictResult
main e1faeba08bde affectedthe minimal test fails through gradle, assertions on and off (3,340,000 bytes)
main 645d4fe14a20 affectedfails through the standalone JUnit runner, assertions on and off; the earlier variant fails 20 of 20 iterations
10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.0.0 affectedthe earlier variant fails on the release jars (3,340,000 bytes; 10.6.0 and 9.12.3 with assertions on and off, 10.5.2 off only, 10.0.0 and 9.0.0 on only)
8.11.4, 8.0.0, 7.7.3 affectedthe earlier variant fails, with the pre-9.1 test-framework import (8.11.4 with assertions on and off, 8.0.0 and 7.7.3 on only) (3,020,000 bytes: 302 bytes accounted per call)
7.4.0 first releasethe earlier variant fails (3,020,000 bytes, assertions on and off); passes with the one-call fix
7.3.1 no APItryUpdateDocValue does not exist; the test does not compile
main e1faeba08bde + fix A or B fixthe minimal test passes, assertions on and off

Independent checks

Confirmed with high confidence by the adjudication after an independent reproducer, an intended-behaviour devil's advocate and three executed reviews by another model family (OpenAI GPT-5.6), none dissenting; a credibility audit agreed; re-run through gradle for this doc, with two fix options validated.

Checks and verdicts324 words

Each checker that executed wrote or re-ran a test from the claim. The cross-model reviews ran on main ea57360912ed, whose involved classes are identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe minimal test fails on main (assertions on and off; its earlier variant 20 of 20 iterations) and on releases back to 7.4.0, and passes with fix A; the bounded exhaustive check finds 4,922 violations on main and 0 with A.
Independent reproducer realIts own test (8,000 calls) fails with 2,672,000 bytes on main, 10.6.0, 10.5.2 and 9.12.3 and passes with its own one-line fix; about 350 bytes of heap per call after GC with ramBytesUsed() 0; a RAM-triggered flush from adding documents does not write them.
refute review (OpenAI GPT-5.6) real, highExecuted; try-updates hold more than 1 MiB while a term-update control stays within it; flagged the writes under the monitor.
intended review (OpenAI GPT-5.6) real, highExecuted; its own test fails with 3,340,000 bytes and passes with the fix; the works-as-designed case is moderate and fails.
root-cause review (OpenAI GPT-5.6) real, highExecuted; root cause and fix agree; its own test fails on main and passes with its fix.
Intended-behaviour devil's advocate real, LowBuilt the strongest works-as-designed case and refuted it; 20,000 distinct documents cost the same 334 bytes per call.
adjudication confirmed, highRe-ran the independent reproduction's test on main (fails, assertions on and off) and with the fix (passes).
credibility audit confirmed (Low)Re-ran the repro; a term-path control of 200,000 updates stays at 371,596 bytes; a separate re-run then checked the one-test repro, and the impact class was lowered from crash to minor.
this doc fails on stock, passes with A and BGradle on e1faeba08bde, assertions on and off; 104 tests of the closest suites pass on stock and with each fix, assertions on.

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 notes1,577 words

Finding record

LFV-1075, snapshot of 2026-11-06

Registry title
IndexWriter.tryUpdateDocValue never checks the RAM buffer for the doc-values updates it adds, so they pile up on the heap without bound until the next refresh, flush() or commit
Area
index / IndexWriter tryUpdateDocValue (reader-pool doc-values update RAM)
Priority
Low, rank 999: In every release since 7.4.0, bulk use of the Expert tryUpdateDocValue without a refresh, flush or commit piles up updates on the heap that the RAM buffer never counts, until OutOfMemoryError. Periodic refreshes bound it and no major search server calls the API, so it is Low.
Impact class
minor
Impact note
Hits applications that call the Expert IndexWriter.tryUpdateDocValue (numeric, binary or soft-deletes field) many times between NRT refreshes, flush() or commit, with default settings: every call keeps about 334 bytes (numeric) to 530 bytes (binary) of resolved updates on the heap that no RAM-buffer check ever writes out and that IndexWriter.ramBytesUsed() reports as 0, so one try soft-delete per document over a 200,000-doc segment leaves 66.8 MB pending (about 4x the default 16 MB buffer) and a run with a reader that is never refreshed grows linearly to OutOfMemoryError (256 MB heap, between 562,500 and 750,000 calls); any refresh, flush(), commit, merge of the segment or unrelated term-based delete or update bounds it again, and an automatic RAM-triggered segment flush does not.
Summary
You open an NRT reader from an IndexWriter and call tryUpdateDocValue on its documents many times, for example 10,000 counter updates or one soft-delete per document, and expect the writer to keep these pending updates within its RAM buffer and write them out once they use too much RAM, as it does for the same updates made through updateNumericDocValue. Instead every call adds one more pending-update object to the writer's per-segment state and nothing ever compares their size with the buffer: with a 1 MB buffer, 10,000 calls leave 3,340,000 bytes pending, and without a refresh, flush() or commit the heap keeps growing until OutOfMemoryError. IndexWriter.ramBytesUsed() reports 0 for all of it.
Introduced
b0b32931b28da83d47f0561f4da734d6b9ee6e16 (master, 'LUCENE-8297: Add IW#tryUpdateDocValues(Reader, int, Fields...)', by Simon Willnauer (s1monw); JIRA LUCENE-8297 = apache/lucene#9344, a patch, no pull request; the code the 8.0+ releases ship) and its same-day branch_7x commit 48a2138e899bca0b8a8485fb7e490a9dc943d997 (the code the 7.x releases ship): tryUpdateDocValue was built on tryModifyDocument, shared with tryDeleteDocument, and adds each call's resolved updates straight to the pooled ReadersAndUpdates (rld.addDVUpdate), so it never reaches writeSomeDocValuesUpdates, the 'reader pool > 50% of our IW buffer' rule from LUCENE-7868 (58105a203a1, 2017-06-21, first in 7.0.0), whose only call site is after a frozen-packet apply; no RAM check was added [read: git blame on 7cecf795236c, whose IndexWriter.java is byte-identical to 645d4fe14a20; the later edits a3c86373e47 (LUCENE-8298), 2d6ad2fee6d (reformat), 79fcd99f4cf, 169cbffd12f (#16581) and 7540c29ee84 (javadoc) do not touch RAM]. Not a regression. (2018-05-05 (author date; commit dates 2018-05-07 on master and branch_7x))
First affected version
7.4.0 [exec: the version port fails with 3,020,000 pending bytes and passes with the one-call fix shadowed]; 7.3.1 and earlier have no tryUpdateDocValue [exec: the repro does not compile on 7.3.1]
Affected versions
every release from 7.4.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 645d4fe14a20 (unreleased 11.0) [exec: the claimed JUnit fails 1 of 1 on main with -ea and -da (3,340,000 bytes), 20 of 20 with -da and 20 iterations, and on 10.6.0 (-ea and -da), 10.5.2 (-da), 10.0.0 (-ea) and 9.12.3 (-ea and -da); its import-only port fails on 9.0.0 (-ea, 3,340,000 bytes), 8.11.4 (-ea and -da), 8.0.0, 7.7.3 (-ea) and 7.4.0 (-ea and -da) with 3,020,000 bytes (302 bytes accounted per call before 9.0, an accounting difference, not a behaviour change); the independent reproducer's own test fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3]; the releases in between by a static check of all 59 release tags from 7.4.0 to 10.6.0 in the history clone (tryUpdateDocValue present, no RAM check, flush or merge call in it; 8.8.2-8.11.4 are not in the clone, 8.11.4 was executed) [read]. Not new in 10.6 (10.5.2 fails the same test). Not fixed upstream: upstream main e1faeba08bd (2026-11-02) has no change to IndexWriter.java, ReadersAndUpdates.java or ReaderPool.java since 645d4fe (last IndexWriter.java commit #16581, author date 2026-09-21) [read through the GitHub API]. Executed on Linux aarch64 with JDK 25 only.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-11-02, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): two scripted prior-report searches (51 and 53 queries over GitHub issues and PRs, commits, downstream repositories, four mailing lists, JIRA and the web; 93 and 1,423 hits, 0 high); manual GitHub searches on apache/lucene (tryUpdateDocValue(s), tryModifyDocument, writeSomeDocValuesUpdates, reader pool, ReadersAndUpdates and DocValuesFieldUpdates RAM, soft deletes heap, IndexWriter#ramBytesUsed and others) and on Elasticsearch, OpenSearch, Solr and Lucene.NET (none calls tryUpdateDocValue); the bodies and comments of #9344, #8919, #9402, #9405, #6312, #6253, #9636, #16570 and #16418; JIRA REST over LUCENE and SOLR; lists.apache.org dev@, java-user@ and issues@; every apache/lucene PR open and updated since 2026-10-31 and every issue and PR created since 2026-10-29; git log -S over the full history. Refresh (2026-11-02): 'tryUpdateDocValue' and 'tryUpdateDocValues' still return only #9344, 'writeSomeDocValuesUpdates' only #9402 and #9405; the 15 apache/lucene PRs updated since 2026-10-31 are about other code; upstream main e1faeba08bd last changed IndexWriter.java in #16581 (author date 2026-09-21, already in 645d4fe). Nearest items, none the same bug: #9344 / LUCENE-8297 (introduced the method; no RAM discussion), #8919 / LUCENE-7868 (introduced the 50% rule a year earlier), #6312 / LUCENE-5248 (2013: resolved term-based updates accumulating 'until RAM is exhausted' on the then code path, fixed in 4.6), #9402 / LUCENE-8355 and #9405 / LUCENE-8358 (other writeSomeDocValuesUpdates defects), #16570 (merged 2026-09-01: removed a different, merge-time doc-values update buffer that IndexWriter#ramBytesUsed did not count), a 2020 comment by dnhatn on elastic/elasticsearch#34553 (suspected that Lucene memory such as ReadersAndUpdates is not accounted, in an Elasticsearch benchmark that uses the term-based update path; no Lucene issue followed), and open PRs #16810, #16948, #12557, #13951 and #16643 (touch these files, not this code).
Lucene commit tested
main e1faeba08bde (2026-11-02): the minimal test through gradle in a scratch worktree, stock and with fix options A and B, assertions on and off, plus 104 tests of the seven closest suites, stock and with each fix, assertions on; the fact check re-ran the repro (gradle and the standalone JUnit runner, assertions on and off) and the suites with each fix under its own seeds. main 645d4fe14a20, whose involved classes are byte-identical: the minimal test through the standalone JUnit runner with -ea and -da (seeds DEADBEEF and 5EED1234), its earlier variant (newDirectory(), a per-call return check and a longer message) 20 of 20 iterations and three more seeds, the hunt's bounded exhaustive check and 86 upstream tests with fix A. Release jars: 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.0.0, 7.7.3 and 7.4.0 fail with the earlier variant (7.x to 9.0.0 with the pre-9.1 test-framework import; 10.6.0, 9.12.3, 8.11.4 and 7.4.0 with assertions on and off, 10.5.2 off only, the others on only); 7.3.1 has no tryUpdateDocValue. All on Linux aarch64 with OpenJDK 25.0.3.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:796-798 (at 645d4fe14a20) (ramBytesUsed() returns docWriter.ramBytesUsed() only), :807-816 (writeSomeDocValuesUpdates; :811 'If the reader pool is > 50% of our IW buffer, then write the updates'), :1706-1720 (Expert tryUpdateDocValue javadoc: no statement on memory or refresh frequency), :1721-1784 (tryUpdateDocValue: one new DocValuesFieldUpdates per call, rld.addDVUpdate at :1775, no RAM check afterwards: fix site), :6599-6600 (the only call site of writeSomeDocValuesUpdates, after a frozen packet is applied)
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:148-164 (at 645d4fe14a20) (addDVUpdate javadoc 'We buffer these in RAM and write to disk when too much RAM is used or when a merge needs to kick off, or a commit/refresh'; :161 counts the bytes, nothing compares them)
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:157-177 (at 645d4fe14a20) (RAM buffer javadoc: 'buffering added documents and deletions', 'just an indicator when to flush', and the delete-by-Query note to refresh or commit periodically)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:795-799 (at e1faeba08bde) (ramBytesUsed), :807-884 (writeSomeDocValuesUpdates; :811 comment, :812 DISABLE_AUTO_FLUSH guard, :816 the 50% test), :1707-1721 (tryUpdateDocValue javadoc and signature), :1724-1784 (body; :1729 getNextGen, :1740/:1743 new *DocValuesFieldUpdates, :1773-1776 finish + addDVUpdate, :1782 changed()), :1791-1829 (tryModifyDocument), :6599-6600 (the only call site of writeSomeDocValuesUpdates)
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:148-164 (at e1faeba08bde) (addDVUpdate javadoc and body; :161 counter, :163 list add); ReaderPool.java:144-150 (ramBytesUsed sum)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1707-1720 (at e1faeba08bde) (javadoc: un-delete use); IndexWriterConfig.java:83 (DEFAULT_RAM_BUFFER_SIZE_MB = 16.0); IndexWriter.java:812 (DISABLE_AUTO_FLUSH guard in writeSomeDocValuesUpdates)
  • lucene/core/src/test/org/apache/lucene/index/TestMixedDocValuesUpdates.java, (at e1faeba08bde) TestSoftDeletesRetentionMergePolicy.java (the only callers in the Lucene tree)
Minimal repro
  • lucene/core/src/test/org/apache/lucene/index/TestReaderPool.java:365-367 (at e1faeba08bde) (rld.ramBytesUsed.get())
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:148-151, (at e1faeba08bde) :161; IndexWriter.java:123-124, :811, :1707-1720; LiveIndexWriterConfig.java:158-159, :166-177, :179-181; IndexWriter.java:4526-4542 (merge carry-over), :1688 (tryDeleteDocument)
  • lucene/core/src/test/org/apache/lucene/index/TestNumericDocValuesUpdates.java:180-203 (at e1faeba08bde) (testUpdatesAreFlushed: asserts getFlushDeletesCount() after each term update)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:293/:808/:881 (at e1faeba08bde) (writeDocValuesLock: declaration, tryLock, unlock), :842-843, :844-860 (synchronized (this) around readerPool.get and rld.writeFieldUpdates), :857, :1688 (synchronized tryDeleteDocument), :1729, :1791, :2070, :6590; ReaderPool.java:144-150

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

Repro files

About the diagrams

Made from the code at e1faeba08bde and the repro's numbers.

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.