All findings · Lucene finding · LFV-1043 · flush-ticket-count-leaks-on-null-ticket

On main, a delete push that finds nothing to freeze leaks a flush-ticket count, and NRT readers report isCurrent() == false for the rest of the writer's life

On Lucene main, when two indexing threads push the buffered deletes at once and the second finds nothing left, IndexWriter counts a flush ticket it never queued. From then on every NRT reader says isCurrent() == false, even right after commit(), while openIfChanged() returns null; search results stay correct.

Summary

Priority
Low, rank 757A 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
minor
Since
#15134 on main (2025-10-09)introduced 2025-10-09 · about 1 year
Affected
main and branch_11x (unreleased 11.0); no release
Status
documented
Found by
non-formal: code reading, then a bounded-exhaustive check and tests

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

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.

Background

  1. Buffered deletes. deleteDocuments and updateDocument collect 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.
  2. 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, IndexWriter reports pending changes.
  3. 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 call openIfChanged, which flushes and returns null if nothing changed.
Diagram of the bug: thread deleter freezes 17.4 MB of buffered deletes while thread adder waits in addTicket; the deleter's ticket is queued, the adder's push then finds an empty buffer and raises the count to 2 without queuing anything, the deleter's ticket is published, and the count stays at 1 with 0 tickets queued; and after commit() a fresh NRT reader reports isCurrent() false while openIfChanged() returns null. With the fix and on 10.5.2 the count is 0 and the reader is current.
The two-thread example: the second push of the same deletes finds an empty buffer, the ticket count stays 1 with no ticket queued, and readers stay not current after commit(). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Two threads share one IndexWriter with 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.
  2. The second push waits for the first, then finds the buffer empty, so the freeze returns null. addTicket had 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.
  3. You commit() and open DirectoryReader.open(writer). You expect isCurrent() == true: nothing changed after it opened, and 10.5.2 returns true.
  4. Instead isCurrent() is false while openIfChanged(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.
  5. Hits, documents and deletes stay correct. The core fits in one line: addTicket(() -> null) leaves hasTickets() true.

Who is affected

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.

The code (main 645d4fe14a20)

  1. 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() and deleteQueue.isOpen(); the thread that wins getAndResetApplyAllDeletes() (an atomic getAndSet(false)) calls ticketQueue.addTicket(() -> maybeFreezeGlobalBuffer(deleteQueue)) (:180).
  2. Two pushes can overlap. The freeze builds the frozen packet before it clears the global buffer (DocumentsWriterDeleteQueue.java:262-273) and runs inside addTicket, which is synchronized (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.
  3. 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 and addTicket returns null (DocumentsWriterFlushQueue.java:42-47). decTickets() runs only in catch (Throwable) (:48-50) and in innerPurge, once per polled ticket (:102-105). The queue is a final field of DocumentsWriter (:96), so the extra 1 stays for the writer's life, and each further losing race adds one.
  4. What reads it. hasTickets() is ticketCount.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), and IndexWriter.nrtIsCurrent requires anyChanges() == false (IndexWriter.java:6140-6146).
  5. Refresh. openIfChanged on an NRT reader goes to StandardDirectoryReader.doOpenFromWriter (:456-475): nrtIsCurrent is false, so it calls writer.getReader(...) (IndexWriter.java:516, a full flush at :586), finds the same version and returns null.
  6. 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. And flushAllThreads pushes 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.
  7. Not affected. commit() decides on changeCount and 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 in doFlush also reads the count (DocumentsWriter.java:569-575); that was not measured.

The numbers

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. IndexWriter is 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 after commit(), while openIfChanged() returns null. A loop that waits for isCurrent() 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 IndexWriter starts 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.

Minimal repro

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.

TestDocumentsWriterFlushQueue.java (one test)29 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.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());
  }
}
Command
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: AssertionError at TestDocumentsWriterFlushQueue.java:27, with assertions on and off.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • For an NRT reader isCurrent() checks whether "a new commit has occurred, or any new uncommitted changes have taken place via the writer". After commit() 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 test TestIndexWriterReader.testIsCurrent asserts isCurrent() for fresh NRT readers.
  • A null ticket is designed. The freeze already returned null for an empty buffer before 2019, and addDeletes then 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 made addDeletes queue no ticket when the freeze returns null (closed queue or empty buffer), leaving success false so the count was given back; #12647 (268dd54a869, 9.9.0) introduced addTicket(Supplier) and kept that. applyAllDeletes tests the result for != null, and addTicket says "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 replaces finally { if (!success) decTickets(); }, where success was set only when a ticket was queued, with catch (Throwable t) { decTickets(); throw t; }. Its parent c67f8d38641 passes both tests; b85f030c6cc fails both.
  • Why the count exists. LUCENE-3551 (#4625, 2011) added hasTickets() to anyChanges() (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.

Suggested fix

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.

Give the count back for a null ticket (validated by the checks) (recommended)

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. testIsCurrent and testReopenAfterNoRealChange), TestIndexWriter, TestIndexWriterDelete, TestDocumentsWriterDeleteQueue, TestFlushByRamOrCountsPolicy, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestIndexWriterThreadsToSegments: 8 classes, 226 tests, 0 failures, 12 skipped.
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Not run: nightly tests, benchmarks.

Restore the success-flag form of the method

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.

Not recommended: raise the count only after a ticket is queued

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

Downstream note

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.

How it was found

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 DocumentsWriterFlushQueue against 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 InfoStream message 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, JIRA archive)2026-10-2964 queries, then 45 new variants in a refresh; 1,426 and 276 distinct hits0 high; the medium hits are from 2007-2024, before the regression
GitHub, by hand2026-10-2938 + 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 PRsno report; no PR touches DocumentsWriterFlushQueue.java
lists.apache.org2026-10-2936 + 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-check2026-10-31search for DocumentsWriterFlushQueue, addTicket, decTickets; changed files of the 18 PRs updated since 2026-10-29; last commit on the file on main and branch_11xonly #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, #16900none 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off; two-thread test fails 20 of 20 (-da); passes with the fix
b85f030c6cc (#15134) vs parent c67f8d38641 boundaryboth tests fail on #15134 and pass on the parent, assertions on and off
branch_11x, upstream main 2f424fd428da readsame addTicket; last commit on the file is b85f030c6cc
10.6.0 4ae946e5a9b not affectedreleased; 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 affectedboth tests pass, assertions on and off
9.9.0; 9.8.0 (port to addDeletes) not affectedminimal test passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, bounded-exhaustive check, JUnit) candidate36,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 realOwn 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 realNo 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 confirmedCommit 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 rootTicketCountExact violated through the closed-queue null; clean with the decrement restored.
Credibility audit and no-noise gate keep; claim-lowRe-ran the minimal and two-thread tests on stock and with the fix; real but minor, one minimal test.
This doc real; fix validatedGradle 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 correctionsRe-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.

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,505 words

Finding record

LFV-1043, snapshot of 2026-11-06

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 commit b85f030c6cc fails and its parent c67f8d38641 passes. 10.6.0 (core jar), 10.5.2, 9.12.3, 9.9.0 and a 9.8.0 port pass. Upstream main 2f424fd428da and branch_11x still have the same DocumentsWriterFlushQueue.java (read, 2026-10-31).

Lucene and upstream sources

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-105
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:96, (at 645d4fe14a20) :168-186, :345-356, :569-575, :691-699, :717-725
  • lucene/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-56
  • lucene/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-4224
  • https://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)

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

Repro files

About the diagrams

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.

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.