addTicket raises a ticket count before it freezes the buffered deletes, so that deletes being frozen still count as pending. When the freeze finds nothing, because another thread just took the deletes, nothing is queued, but the count is never lowered. To every NRT reader, a count above 0 means changes are pending.
- Buffered deletes.
deleteDocumentsandupdateDocumentcollect delete terms in a global buffer. When they use as much RAM as the RAM buffer (16 MB by default), the next thread that checks pushes them: it freezes them into a packet, a flush ticket, that the writer applies later. - The ticket count. The flush queue counts a ticket from the moment a push starts until the ticket is applied. While the count is above 0,
IndexWriterreports pending changes. - NRT currentness. For a reader from
DirectoryReader.open(writer),isCurrent()is true when the writer has no new commit and no pending changes. When it is false, applications callopenIfChanged, which flushes and returns null if nothing changed.
commit(). Select the still to open it at full size. Generated with AI, checked against the code.- Two threads share one
IndexWriterwith the default config. One deletes by term until the buffered deletes pass the 16 MB RAM buffer (524,288 deletes, 17.4 MB) and starts pushing them. The other adds a document, still sees the full buffer, and asks for a second push. - The second push waits for the first, then finds the buffer empty, so the freeze returns null.
addTickethad already raised the ticket count by one (to 2, while the first push's ticket was still queued), and since #15134 it lowers it again only if the freeze throws. Once the queued tickets are applied: count 1, tickets queued 0. - You
commit()and openDirectoryReader.open(writer). You expectisCurrent() == true: nothing changed after it opened, and 10.5.2 returns true. - Instead
isCurrent()is false whileopenIfChanged(r)returns null, because the writer treats a ticket count above 0 as pending changes. After 3 more rounds of add,commit()and a fresh open it is still false, and each refresh runs a full flush for nothing, until the writer is closed. - Hits, documents and deletes stay correct. The core fits in one line:
addTicket(() -> null)leaveshasTickets()true.
Applications on main (unreleased 11.0) that index from several threads with deleteDocuments or updateDocument on one IndexWriter, default config. Rare: the deletes alone must fill the RAM buffer and two pushes must overlap. Hook-free runs hit it with 0.02-1 MB buffers, in 0 of 5 rounds at 16 MB. Once hit, it lasts until the writer is closed.
Mechanism, terms and numbers791 words
DocumentsWriterFlushQueue.addTicket raises its ticket count before it runs the supplier that freezes the buffered deletes, so that deletes being frozen still count as pending changes. When the supplier returns null because another thread has just emptied the buffer, nothing is queued, and since #15134 (b85f030c6cc, 2025-10-09) the count is not lowered again: only an exception gives it back. DocumentsWriter.anyChanges() reads that count, so from then on IndexWriter.nrtIsCurrent() is false and every NRT reader of the writer reports isCurrent() == false, also one opened right after commit(), while openIfChanged() returns null. Each refresh runs a full flush only to discard the reader. No hit, document or delete is wrong, and a new IndexWriter clears the state. Main and branch_11x only; no release has it.
- Who asks for a push. The default flush policy calls
setApplyAllDeletes()when the buffered deletes use at least the RAM buffer (FlushByRamOrCountsPolicy.java:41-48,:55-56), on every delete and after an added document once that thread's RAM growth passes a small accounting step (DocumentsWriterFlushControl.java:207-215,:238,:486-489).DocumentsWriter.applyAllDeletes(:168-186) checks the flag,isFullFlush()anddeleteQueue.isOpen(); the thread that winsgetAndResetApplyAllDeletes()(an atomicgetAndSet(false)) callsticketQueue.addTicket(() -> maybeFreezeGlobalBuffer(deleteQueue))(:180). - Two pushes can overlap. The freeze builds the frozen packet before it clears the global buffer (
DocumentsWriterDeleteQueue.java:262-273) and runs insideaddTicket, which issynchronized(DocumentsWriterFlushQueue.java:38). While thread A is in there, thread B's RAM check still sees the full buffer, sets the flag again, wins it, and waits for the queue's monitor. - The leak. When B gets in,
incTickets()runs first (DocumentsWriterFlushQueue.java:39-40, on purpose: "first inc the ticket count - freeze opens a window for #anyChanges to fail"). The supplier finds an empty buffer and returns null (DocumentsWriterDeleteQueue.java:269-276,DocumentsWriter.java:719-724). Nothing is queued andaddTicketreturns null (DocumentsWriterFlushQueue.java:42-47).decTickets()runs only incatch (Throwable)(:48-50) and ininnerPurge, once per polled ticket (:102-105). The queue is a final field ofDocumentsWriter(:96), so the extra 1 stays for the writer's life, and each further losing race adds one. - What reads it.
hasTickets()isticketCount.get() != 0(DocumentsWriterFlushQueue.java:77-80);DocumentsWriter.anyChanges()includes it (:352-356), so that changes sitting in the ticket queue still count (LUCENE-3551, #4625, 2011, the comment at:345-351), andIndexWriter.nrtIsCurrentrequiresanyChanges() == false(IndexWriter.java:6140-6146). - Refresh.
openIfChangedon an NRT reader goes toStandardDirectoryReader.doOpenFromWriter(:456-475):nrtIsCurrentis false, so it callswriter.getReader(...)(IndexWriter.java:516, a full flush at:586), finds the same version and returns null. - Other ways to a null ticket. A TLA+ model of the flush family found that a full flush can close the queue between
applyAllDeletes'isOpen()check and the freeze, which then returns null for the closed queue. AndflushAllThreadspushes the "naked frozen global deletes" with the same supplier (DocumentsWriter.java:691-699). The repro uses neither; the fix acts on the null result itself, so it covers all of them. - Not affected.
commit()decides onchangeCountand still skips a commit when nothing is pending (IndexWriter.java:5934-5936).hasUncommittedChanges()stays true as a side effect, but its javadoc allows a spurious true (IndexWriter.java:4218-4224), so it is not claimed on its own. The ticket-backlog check indoFlushalso reads the count (DocumentsWriter.java:569-575); that was not measured.
The supplementary two-thread test, default IndexWriterConfig on a ByteBuffersDirectory: an InfoStream message hook holds the deleting thread inside its push, after 524,288 single-term deletes have buffered 18,266,984 bytes (the limit is 16,777,216); the adding thread's 20 KB document then asks for a second push and is seen BLOCKED in addTicket. A counting copy of the queue (one log line per increment and decrement, no other change) logged the same steps in all 12 runs, with the two-thread test and with the diagram probe, assertions on and off: the deleter's push 0 to 1; the adder's push, which gets null, 1 to 2 (the deleter's ticket is still queued); the adder's own segment flush 2 to 3; then the two tickets are published, 3 to 2 to 1. After both pushes main has ticket count 1 with 0 tickets queued. After commit(), DirectoryReader.open(writer) has 2 documents, openIfChanged returns null and isCurrent() is false; after 3 more rounds of addDocument, commit() and a fresh open it is still false and the count is 1 at each check. With the fix and on 10.5.2 the count is 0 and every reader is current. The hook only fixes the timing of a legal interleaving: hook-free runs reach the same state (see Who is affected).
Exposure and likelihood260 words
- Who. Code built on main or branch_11x (first release 11.0.0) that indexes from several threads with deletes or updates on one writer.
IndexWriteris documented as "completely thread safe, meaning multiple threads can call any of its methods, concurrently". - What breaks. For the rest of the writer's life,
isCurrent()is false for every NRT reader, including one opened right aftercommit(), whileopenIfChanged()returns null. A loop that waits forisCurrent()to turn true never ends. Every refresh (openIfChanged,SearcherManager.maybeRefresh) runs a full flush and opens a reader only to discard it. - What does not break. No hit, document or delete is lost, wrong or exposed; nothing throws or hangs inside Lucene. The signal fails safe: it says "changed" when nothing changed, never "current" while changes are missing. A new
IndexWriterstarts with a clean count. - How often. Hook-free runs on the stock jar, a new writer per round: 4 threads (2 deleting, 2 updating) at a 0.1 MB RAM buffer left 6 of 12 rounds stale (0 of 12 with the fix); an independent verifier (2 deleting, 2 adding threads) saw 6 of 10 at 0.1 MB (0 of 10 with the fix), 2 of 10 at 1 MB and 0 of 5 at the default 16 MB; with serial merges 1 of 6 rounds stayed stale after a second commit (0 of 6 with the fix); 16 threads on 2 CPUs fired it at 0.02 MB, and 16 runs with 8-16 threads at 0.05-2 MB did not. The rate at 16 MB is unmeasured.
One JUnit test, one method, single thread, no index: call the flush queue the way the writer does when nothing is left to freeze, addTicket(() -> null), then check hasTickets(). It sits in org.apache.lucene.index because the queue is package-private; the two-thread scenario above shows the public effect.
/*
* 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.tests.util.LuceneTestCase;
public class TestDocumentsWriterFlushQueue extends LuceneTestCase {
public void testNullTicketDoesNotLeaveTicketCount() throws Exception {
DocumentsWriterFlushQueue queue = new DocumentsWriterFlushQueue();
assertNull(queue.addTicket(() -> null));
// expected: no pending ticket after a null ticket (as on 10.5.2); actual: hasTickets() stays true
assertFalse(queue.hasTickets());
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestDocumentsWriterFlushQueue -Ptests.seed=DEADBEEF
- Expected
hasTickets()is false after a null ticket, as on 10.5.2.- Observed
hasTickets()stays true:AssertionErroratTestDocumentsWriterFlushQueue.java:27, with assertions on and off.
More files, environment, full output223 words
The supplier returning null is exactly what DocumentsWriter.applyAllDeletes passes when the buffer is already empty, so the test needs no threads and no timing. The public consequence (a fresh NRT reader not current after commit()) is shown by the supplementary two-thread test described in the explanation; it is evidence, not the regression test.
Regression test: this same test. It passes with the fix and can go in a new TestDocumentsWriterFlushQueue (none exists upstream) or as a method in TestDocumentsWriterDeleteQueue. The bare AssertionError comes from the assertFalse without a message; a message can be added without changing the result.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. 10.6.0 (the released core jar), 10.5.2, 9.12.3 and 9.9.0: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestDocumentsWriterFlushQueue.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestDocumentsWriterFlushQueue -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestDocumentsWriterFlushQueue > testNullTicketDoesNotLeaveTicketCount FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:205223864E05044B]:0)
at org.junit.Assert.fail(Assert.java:87)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertFalse(Assert.java:65)
at org.junit.Assert.assertFalse(Assert.java:75)
at org.apache.lucene.index.TestDocumentsWriterFlushQueue.testNullTicketDoesNotLeaveTicketCount(TestDocumentsWriterFlushQueue.java:27)
# with the fix: 1 test, 0 failures (assertions on and off)
# supplementary two-thread test on stock main (evidence, not the repro)
TestNrtReaderStaysStaleAfterRacingDeleteFreeze > testFreshNrtReaderIsCurrentAfterRacingDeletePush FAILED
java.lang.AssertionError: a fresh NRT reader must be current- For an NRT reader
isCurrent()checks whether "a new commit has occurred, or any new uncommitted changes have taken place via the writer". Aftercommit()and a fresh open, neither has. - A null ticket is designed; its count was given back from 2019 (LUCENE-8813) until the #15134 cleanup.
- Lucene tolerates a brief false (LUCENE-3581); this one never clears.
The full argument261 words
- The contract.
DirectoryReader.java:631-638: for an NRT reader,isCurrent()"checks if either a new commit has occurred, or any new uncommitted changes have taken place via the writer. Note that even if the writer has only performed merging, this method will still return false." The upstream testTestIndexWriterReader.testIsCurrentassertsisCurrent()for fresh NRT readers. - A null ticket is designed. The freeze already returned null for an empty buffer before 2019, and
addDeletesthen queued a ticket with no updates, so the count matched the queue. LUCENE-8813 (165d2d5ff531, 2019, 8.2.0) added a null result for a closed queue and madeaddDeletesqueue no ticket when the freeze returns null (closed queue or empty buffer), leavingsuccessfalse so the count was given back; #12647 (268dd54a869, 9.9.0) introducedaddTicket(Supplier)and kept that.applyAllDeletestests the result for!= null, andaddTicketsays "no need to publish anything if we don't have any frozen updates". - The change was a mechanical cleanup. #15134 ("Remove even more boolean success flags") changed 15 files, is labelled
skip-changelog, and has no review comment on this file. Its diff replacesfinally { if (!success) decTickets(); }, wheresuccesswas set only when a ticket was queued, withcatch (Throwable t) { decTickets(); throw t; }. Its parentc67f8d38641passes both tests;b85f030c6ccfails both. - Why the count exists. LUCENE-3551 (#4625, 2011) added
hasTickets()toanyChanges()(DocumentsWriter.java:345-351), so that changes sitting in the ticket queue still count. A count with no ticket behind it is the opposite of that design.
The devil's-advocate check156 words
The strongest case: Lucene already accepts a spurious isCurrent() == false. TestIndexWriterReader.testReopenAfterNoRealChange asserts isCurrent() == false and then openIfChanged() == null after a delete that matches nothing. LUCENE-3581 (#4655, open since 2011) asks to remove that conservatism, and Michael McCandless commented there in 2012: "We are returning \"false\" when we could return \"true\" from isCurrent, but this just means the app will go through the reopen when it didn't have to...? Ie relatively minor?". And hasUncommittedChanges() is documented as allowed to be true with no real change.
Why it fails: every accepted case is transient, because the next reopen applies the pending delete and the reader is current again. Here nothing is pending, the count is never given back, and the false survives commit(), forceMerge(1) and every later reader. That supports a low impact, not "intended". No single-thread trigger was found: deleteDocuments(new Term[0]) followed by commit() does not leak. Verdict: a regression, not intended.
Recommended Give the count back for a null ticket
In DocumentsWriterFlushQueue.addTicket, lower the count when the supplier returns null: } else { decTickets(); }, 2 added lines. It restores the behaviour from before #15134 for both callers that can get a null. Validated: the repro and the two-thread test pass, and so do 226 tests in 8 closest suites.
if (ticket != null) {
// no need to publish anything if we don't have any frozen updates
queue.add(ticket);
} else {
decTickets();
}
return ticket;All options and trade-offs3 options ยท 294 words
One option was validated in a scratch worktree of 645d4fe14a20; the second is equivalent; the third is a trap to avoid.
One file, +2 lines. Keeps #15134's cleanup (no success flag) and the early incTickets() that guards the freeze window.
if (ticket != null) {
// no need to publish anything if we don't have any frozen updates
queue.add(ticket);
} else {
decTickets();
}
return ticket;Trade-offs
None found. The count is still raised during the freeze, so anyChanges() cannot miss deletes that are being frozen.
What the checks validated
- Stock: the minimal test fails with assertions on and off.
- With the fix: the minimal test and the two-thread test pass with assertions on and off (the assertions-off step was re-run because its first attempt stopped before any test ran).
- Closest suites with the fix:
TestIndexWriterReader(incl.testIsCurrentandtestReopenAfterNoRealChange),TestIndexWriter,TestIndexWriterDelete,TestDocumentsWriterDeleteQueue,TestFlushByRamOrCountsPolicy,TestIndexWriterExceptions,TestIndexWriterExceptions2,TestIndexWriterThreadsToSegments: 8 classes, 226 tests, 0 failures, 12 skipped. :lucene:core:checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass.- Not run: nightly tests, benchmarks.
Put boolean success and finally { if (!success) decTickets(); } back, as at c67f8d38641. Main with the parent's DocumentsWriterFlushQueue.java passes the two-thread test.
Trade-offs
Same behaviour as the recommended fix, but it undoes part of the #15134 cleanup.
Moving incTickets() after queue.add(ticket) also stops the leak.
Trade-offs
It reopens the window the comment on incTickets() guards: the freeze clears the global buffer before the ticket is queued, so anyChanges() could report no changes while frozen deletes are in flight, the unsafe direction ("current" while changes are missing).
A server built on main that decides when to refresh from isCurrent() would refresh, and flush, on every check after the race, until its writer is reopened.
Code reading of check-then-act sites in the writer, where a condition checked before a lock may no longer hold when the work runs (applyAllDeletes). Following the freeze's null result into addTicket showed the count is never given back. A bounded-exhaustive check of the real flush queue, hook-free runs and a two-thread test confirmed it.
The method in full184 words
Method: code reading, then executed checks (the study's "non-formal" tier). A hunt over sites where state is read before a lock is taken and acted on after it listed DocumentsWriter.applyAllDeletes: its preconditions (an open, non-empty delete buffer) can be gone when the freeze runs, so the supplier returns null. Following that null into DocumentsWriterFlushQueue.addTicket showed the count is never given back.
- Bounded-exhaustive translation validation: the real
DocumentsWriterFlushQueueagainst an independent counter model, over all 55,987 operation sequences of length up to 6 (null supplier, deletes ticket, segment ticket, mark failed, purge, throwing supplier): 36,456 mismatching on main, exactly the sequences with a null supplier, and 0 with the fix and on 10.5.2. - Hook-free runs on the stock jar: stale in 6 of 12 rounds at a 0.1 MB RAM buffer, 0 of 12 with the fix.
- A deterministic two-thread JUnit on the public API (an
InfoStreammessage hook fixes the timing), then the one-line minimal test. - A second hunt, a TLA+ model of the flush family, later found the same root (invariant
TicketCountExact, through the closed-queue null) and handed its evidence over.
No prior report found in GitHub issues and PRs (incl. the JIRA archive), the Lucene mailing lists or downstream trackers (2026-10-29, re-checked 2026-10-31). Open PR #16948 shows a similar symptom with a different root, in IndexWriter's constructor.
Searches5 searches
Verdict new. #15134 is the introducing PR and has no review comment on this file.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, JIRA archive) | 2026-10-29 | 64 queries, then 45 new variants in a refresh; 1,426 and 276 distinct hits | 0 high; the medium hits are from 2007-2024, before the regression |
| GitHub, by hand | 2026-10-29 | 38 + 28 searches (addTicket, ticketCount, decTickets, hasTickets, DocumentsWriterFlushQueue, nrtIsCurrent, anyChanges, isCurrent/hasUncommittedChanges since 2025-10, "success flags", 15134); all items created since 2026-10-26; the changed files of all 409 open PRs and the 100 most recently updated closed PRs | no report; no PR touches DocumentsWriterFlushQueue.java |
| lists.apache.org | 2026-10-29 | 36 + 24 phrase searches on dev@, java-user@, issues@, builds@ | no report; the maintainer replies on the dev@ thread name only MMapDirectory bugs as known |
| GitHub re-check | 2026-10-31 | search for DocumentsWriterFlushQueue, addTicket, decTickets; changed files of the 18 PRs updated since 2026-10-29; last commit on the file on main and branch_11x | only #14261 (an older, unrelated test failure); still b85f030c6cc; #16867 changes the delete-push trigger, not addTicket |
| Closest items (read) | 2026-10-29 | #16948/#13151, #4655 (LUCENE-3581), #4625 (LUCENE-3551), #5741 (LUCENE-4676), #15134, #12647, #16888, #16923, #16900 | none is this bug: other roots, the transient false, design context (#4625 added hasTickets() to anyChanges(); #5741 is an isCurrent() race of 2013 that was closed as a test bug), history, or the opposite symptom |
Introduced on main by #15134 (b85f030c6cc, 2025-10-09), a success-flag cleanup that was never backported. No release has it (10.6.0, 10.5.2 and 9.12.3 pass); it would first ship in 11.0.0. Not fixed upstream as of 2026-10-31.
Versions tested6 rows
Introduced by #15134 "Remove even more boolean success flags" (b85f030c6cc, thecoop, 2025-10-09, label skip-changelog), which replaced the success-flag finally in addTicket with a catch (Throwable). Blame at 645d4fe14a20 puts the catch block on it, and the file is byte-identical at b85f030c6cc and 645d4fe14a20.
Earlier, on purpose: LUCENE-8813 (165d2d5ff531, 2019, 8.2.0) added a null freeze result for a closed queue and made a null result (closed queue or empty buffer) queue no ticket and give its count back; #12647 (268dd54a869, 9.9.0) introduced addTicket(Supplier) and kept that.
Branches: git tag --contains b85f030c6cc is empty; every release tag from 8.2.0 to 8.8.1 and from 9.0.0 to 10.6.0, and branch_10_6, keep the success-flag code; branch_11x and upstream main (2f424fd428da, 12.0.0-SNAPSHOT) still have b85f030c6cc as the last commit on the file.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, assertions on and off; two-thread test fails 20 of 20 (-da); passes with the fix |
b85f030c6cc (#15134) vs parent c67f8d38641 | boundary | both tests fail on #15134 and pass on the parent, assertions on and off |
branch_11x, upstream main 2f424fd428da | read | same addTicket; last commit on the file is b85f030c6cc |
10.6.0 4ae946e5a9b | not affected | released; the same commit as RC1, where the core-jar main() form of both tests passed; the minimal test passes on the released jar |
| 10.5.2, 9.12.3 | not affected | both tests pass, assertions on and off |
9.9.0; 9.8.0 (port to addDeletes) | not affected | minimal test passes |
Confirmed by two executed cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier, an adjudication and an independent fact check. A TLA+ model from a second hunt found the same root, and the history leg bracketed the commit. This doc's gradle run reproduced it and validated the fix.
Checks and verdicts384 words
Every check agrees; none produced a not-real argument.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, bounded-exhaustive check, JUnit) | candidate | 36,456 of 55,987 operation sequences wrong on main (those with a null supplier), 0 with the fix and on 10.5.2; the two-thread test fails 20 of 20; hook-free 6 of 12 at 0.1 MB. |
| Refute review (OpenAI GPT-5.6, executed) | real (high) | Own test fails with assertions on (1 of 1) and off (3 of 3), on main ea57360912ed (the same DocumentsWriterFlushQueue.java); a non-racing control passes; with the fix testIsCurrent and testHasUncommittedChanges pass. |
| Independent reproducer | real | Own test with the default 16 MB buffer fails 10 of 10; the fix, 10.5.2 and 9.12.3 pass; stale through 3 more rounds of commits and forceMerge(1). |
| Intended and root-cause lenses (OpenAI GPT-5.6, executed) | real (high) | The leak is permanent, so "works as designed" fails; the fix covers both callers; TestIndexWriterReader passes with the fix. Caveat: the earlier verdicts were visible to it. |
| Intended-behaviour devil's advocate | real | No intended, documented or misuse argument holds; reached the state hook-free with serial merges (1 of 6); no single-thread trigger. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test (5 of 5 fail, the fix 5 of 5 pass) and the minimal test; impact minor. |
| History follow-up | confirmed | Commit bracket b85f030c6cc / c67f8d38641; 10.6.0 (then RC1), 10.5.2, 9.12.3, 9.9.0 and a 9.8.0 port pass. |
| Second hunt (TLA+ model of the flush family) | same root | TicketCountExact violated through the closed-queue null; clean with the decrement restored. |
| Credibility audit and no-noise gate | keep; claim-low | Re-ran the minimal and two-thread tests on stock and with the fix; real but minor, one minimal test. |
| This doc | real; fix validated | Gradle in a scratch worktree of 645d4fe14a20: stock fails; the fix passes the repro, the two-thread test and 226 tests in 8 closest suites. |
| Independent fact check (executed) | real; 4 text corrections | Re-ran the minimal test through the standalone runner and through the gradle command (fails, assertions on and off; passes with the fix), on the released 10.6.0 jar, and both tests on 10.5.2 and 9.12.3 with assertions on (pass). A counting copy of the queue showed the count steps. Its four text corrections are applied here. |
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 488 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,505 words
- Registry title
- IndexWriter on main: a push of the buffered deletes that finds nothing left to freeze leaks one flush-ticket count, so for the rest of the writer's life every NRT reader reports isCurrent() == false, even right after commit(), while openIfChanged() returns null
- Area
- index / IndexWriter NRT (DocumentsWriterFlushQueue flush-ticket count, DocumentsWriter.anyChanges)
- Priority
- Low, rank 757: A main-only IndexWriter regression that, after a rare delete-freeze race, leaves NRT readers permanently reporting isCurrent() false and makes every refresh do pointless work but fails safe with no wrong hits.
- Impact class
- minor
- Impact note
- Applications on main (unreleased 11.0) that index from several threads with deleteDocuments or updateDocument on one IndexWriter, default config: after one losing race between two pushes of the buffered deletes, every NRT reader of that writer reports isCurrent() == false for the rest of the writer's life, even right after commit(), while openIfChanged() returns null, so a currentness check never clears and every refresh runs a full flush only to find nothing changed; no hits, documents or deletes are wrong, commit() is unaffected, a new IndexWriter clears it, and how often the race fires at the default 16 MB RAM buffer is unmeasured (0 of 5 hook-free rounds there).
- Summary
- Several threads add and delete documents on one IndexWriter; afterwards you commit and open an NRT reader without changing anything else, and you expect that reader's isCurrent() to be true, as it is on 10.5.2. Instead, if two threads pushed the buffered deletes at the same time and the second push found nothing left to freeze, DocumentsWriterFlushQueue.addTicket counted a ticket it never queued and never gives the count back (a regression of #15134). From then on DocumentsWriter.anyChanges() is always true, so every NRT reader of the writer, also ones opened after later commits, reports isCurrent() == false while openIfChanged() returns null.
- Introduced
- b85f030c6cce80fa44ee89b9f13b5aa346767fd5 (main; apache/lucene#15134 'Remove even more boolean success flags', author thecoop, approved by uschindler; label skip-changelog). In DocumentsWriterFlushQueue.addTicket it replaced the success flag plus finally { if (!success) decTickets(); }, which gave the count back both for an exception and for a null ticket, with catch (Throwable t) { decTickets(); throw t; }, which covers only the exception [read: blame on main 645d4fe14a20 puts the catch block, DocumentsWriterFlushQueue.java:48-50, on b85f030c6cce, and the file is byte-identical at b85f030c6cc and 645d4fe14a20; exec: the minimal test and the two-thread public-API test both fail on a build of b85f030c6cc and pass on a build of its parent c67f8d38641, -ea and -da]. Enabling history, not defects: LUCENE-8813 (165d2d5ff531, 8.2.0) made the global-buffer freeze return null for a closed or empty queue and gave the count back for it on purpose; #12647 (268dd54a869, 9.9.0) introduced addTicket(Supplier) with the two null-capable callers in DocumentsWriter.applyAllDeletes and flushAllThreads and kept the give-back. (2025-10-09 (author and commit date of b85f030c6cc; PR merged 2025-10-09T08:22Z, opened 2025-08-28))
- First affected version
- unreleased (main since b85f030c6cc: 11.0.0-SNAPSHOT at 645d4fe14a20, upstream main is 12.0.0-SNAPSHOT since #16942; and branch_11x, so it would first ship in 11.0.0). No release has it: #15134 was never backported, so 10.6.0 (its RC1 revision 4ae946e5a9b) and every earlier release keep the old success-flag code
- Affected versions
- [exec] main 645d4fe14a20 (the minimal test fails with -ea and -da; the two-thread public-API test fails with -ea and -da, 20 of 20 iterations with -da, and the independent reproduction's own default-config test 10 of 10 with -da) and a build of the introducing commit b85f030c6cc (both tests, -ea and -da); main ea57360912ed (the cross-model reviewers' own tests). [read] upstream main 716d6f216f52 (head at 2026-10-29) and branch_11x: the last commit on DocumentsWriterFlushQueue.java is still b85f030c6cc. Not affected [exec]: the parent commit c67f8d38641 (both tests, -ea and -da), main with the parent's DocumentsWriterFlushQueue.java (-da), 10.6.0 RC1 4ae946e5a9b (core-jar main() forms of both tests, -da), 10.5.2 and 9.12.3 (-ea and -da), 9.9.0 (minimal test, -da) and 9.8.0 (a port to addDeletes, -da); [read] every release 9.0.0 to 10.5.2, 8.2.0 to 8.8.1 and branch_10_6 (old success-flag code; branch_10x was retired upstream by #16911 on 2026-10-29)
- 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: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-29, read-only). The prior-report record (verdict new): a full search (a scripted prior-report search with 64 queries over GitHub issues and PRs, commits, Elasticsearch, OpenSearch and Solr, dev@, java-user@, issues@ and commits@, and the JIRA archive: 1,426 distinct hits, 0 high, the 12 medium all from 2007-2024; 6 web queries; 38 manual GitHub searches; 36 lists.apache.org phrase searches; every apache/lucene item created since 2026-10-26; the changed files of all 409 open PRs and of the 100 most recently updated closed PRs, none touching DocumentsWriterFlushQueue.java; PR #15134 re-read in full, no review comment on the file) and a refresh with new query variants (45 scripted prior-report search queries, 276 hits, 0 high; 28 GitHub GraphQL, 10 REST, 16 downstream, 24 mailing-list and 3 web queries; every item updated since a recent cutoff; the maintainer replies on the dev@ thread name only MMapDirectory bugs as already known). Checks: GitHub search for DocumentsWriterFlushQueue, addTicket, decTickets and 'ticket count' finds only old unrelated items (#14261 and pre-2019 test failures); the only isCurrent or hasUncommittedChanges items created since 2026-10-27 are #16948 and #16923; the PRs updated since a recent cutoff (#16915, #16931, #16941, #16943) touch no writer file; the last commit on DocumentsWriterFlushQueue.java on upstream main (head 716d6f216f52) and on branch_11x is still b85f030c6cc. Closest items, not reports: #15134 (the introducing PR), #12647 (introduced the Supplier-based addTicket and kept the null-ticket give-back), #5741 (LUCENE-4676, added hasTickets() to anyChanges()), open #4655 (LUCENE-3581, a transient, conservative isCurrent() == false while only buffered deletes are pending), open PR #16948 with its issue #13151 (by others; the same symptom family, hasUncommittedChanges() true and isCurrent() false, but a different root in IndexWriter's constructor; it does not touch the ticket queue and would not fix this), open PR #16888 (the #15134 author's continuing success-flag cleanup; it does not touch this file), and open PR #16923 and #16900 (the opposite symptom). Not fixed upstream. Tracking issue #16937 does not list it.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the one-line fix it passes, so do the supplementary two-thread test and 226 tests in 8 closest suites. The introducing commitb85f030c6ccfails and its parentc67f8d38641passes. 10.6.0 (core jar), 10.5.2, 9.12.3, 9.9.0 and a 9.8.0 port pass. Upstream main2f424fd428daand branch_11x still have the sameDocumentsWriterFlushQueue.java(read, 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushQueue.java:38-52 (at 645d4fe14a20) (addTicket: incTickets() first; a null ticket from the supplier is returned with no decTickets(); only catch (Throwable) gives the count back), :77-80 (hasTickets: ticketCount != 0) and :105 (innerPurge decrements once per polled ticket)lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:168-186 (at 645d4fe14a20) (applyAllDeletes: addTicket(() -> maybeFreezeGlobalBuffer(deleteQueue))), :344-357 (anyChanges includes ticketQueue.hasTickets()), :690-703 (flushAllThreads: the naked frozen deletes, a second null-capable caller), :717-725 (maybeFreezeGlobalBuffer returns null when nothing is frozen), :569 (the ticket-backlog check also reads the count)lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:243-277 (at 645d4fe14a20) (maybeFreezeGlobalBuffer / freezeGlobalBufferInternal return null for a closed queue or an empty global buffer)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6140-6146 (at 645d4fe14a20) (nrtIsCurrent requires docWriter.anyChanges() == false) and :4218-4232 (hasUncommittedChanges javadoc allows a spurious true; hasChangesInRam -> docWriter.anyChanges())lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:626-642 (at 645d4fe14a20) (isCurrent contract: for an NRT reader it checks whether a new commit or new uncommitted changes happened; false only otherwise documented for merging)lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:462-470 (at 645d4fe14a20) (doOpenFromWriter: nrtIsCurrent false -> writer.getReader(), then null when the version did not change)lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:232 (at 645d4fe14a20) (isSearcherCurrent is package-private)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushQueue.java:31-52, (at 645d4fe14a20) :77-80, :102-105lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:96, (at 645d4fe14a20) :168-186, :345-356, :569-575, :691-699, :717-725lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:240-277 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:207-215, (at 645d4fe14a20) :238, :486-489, :510-524; FlushByRamOrCountsPolicy.java:41-56lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:516, (at 645d4fe14a20) :586, :4218-4232, :5934-5936, :6140-6146; StandardDirectoryReader.java:456-475
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168 (at 645d4fe14a20) (thread safety)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:625-642 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:203-240 (at 645d4fe14a20) (testIsCurrent), :1072-1100 (testReopenAfterNoRealChange)lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushQueue.java:44; (at 645d4fe14a20) DocumentsWriter.java:180; IndexWriter.java:4218-4224https://github.com/apache/lucene/issues/4655 (LUCENE-3581; Michael McCandless comment of 2012-03-25, read 2026-10-31)
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushQueue.java:39-40 (at 645d4fe14a20) (the comment on incTickets()); DocumentsWriterDeleteQueue.java:262-273 (freeze clears the buffer before the ticket is queued)
- Prior reports
GitHub API, read-only, 2026-10-31 (gh search prs/issues; commits?path=...DocumentsWriterFlushQueue.java on main and branch_11x; files of PRs updated since 2026-10-29; gh pr diff 16867)
- History
GitHub API, read-only, 2026-10-31 (commits?path=... on main, branch_11x, branch_10_6; main head 2f424fd428da)
- TestDocumentsWriterFlushQueue.java (one test):
repro/LFV-1043/TestDocumentsWriterFlushQueue.java(29 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe of the two-thread schedule on main (assertions on and off), with the fix and on 10.5.2.
assets/LFV-1043/bug.png(488 KB)
