- 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. - 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. tryUpdateDocValue(Expert) skips the batch: it takes a document id from an NRT reader and adds its resolved update to the reader pool directly.
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.- 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. - Call
writer.tryUpdateDocValue(reader, 0, new NumericDocValuesField("counter", i))10,000 times. Every call succeeds. - 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, ...). - Instead 3,340,000 bytes stay pending: 10,000 separate update objects of 334 bytes, none written, and
IndexWriter.ramBytesUsed()does not count them. - 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
OutOfMemoryErrorin a 256 MB heap. The term-based path stays at 13 to 24 MiB for 1.5 million calls.
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.
- Reader pool / pending updates: the writer keeps one
ReadersAndUpdatesper segment. Resolved doc-values updates wait there asDocValuesFieldUpdatesobjects, one list per field, until they are written as a new doc-values generation of the segment.ReadersAndUpdates.addDVUpdatedocuments 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.writeSomeDocValuesUpdatessums 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).
public synchronized long tryUpdateDocValue(IndexReader readerIn, int docID, Field... fields)(IndexWriter.java:1721) builds the updates (:1724) and callstryModifyDocument(:1725, defined at:1791-1829), which finds the segment'sReadersAndUpdatesand runs the closure.- The closure takes a fresh generation (
:1729), creates a newNumericDocValuesFieldUpdatesorBinaryDocValuesFieldUpdatesper field (:1740,:1743), adds the one document, thenupdates.finish(); rld.addDVUpdate(updates);(:1773-1776) andchanged()(:1782). Nothing runs after it. addDVUpdateappends the object (ReadersAndUpdates.java:163) and adds its size to the segment's counter (:161). It compares nothing.- 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). IndexWriter.ramBytesUsed()returnsdocWriter.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.
| workload | pending or retained | bound |
|---|---|---|
| 10,000 try-updates of one document, 1 MB buffer | 3,340,000 bytes pending (334 per call) | 524,288 (half the buffer) |
| 20,000 try-updates of 20,000 different documents, 1 MB buffer | 6,680,000 bytes pending (334 per call) | 524,288 |
| one try soft-delete per document, 200,000-document segment, default 16 MB buffer | 66.8 MB pending, ramBytesUsed() 0 | 8 MB |
| try-updates, 16 MB buffer, reader never refreshed, 256 MB heap | 73 / 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 calls | bounded |
| try-updates with the reader refreshed every 10,000 calls | 9 to 12 MiB for 1.5 million calls | bounded |
tryDeleteDocument, reader never refreshed | 9 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
tryUpdateDocValueon 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.
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.
/*
* 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);
}
}
}
}
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 byteson 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.
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.
| lucene | main 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) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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 acceptedReadersAndUpdates.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). TheIndexWriterclass javadoc: "These changes are buffered in memory and periodically flushed to the Directory" (IndexWriter.java:123-124). ThetryUpdateDocValuejavadoc (: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.
tryDeleteDocumentcreates 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. UpstreamTestNumericDocValuesUpdates.testUpdatesAreFlushedsets a tiny RAM buffer and asserts thatgetFlushDeletesCount()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) addedtryUpdateDocValueontryModifyDocument, shared withtryDeleteDocument, 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.
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.
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.
tryUpdateDocValueissynchronized, so doc-values files are written while the IndexWriter monitor is held.writeSomeDocValuesUpdateswas 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 andtryDeleteDocument, bothsynchronized) 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 withtryLock()(:808) and never waits; the monitors the method then takes (ReaderPool, the synchronizedReadersAndUpdates.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
writeSomeDocValuesUpdatesruns, so if that write throws anIOExceptionthe 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,TestReaderPoolandTestDocValuesFieldUpdates(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.
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.
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.
Let the existing flush control see the pending updates.
Trade-offs
Broader: changes accounting for every path, not only this one.
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.
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.
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
IndexWriterworkloads with the default config in a 256 MB heap and compared the heap retained after garbage collection with the 16 MB RAM buffer andIndexWriter.ramBytesUsed().tryUpdateDocValuewith a reader that is never refreshed, and its soft-delete variant, grew without bound toOutOfMemoryError; the same workload with a refresh every 10,000 calls,tryDeleteDocumentand 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr, Lucene.NET; dev@, java-user@, issues@, commits@; JIRA; the web | 2026-11-02 | two 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 history | no report; context #9344, #8919, #6312, #9402, #9405, #16570, elastic/elasticsearch#34553 |
| apache/lucene recent and open PRs | 2026-11-02 | every PR updated since 2026-10-31 and every issue and PR created since 2026-10-29, with their file lists | none touches tryUpdateDocValue or writeSomeDocValuesUpdates |
| apache/lucene main | 2026-11-02 | refresh: last commits on the fix-site files | nothing 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 code | 2026-11-02 | fact-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 downstream | only #9344, #9402 and #9405 (context); no PR touching this code; no downstream call |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main e1faeba08bde | affected | the minimal test fails through gradle, assertions on and off (3,340,000 bytes) |
main 645d4fe14a20 | affected | fails 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 | affected | the 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 | affected | the 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 release | the earlier variant fails (3,020,000 bytes, assertions on and off); passes with the one-call fix |
| 7.3.1 | no API | tryUpdateDocValue does not exist; the test does not compile |
main e1faeba08bde + fix A or B | fix | the minimal test passes, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The 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 | real | Its 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, high | Executed; 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, high | Executed; 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, high | Executed; root cause and fix agree; its own test fails on main and passes with its fix. |
| Intended-behaviour devil's advocate | real, Low | Built the strongest works-as-designed case and refuted it; 20,000 distinct documents cost the same 334 bytes per call. |
| adjudication | confirmed, high | Re-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 B | Gradle on e1faeba08bde, assertions on and off; 104 tests of the closest suites pass on stock and with each fix, assertions on. |
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 421 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,577 words
- 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. main645d4fe14a20, whose involved classes are byte-identical: the minimal test through the standalone JUnit runner with-eaand-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 notryUpdateDocValue. All on Linux aarch64 with OpenJDK 25.0.3.
- 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
- TestTryUpdateDocValueRamBuffer.java (one test):
repro/LFV-1075/TestTryUpdateDocValueRamBuffer.java(46 lines)
Made from the code at e1faeba08bde and the repro's numbers.
assets/LFV-1075/bug.png(421 KB)
