All findings · Lucene finding · LFV-1066 · searcherlifetime-prune-sorts-by-first-record-time

SearcherLifetimeManager.prune sorts searchers by first-record time, so PruneByAge can drop the current searcher

When a request records an older searcher after another request has recorded the refreshed one, SearcherLifetimeManager.prune takes the older searcher for the newest, and PruneByAge drops the current searcher even right after record() returned a token for it: acquire(token) returns null. Low impact, in every release since 3.5.0.

Summary

Priority
Low, rank 946When an older searcher is first recorded late, the opt-in experimental SearcherLifetimeManager can prune the current searcher and keep the older one, so paging requests get the documented 'timed out' null. No result is wrong, the timing window is narrow and nobody has reported it since 3.5.0, so it is Low.
Impact
minor
Since
3.5.0 (LUCENE-3486, 2011-10-10)introduced 2011-10-10 · over 15 years
Affected
every release from 3.5.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a preemption and refresh-fault exploration of the NRT reference managers, then TLA+ model checking and a bounded-exhaustive test of the real classes

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

prune should rank the kept searchers newest first, but it ranks them by when each was first recorded. So when an older searcher is recorded late, it is taken for the newest (age 0), and the current searcher is aged from that late record and pruned.

Background: keeping searchers for follow-up requests

  1. SearcherManager hands out the current IndexSearcher and, after index changes, opens a new one (maybeRefresh()).
  2. SearcherLifetimeManager keeps older searchers open for follow-up requests: record(searcher) returns a token (the reader version), and acquire(token) returns the same searcher for the next page, or null once it was pruned ("session timed out").
  3. prune(pruner) passes the kept searchers newest first, each with its age, the time since it was the current searcher. PruneByAge(maxAge) drops those older than maxAge, so the newest searcher, age 0, should always stay.
Timeline of the bug: request A acquires searcher S1; a document is added and a refresh makes S2 current; request B records S2; A then records S1 for the first time. prune sorts by first-record time, so it passes S1 first with age 0 and S2 second, aged from S1's late record. PruneByAge drops S2, the current searcher, so acquire(token) for S2 returns null, and the older S1 stays open.
A timeline of the two searchers: S2 recorded first, the older S1 recorded late, prune then ordering [S1, S2] and dropping S2, the current searcher. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You follow the class javadoc: each new request takes the latest searcher from SearcherManager and calls record() for a paging token; a reopen thread calls prune(new PruneByAge(maxAge)).
  2. Request A takes searcher S1. One document is added and maybeRefresh() makes S2 current. Request B records S2; then A, still running, records S1, so S1's first record comes after S2's.
  3. You expect prune to pass S2 first with age 0 ("newest to oldest"; age is the time since a searcher was current), so S2 and every token for it survive.
  4. Instead prune sorts by first-record time: S1 gets age 0 and S2 is aged from S1's record. 250 ms after A's record, request C records S2 and gets a token, prune(new PruneByAge(0.1)) drops S2 anyway, acquire(token) returns null, and the older S1 stays open.

Who is affected

Users of the opt-in, experimental SearcherLifetimeManager where a request can record an older searcher after the refreshed one was recorded, for example by recording after running the query. They get a spurious "session timed out" null until the next record(), an older reader kept open longer, and count-based pruners keep the wrong searcher. No wrong results.

Mechanism, terms and numbers711 words

SearcherLifetimeManager keeps older searchers open so that a follow-up request (the next page) can reuse the searcher of the first request through the token record() returns. prune(Pruner) is documented to pass the searchers newest to oldest, each with its age, the time since it was the current searcher, and PruneByAge drops only searchers older than the newest by more than maxAge. The code instead sorts by the time each reader version was first recorded. When a request records an older searcher for the first time after another request has recorded the refreshed one, prune takes the older searcher for the newest (age 0) and ages the current searcher from that late record. PruneByAge then drops the current searcher, even right after record() returned a token for it, and keeps the older one. The effect is small and recoverable: acquire(token) returns the documented "session timed out" null until the next record() adds the searcher back, the older reader stays open longer than maxAge, and a count-based pruner keeps the wrong searcher. No search result is wrong. It dates from 3.5.0, when the class was added, and every release tried since then fails.

The mechanism (main 645d4fe14a20)

  1. The sort key is the first-record time. SearcherTracker stores recordTimeSec = System.nanoTime() / NANOS_PER_SEC in its constructor (SearcherLifetimeManager.java:97-104), and compareTo is Double.compare(other.recordTimeSec, recordTimeSec) under the comment "Newer searchers are sort before older ones" (:106-110).
  2. It is set once per reader version. record() looks the tracker up by reader version and creates one only if none exists (:146-155); a later record() of the same searcher returns the same token and changes nothing (:156-164). Its javadoc invites repeated calls: "Always call this when you've obtained a possibly new IndexSearcher ... It's fine if you already passed the same searcher to this method before" (:132-134).
  3. Ages come from the sorted neighbour. prune copies and sorts the trackers (:239-243), gives the first age 0 (:248-249) and each later one now - lastRecordTimeSec, the record time of the tracker before it in that order (:251, :263), as the comment says: "First tracker is always age 0.0 sec, since it's still 'live'; second tracker's age (= seconds since it was 'live') is now minus first tracker's recordTime" (:253-256).
  4. What goes wrong. Record S2 at t0, then the older S1 for the first time at t1 > t0. The sorted list is [S1, S2]: S1 gets age 0, S2 gets now - t1. PruneByAge returns ageSec > maxAgeSec (:222-224), so S2 is removed and its reader released (:257-262) once now - t1 > maxAge; acquire(version) then finds no tracker and returns null (:177-185).
  5. Why it heals. The token is the reader version (:164). While S2 is SearcherManager's current searcher its reader stays open, so the next record(S2) creates a new tracker with a fresh record time, which sorts first, and old tokens for S2 resolve again; S1 is then aged from that re-record.

The numbers (executed)

  • Minimal test, no sleep: record S2, record S1, record S2 again for a token, prune(new PruneByAge(0)) (which must keep the newest searcher): acquire(token) is null. Fails 1/1 with -ea, 10/10 and 20/20 with -da on main; also on 10.6.0 and 9.12.3.
  • First repro: 250 ms after A's late record of S1, request C records S2 and prune(new PruneByAge(0.1)) drops it.
  • Impact demo: 1.5 s after the two records, prune(new PruneByAge(1.0)): acquire(token of S2) is null while acquire(token of S1) returns the older searcher; with the fix it is the other way round.
  • Bounded-exhaustive test of the real classes over all 8,892 operation sequences of length up to 10: 520 order mismatches and 380 cases where the newest recorded searcher was pruned on stock, all and only in sequences with a late first record; 0 with the fix.
  • With the javadoc's PruneByAge(600.0) the same path drops S2 once 600 s have passed since the late record, whether or not a newer searcher is first recorded in the meantime; if none is, S2 is still the current searcher when it is dropped (by reading the code; the runs above are that path scaled down).
Exposure and likelihood318 words
  • Who. Applications that opt into SearcherLifetimeManager (@lucene.experimental) with the javadoc's per-request acquire-then-record pattern and a time- or count-based Pruner, where one request's acquire-to-record window can span a refresh plus another request's record() of the refreshed searcher, for a searcher no request recorded while it was current. This is a legal variation of the javadoc's acquire-then-record pattern: any delay between acquire() and record() (for example recording after running the query) opens the window, and frequent NRT refreshes with light traffic make it likelier.
  • What goes wrong. Follow-up requests on the current searcher get acquire(token) == null, the documented "session timed out" outcome, until the next record() adds it back, so the user sees a spurious timeout or the application restarts the search. The older reader stays open longer than maxAge: it is dropped only maxAge after a newer searcher gets a new tracker (for example the re-record of S2), and nothing in the class bounds how long that takes. A count-based Pruner ("keep the newest N", the use the design review discussed) keeps S1 and drops S2.
  • Not hit. Lucene returns no wrong hit, count or score, nothing is corrupted and nothing throws (what the application does after a null from acquire() is up to it). Applications that give every searcher its first record from one refresh path (the initial searcher before requests start, then each new searcher right after maybeRefresh(), as ShardSearchingTestBase does) avoid the trigger: a request's later record() then finds the existing tracker and changes nothing, unless that searcher has already been pruned.
  • Default vs opt-in, scale. Opt-in API; no special codec or settings; two searchers and one document are enough. Same failure with assertions on and off.
  • How likely. Rare: it needs the out-of-order first record and, for the current searcher to be dropped, no newer searcher first recorded within maxAge afterwards. The saved searches found no public report.

Minimal repro

One JUnit test, one method, single thread, public API, no sleep: record the current searcher, record the older one, record the current one again for a token, prune(new PruneByAge(0)), then acquire(token) must not be null. It fails with assertions on and off on main, and on 10.6.0 and 9.12.3.

TestSearcherLifetimeManagerPruneOrder.java51 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.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSearcherLifetimeManagerPruneOrder extends LuceneTestCase {

  public void testPruneKeepsCurrentSearcher() throws Exception {
    Directory dir = newDirectory();
    IndexWriter w = new IndexWriter(dir, newIndexWriterConfig());
    SearcherManager mgr = new SearcherManager(w, null);
    SearcherLifetimeManager slm = new SearcherLifetimeManager();
    IndexSearcher older = mgr.acquire();
    w.addDocument(new Document());
    mgr.maybeRefresh();
    IndexSearcher current = mgr.acquire();
    slm.record(current);
    slm.record(older);
    long token = slm.record(current);
    slm.prune(new SearcherLifetimeManager.PruneByAge(0));
    // expected: the current searcher (age 0) survives prune;
    // actual: it is pruned and acquire(token) returns null
    IndexSearcher s = slm.acquire(token);
    assertNotNull(s);
    slm.release(s);
    mgr.release(older);
    mgr.release(current);
    slm.close();
    mgr.close();
    w.close();
    dir.close();
  }
}
Command
cp $REPRO/TestSearcherLifetimeManagerPruneOrder.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSearcherLifetimeManagerPruneOrder -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
acquire(token) returns the current searcher: it is the newest, so PruneByAge(0) keeps it.
Observed
acquire(token) returns null (AssertionError at line 42): prune dropped the current searcher and kept the older one.

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

More files, environment, full output401 words
  • Deterministic. Each record() of a new version reads System.nanoTime(), so S1's record time is later than S2's, and S2's computed age is positive, whenever the clock advances between the calls, which it did in every run; PruneByAge(0) drops any searcher with age > 0. A correct implementation gives the newest searcher age 0 and keeps it. It failed 10 of 10 with --iters 10; the seeds DEADBEEF and 5EED1066 fail the same way through gradle. nanoTime() only promises values that do not decrease: on a clock so coarse that both records read the same value the test could pass on stock, but it cannot fail on a correct implementation.
  • PruneByAge(0) is legal. The constructor rejects only negative values; with 0 it keeps exactly the searchers whose computed age is 0; a correct implementation gives that age to the newest searcher, so the newest must survive. The study's first repro used a 250 ms sleep against PruneByAge(0.1) and fails the same way; it is supplementary evidence only.
  • The test is the study's minimal test with its one comment reworded onto two lines, so that it passes Lucene's checkGoogleJavaFormat; the code is unchanged, and the assertion moved from line 41 to line 42.
  • Resources. There is no try/finally, so a failing run leaves its readers and the IndexWriter open. With the documented seeds the run reports only the one AssertionError. When newDirectory() picks a file-system directory (rare outside nightly runs; seen in one of ten iterations with seed 1066FAC7, and with seed DEADBEEF when the directory is forced to MMapDirectory) it also reports a suite-level "file handle leaks" failure for write.lock. With a fix it passes and closes everything. As a regression test, use this same test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on 10.6.0 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64
Command (environment)
cp $REPRO/TestSearcherLifetimeManagerPruneOrder.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSearcherLifetimeManagerPruneOrder -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# stock main 645d4fe14a20, gradle, seed DEADBEEF, assertions on (off: same failure)
TestSearcherLifetimeManagerPruneOrder > testPruneKeepsCurrentSearcher FAILED
    java.lang.AssertionError
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C4B4FADBD819493]:0)
        at org.junit.Assert.fail(Assert.java:87)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.junit.Assert.assertNotNull(Assert.java:713)
        at org.junit.Assert.assertNotNull(Assert.java:723)
        at org.apache.lucene.search.TestSearcherLifetimeManagerPruneOrder.testPruneKeepsCurrentSearcher(TestSearcherLifetimeManagerPruneOrder.java:42)
:lucene:core:test (FAILURE): 1 test, 1  failure

# with the recommended fix (repro + TestSearcherManager + TestShardSearching, which is @Nightly and skipped here)
:lucene:core:test (SUCCESS): 14 tests, 1 skipped
# TestShardSearching with -Ptests.nightly=true
:lucene:core:test (SUCCESS): 1 test

Why it looks unintended

  • prune promises "newest to oldest"; PruneByAge drops only searchers older "than the newest searcher". S2 is newest by version, by being current and by last record.
  • LUCENE-3639 made age the time since the searcher was replaced, "not how long ago the searcher was first enrolled"; the sort stayed.
  • Nothing requires recording searchers in open order.
The full argument271 words
  • prune: "The entries are passed to the Pruner in sorted (newest to oldest IndexSearcher) order" (SearcherLifetimeManager.java:228-229).
  • Pruner.doPrune: "@param ageSec how much time has passed since this searcher was the current (live) searcher" (:201). S2 is still current yet gets a positive age; S1, which S2 replaced, gets 0.
  • PruneByAge: "drops any searcher older by more than the specified seconds, than the newest searcher" (:208-209). The code comment: "First tracker is always age 0.0 sec, since it's still 'live'" (:253-256).
  • LUCENE-3639 (#4713, 3.6.0). Michael McCandless wrote that the patch makes it "compute age by how long ago the searcher was replaced with a new searcher (not how long ago the searcher was first enrolled)"; lucene/CHANGES.txt says the same ("compute age to be how long ago the searcher was replaced with a new searcher"). The sort by first-record time stayed.
  • The design review relied on the order. On LUCENE-3486 (#4560), Shai Erera noted that a "newest 5 searchers" pruner can count doPrune() calls "because prune(Pruner) guarantees that the searchers are passed newest to oldest".
  • No call-order precondition. Neither the class javadoc nor record() asks callers to record searchers in the order they were opened, and record() is safe to call from concurrent threads (putIfAbsent, "Another thread beat us", :151-155).
  • Tests. TestSearcherManager uses the acquire-then-record pattern from search threads beside a reopen thread but accepts a null and never asserts which searcher is pruned; ShardSearchingTestBase records each node's initial searcher at startup and each new searcher in the reopen thread, which avoids the trigger. No test pins the record-time order.
The devil's-advocate check202 words

The case for intended (made by an executing reviewer from another model family, OpenAI GPT-5.6): record() is the session's recency event; the order has been by record time since 3.5.0, whose javadoc defined age as "how long ago this searcher was recorded vs the most recently recorded searcher"; a null from acquire() is documented; the class is experimental; and pure version order would expire at once a token just returned by the late first record of an older searcher.

Why it fails. (1) LUCENE-3639 replaced the 3.5 wording on purpose with "since this searcher was the current (live) searcher" and "not how long ago the searcher was first enrolled". (2) In the repro S2 is the newest under every reading: highest version, still current, and the most recently recorded (request C records it after A records S1). Stock keeps S2 only if "newest" means "first recorded most recently", the reading the author rejected. (3) The documented null is for searchers that "timed out", and PruneByAge limits that to searchers older than the newest. (4) The token-window point is about a fix, not the bug; the recommended fix keeps that window. The other cross-model lenses, independent verifiers and the adjudication called it real.

Suggested fix

Recommended C. Version order; age from the earliest newer record, never from before the searcher's own first record

Sort by reader version, newest first, and age each older searcher from the earliest record of a newer one, but never from before its own first record, so a just-issued token keeps a full window. Validated with gradle: the repro passes, TestSearcherManager 12/12, nightly TestShardSearching, format and lint checks.

All options and trade-offs3 options ยท 912 words

All options sort by reader version, newest first; they differ in how they age the searchers after the first. This relies on the reader versions of one index growing with every change, which DirectoryReader.getVersion documents ("This version is advanced every time a change is made with IndexWriter"); the class's version keys only need versions to be unique. So the fixes assume that one SearcherLifetimeManager serves the searchers of one index (for example from one SearcherManager). Option C was applied in a scratch worktree of main 645d4fe14a20 and run with gradle; A and B were checked by the study's standalone runs, TLA+ model and bounded-exhaustive test. Which age policy to adopt for a late first-recorded older searcher is the maintainers' call; the claim holds under all three. Workaround today: give every searcher its first record from one refresh path, the initial searcher before requests start and each new searcher right after maybeRefresh(), with all refreshes on that thread (as ShardSearchingTestBase does).

C. Version order; age from the earliest newer record, never from before the searcher's own first record (validated by the checks) (recommended)

compareTo becomes Long.compare(other.version, version). In prune, each later searcher's age is now - max(earliest record time of the newer searchers already passed, its own record time). The running minimum ages an older searcher from the earliest first record of any newer searcher, the earliest time the class can see that it had been replaced (it sees only first-record times). The max keeps stock's full maxAge window for a token returned by an older searcher's first record.

diff --git a/lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java b/lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java
index d7a7589e5b0..1bff3d9363c 100644
--- a/lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java
+++ b/lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java
@@ -103,10 +103,11 @@ public class SearcherLifetimeManager implements Closeable {
       recordTimeSec = System.nanoTime() / NANOS_PER_SEC;
     }
 
-    // Newer searchers are sort before older ones:
+    // Newer searchers (higher reader version) sort before older ones, whatever the order in
+    // which they were first recorded:
     @Override
     public int compareTo(SearcherTracker other) {
-      return Double.compare(other.recordTimeSec, recordTimeSec);
+      return Long.compare(other.version, version);
     }
 
     @Override
@@ -241,14 +242,18 @@ public class SearcherLifetimeManager implements Closeable {
       trackers.add(tracker);
     }
     Collections.sort(trackers);
-    double lastRecordTimeSec = 0.0;
+    // earliest record time of any newer searcher: this searcher has not been the current one
+    // since then
+    double newerRecordTimeSec = Double.NaN;
     final double now = System.nanoTime() / NANOS_PER_SEC;
     for (SearcherTracker tracker : trackers) {
       final double ageSec;
-      if (lastRecordTimeSec == 0.0) {
+      if (Double.isNaN(newerRecordTimeSec)) {
         ageSec = 0.0;
       } else {
-        ageSec = now - lastRecordTimeSec;
+        // a searcher first recorded after a newer one still gets a full window from its own
+        // record, so the token that record returned does not expire at once
+        ageSec = now - Math.max(newerRecordTimeSec, tracker.recordTimeSec);
       }
       // First tracker is always age 0.0 sec, since it's
       // still "live"; second tracker's age (= seconds since
@@ -260,7 +265,10 @@ public class SearcherLifetimeManager implements Closeable {
         searchers.remove(tracker.version);
         tracker.close();
       }
-      lastRecordTimeSec = tracker.recordTimeSec;
+      newerRecordTimeSec =
+          Double.isNaN(newerRecordTimeSec)
+              ? tracker.recordTimeSec
+              : Math.min(newerRecordTimeSec, tracker.recordTimeSec);
     }
   }
 

Trade-offs

An older searcher first recorded late lives up to maxAge after its own record, not after it was replaced. The old comment "now minus first tracker's recordTime" (:253-256) would need updating; the diff leaves it as is. The root-cause reviewer's coupled case (a newer searcher recorded late between two others) is covered only when the older searcher was first recorded before the newer ones: the reviewer's three-searcher test, whose two older searchers are first recorded 300 ms after the newest, fails with C by design and passes with B. The minimal test covers only the two-searcher order bug; if the running minimum stays, a three-searcher test would pin it.

What the checks validated

Gradle, scratch worktree of 645d4fe14a20, JDK 25 (2026-11-02): the minimal test passes with assertions on (seed DEADBEEF) and off (seed 5EED1066); TestSearcherManager 12/12 in both runs; TestShardSearching passes with -Ptests.nightly=true; checkGoogleJavaFormat, ecjLintMain and ecjLintTest pass. Without the fix the same worktree fails the test. The study: standalone repro 1/1 -ea, 20/20 -da; TLA+ NewestFirst, LiveKept, TokenWindow and RetentionBoundOwn hold (76,854 states; the model assumes that each record() reads a strictly later clock value); 0 mismatches over 8,892 operation sequences.

A. Sort by version only (validated by the checks)

Change compareTo to Long.compare(other.version, version) and keep the age loop. One line; fixes the order and never drops the newest searcher.

-      return Double.compare(other.recordTimeSec, recordTimeSec);
+      return Long.compare(other.version, version);

Trade-offs

Ages a late first-recorded older searcher from the newer searcher's earlier record, so the token record() has just returned for it can expire at the next prune; the pairwise "previous tracker" age can keep an older reader too long when a newer one was recorded late between two others.

What the checks validated

Study only: the independent reproducer's own test passes with exactly this change; TLA+ NewestFirst and LiveKept hold, TokenWindow and RetentionBound are violated.

B. Version order plus the running minimum, without the max (validated by the checks)

Each older searcher is aged from the earliest first record of a newer one, the closest the class gets to "time since replaced".

Trade-offs

Like A, it can expire at once a token just returned by an older searcher's first record (the intended-lens reviewer's control fails).

What the checks validated

Study only: 0 mismatches over 8,892 operation sequences; TLA+ NewestFirst, LiveKept and RetentionBound hold, TokenWindow is violated; the root-cause reviewer's three-searcher test passes.

Downstream note

Services that page results with these tokens may show a spurious "session timed out" on the current searcher and keep an older point-in-time reader open longer than maxAge; giving every searcher, the initial one included, its first record from the refresh path avoids it.

How it was found

Semi-formal. A hunt unit exploring thread preemptions in Lucene's NRT reference managers held it as a lead with a failing public-API test. A follow-up unit model-checked record/prune in TLA+ and ran a bounded-exhaustive test of the real classes: 520 order mismatches over 8,892 operation sequences on stock, all with a late first record, and 0 with the fix.

The method in full182 words
  • Lead. A unit that explored preemptions and refresh faults in the NRT reference managers, next to a TLA+ model of LiveFieldValues and the reopen thread, drove SearcherLifetimeManager record/prune in its workload and held this back as a Low lead with a one-test public-API JUnit that fails on stock main (2026-10-29). Whether the first signal came from that work or from reading the code while writing the workload is not recorded, so the method could also be called non-formal.
  • Adjudication unit. Re-ran the lead, reduced it to the current-searcher token scenario, model-checked record/prune in TLA+ (TLC: stock violates NewestFirst, LiveKept and RetentionBound; the fix satisfies NewestFirst, LiveKept, TokenWindow and RetentionBoundOwn, 76,854 states, vacuity probes reached) and ran a bounded-exhaustive model-based test of the real SearcherManager and SearcherLifetimeManager over all 8,892 operation sequences of length up to 10 (520 order and 380 live-searcher mismatches on stock, all and only in sequences with a late first record; 0 with the fix).
  • Verification. Executing cross-model reviews (OpenAI GPT-5.6: intended, root-cause and refute lenses), an independent reproducer, an intended-behaviour devil's advocate, an adjudication and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-11-01): GitHub issues and PRs including the JIRA archive, commits, the files of all open PRs, mailing lists, downstream trackers and the web. Closest: #4560, the feature, which states the newest-to-oldest guarantee, and #4713 (LUCENE-3639), which redefined age.

Searches4 searches

Related, not reports of this bug: #4560 (LUCENE-3486), the feature, whose review states that prune passes searchers newest to oldest; #4713 (LUCENE-3639), where age became the time since the searcher was replaced and the order was kept; the java-user thread "Consistent NRT searching with SearcherLifetimeManager and multiple instances" (2023-12), where the author advises recording every refreshed searcher from the refresh path, which avoids the trigger; the java-user thread "Custom pruning strategy with SearcherLifetimeManager" (2017-11), which asks for the token in doPrune. Downstream mentions in nrtsearch, elasticsearch, OpenSearch and lucenenet are uses of the class or unrelated errors. Private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive), downstream repositories, mailing lists, JIRA2026-11-01two automated prior-art passes of 45 queries each (class and method names, compareTo, recordTimeSec, "newest to oldest", acquire null, Pruner doPrune)no report; one high hit, #4560 (the feature)
GitHub search (apache/lucene, lucenenet, solr, elasticsearch, OpenSearch, nrtsearch, luceneserver, all of GitHub)2026-11-0130 manual queries (PruneByAge, SearcherLifetimeManager prune/pruned/bug, recordTimeSec SearcherTracker), plus a last-48-hours windowno report
Open apache/lucene PRs2026-11-01changed files of all 409 open PRsnone touches SearcherLifetimeManager.java
Mailing lists and the web2026-11-018 dev@ and java-user@ threads naming the class read; web searchesno report; a 2023 thread gives the workaround

History

Present since the class was added: LUCENE-3486 (#4560, cbdaea3486e, 2011-10-10), first released in 3.5.0. LUCENE-3639 (#4713, 3.6.0) redefined age and kept the sort. Fails on every release run from 3.5.0 to 10.6.0 and on main; not fixed upstream.

Versions tested8 rows
  • Introduced: cbdaea3486e on trunk and 346799474a2 on branch_3x, "LUCENE-3486: add SearcherLifetimeManager, so you can easily retrieve the same searcher you previously used" (Michael McCandless, 2011-10-10; SVN era, no PR). First release 3.5.0. 05509413d45 (LUCENE-4851, 2013) only rewrote the comparison as Double.compare.
  • Contract change: LUCENE-3639 (aee83bf7eef / 906894ad97a, 2011-12-18, first released in 3.6.0) made age the time since the searcher was replaced, computed from the previous sorted tracker's record time, and kept the sort. From 3.6.0 on the repro's scenario fails as written; 3.5.0 fails with its own timing (whole-second ages).
  • Upstream: the 10.6.0-to-main diff of the file is javadoc markup and one typo fix ("returend" to "returned"), which is all that the last upstream commit to it, 54283150a81 (#16255, a build-tooling commit, 2026-06-14), changed in it: no behaviour change; upstream main f815e679bf (2026-11-01) is unchanged at the fix site. Not new in 10.6.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0 affectedminimal test and first repro fail
10.5.2 affectedfirst repro fails
9.12.3 affectedminimal test and first repro fail
9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.0.0 affectedframework-free probe of the same scenario fails
3.6.2, 3.6.0 affectedprobe fails (3.6.0: first release with LUCENE-3639)
3.5.0 affectedfails with 3.5 timing: older searcher first recorded 2.2 s after the newer one, PruneByAge(1)
3.4.0 not affectednot affected: SearcherManager and SearcherLifetimeManager do not exist

Independent checks

Real by the finder, the root-cause and refute cross-model reviewers (OpenAI GPT-5.6), an independent reproducer, a devil's advocate and the adjudication (high confidence, Low). The intended-lens reviewer said not real, reading "newest" as "most recently recorded"; in the repro the current searcher is also the most recently recorded, and stock still drops it.

Checks and verdicts305 words

The cross-model reviews ran on main ea57360912ed, whose SearcherLifetimeManager.java is identical to 645d4fe14a20. Every other check ran on 645d4fe14a20 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder (judge unit) reproduced, LowReduced the lead to one JUnit; TLA+ and a bounded-exhaustive test of the real classes; fix C validated.
cross-model reviewer, intended lens (OpenAI GPT-5.6, executing) not real (strong)Record time as the recency event; its control against a pure version-order fix led to fix C. Answered with the current-searcher token repro.
cross-model reviewer, root-cause lens (OpenAI GPT-5.6, executing) real (high)Its tests fail 3 of 3 on stock; the first bad state is the sorted list; found a second, coupled age issue, whose three-searcher test passes with fix B and fails with fix C by design.
cross-model reviewer, refute lens (OpenAI GPT-5.6, executing) real (high)Own test fails with -ea and -da, its control passes; with the fix both pass and TestSearcherManager passes 12 of 12.
independent reproducer realOwn test fails on main, 10.6.0, 10.5.2 and 9.12.3; a version-order shadow passes; recording S1 before the refresh passes on stock.
intended-behaviour devil's advocate real (Low)LUCENE-3639 rules out "first enrolled", the only reading under which stock is right.
adjudication confirmed (high, Low)Re-ran the finder's and the reproducer's tests (fail) and the test with the fix (passes).
minimal-repro check reproduced, LowMinimal repro fails 1/1 (-ea) and 20/20 (-da) on main and passes with the fix.
credibility audit confirmedRe-ran the repro with a fresh seed (fails); impact not inflated.
this doc's gradle validation fails on stock, passes with fix CScratch worktree of 645d4fe14a20: stock fails with assertions on and off; fix C passes with TestSearcherManager, nightly TestShardSearching and the format and lint checks.

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

Finding record

LFV-1066, snapshot of 2026-11-06

Registry title
SearcherLifetimeManager.prune orders searchers by the time each was first recorded instead of newest to oldest, so when an older searcher is first recorded after the current one, PruneByAge drops the current searcher (even right after record() returned a token for it) and keeps the older one
Area
core / search: SearcherLifetimeManager (keeping old searchers for follow-up requests such as paging)
Priority
Low, rank 946: When an older searcher is first recorded late, the opt-in experimental SearcherLifetimeManager can prune the current searcher and keep the older one, so paging requests get the documented 'timed out' null. No result is wrong, the timing window is narrow and nobody has reported it since 3.5.0, so it is Low.
Impact class
minor
Impact note
Applications that use the opt-in, @lucene.experimental SearcherLifetimeManager with the class javadoc's per-request pattern (acquire from SearcherManager, then record) and prune(new PruneByAge(maxAge)): when a request records an older searcher for the first time after another request has already recorded the refreshed one, prune treats the older searcher as the newest, so once maxAge has passed with no newer searcher first recorded it drops SearcherManager's current searcher (acquire(token) returns the documented 'session timed out' null for tokens just issued for it, until the next record() adds it back) and keeps the older reader open; a count-based Pruner keeps the wrong searcher; no search result is wrong and nothing is corrupted, it needs no scale and fails the same way with -ea and -da.
Summary
You use SearcherManager with SearcherLifetimeManager as the javadoc shows: request A acquires searcher S1, the manager refreshes to S2, request B acquires and records S2, and then A records S1. You expect prune to pass the searchers newest to oldest, as its javadoc says, so PruneByAge never drops S2, the current searcher. Instead prune sorts them by the time each was first recorded, so S1 goes first with age 0 and S2 is aged from S1's late record: 250 ms later, request C records S2 and gets a token, prune(new PruneByAge(0.1)) drops S2 anyway, acquire(token) returns null, and the older S1 is kept.
Introduced
cbdaea3486e on trunk (LUCENE-3486 = apache/lucene#4560, author mikemccand, 2011-10-10T22:05:55Z) and its branch_3x backport 346799474a2 the same day, first released in 3.5.0 [read: SearcherTracker.compareTo has sorted by recordTimeSec since the class was added; 05509413d45 (LUCENE-4851, 2013) only rewrote it with Double.compare; exec: 3.4.0 lacks the API, 3.5.0 fails]. The age contract the order now breaks came with LUCENE-3639 (aee83bf7eef on trunk, 906894ad97a on branch_3x, 2011-12-18, first released in 3.6.0), which redefined ageSec as the time since the searcher was the current (live) searcher and kept the record-time sort, so there is no later regression (2011-10-10)
First affected version
3.5.0 [exec: a framework-free probe on the 3.5.0 release jars, with the older searcher first recorded 2.2 s after the newer one and PruneByAge(1), prunes the current searcher's just-returned token; the timing-free order probe fails 8 of 8 with --iters 8, one single run passed on a whole-second record-time tie]; the repro's exact scenario fails from 3.6.0 on [exec], after LUCENE-3639 made the age now-based; 3.4.0 and earlier are not affected [exec: SearcherManager and SearcherLifetimeManager do not exist there]
Affected versions
every release from 3.5.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 645d4fe14a20; never fixed (upstream main f815e679bf unchanged at the fix site, last commit to the file 54283150a81, author date 2026-06-14, a javadoc typo fix from a build-tooling commit, no behaviour change; checked 2026-11-01). [exec] the JUnit repro fails 1 of 1 on main with -ea and -da (10 of 10 with --iters 10 and -da), on 10.6.0 (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's own JUnit fails on main (-ea and -da), 10.6.0, 10.5.2 and 9.12.3; framework-free probes of the same current-searcher scenario fail on 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.0.0, 3.6.2 and 3.6.0, order probes fail on 9.0.0, 8.11.4, 8.0.0, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 3.6.2 and 3.5.0, and 3.5.0 fails in its record-time-difference form. [read] the 10.6.0-to-main diff of SearcherLifetimeManager.java is javadoc markup and one typo. Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by TLA+/TLC, exhaustive check
Upstream and prior reports
none found (2026-11-01): two scripted prior-report search passes (45 queries each over apache/lucene issues and PRs incl. the migrated JIRA archive, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and JIRA; 3,173 and 112 distinct hits, 1 high: #4560, the feature issue itself); 30 manual GitHub searches in apache/lucene, lucenenet, solr, elasticsearch, OpenSearch, nrtsearch, luceneserver and all of GitHub; the changed files of all 409 open apache/lucene PRs (none touches SearcherLifetimeManager.java); upstream commits on the file (no behaviour change since 2011; main f815e679bf); 8 java-user and dev threads read; web searches; PRs and issues created or updated since 2026-10-30 (#16952-#16959) unrelated, re-checked. Related, not reports: #4560 (LUCENE-3486, the feature; its review states 'prune(Pruner) guarantees that the searchers are passed newest to oldest' for count-based pruners), #4713 (LUCENE-3639, where age became 'how long ago the searcher was replaced', order kept), the 2023 java-user thread 'Consistent NRT searching with SearcherLifetimeManager and multiple instances' (the author advises recording every refreshed searcher from the refresh path, which avoids the trigger) and the 2017 java-user thread 'Custom pruning strategy with SearcherLifetimeManager' (asks for the token in doPrune)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle with assertions on and off, stock (fails) and with the recommended fix (passes, with TestSearcherManager, nightly TestShardSearching, checkGoogleJavaFormat and ecj lint), in a scratch worktree (2026-11-02); through the standalone JUnit runner on main (1/1 with -ea, 10/10 with -da), 10.6.0 and 9.12.3. The study's first repro on 10.6.0, 10.5.2 and 9.12.3, and probes of the same scenario on 3.5.0 to 9.0.0. Upstream main f815e679bf (2026-11-01) is unchanged at the fix site.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:92-110 (SearcherTracker: recordTimeSec set once in the constructor at :103; compareTo by recordTimeSec at :106-110)
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:131-165 (record(): javadoc 'Always call this when you've obtained a possibly new IndexSearcher ... It's fine if you already passed the same searcher to this method before'; an existing tracker is never updated)
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:167-185 (acquire(): null 'when the requested searcher has already timed out')
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:196-225 (Pruner.doPrune ageSec 'how much time has passed since this searcher was the current (live) searcher'; PruneByAge 'drops any searcher older by more than the specified seconds, than the newest searcher')
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:227-265 (prune javadoc 'passed to the Pruner in sorted (newest to oldest IndexSearcher) order'; first tracker age 0 'since it's still "live"', each later one now minus the previous tracker's recordTimeSec)
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:29-86 (class javadoc usage: per request obtain the latest searcher and record it; prune(new PruneByAge(600.0)) from the reopen thread; @lucene.experimental)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:97-110, :132-164, :177-185, :222-224, :239-263
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:38-75 (class javadoc usage), :86 (@lucene.experimental), :170-172 (null = session timed out)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/ShardSearchingTestBase.java:605-612 (records the initial searchers), :520-529 (records each new searcher in the reopen thread)
Minimal repro
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:214-219 (PruneByAge accepts 0)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:151-155, :201, :208-209, :228-229, :253-256
  • lucene/CHANGES.txt:16833-16837 (LUCENE-3639 entry)
  • a Lucene clone releases/lucene-solr/3.5.0 lucene/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:219-221 (3.5 ageSec wording)
  • lucene/core/src/test/org/apache/lucene/search/TestSearcherManager.java:180-200
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/ShardSearchingTestBase.java:520-529, :605-612
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:156-161 (one searcher per version)
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:617-623 (getVersion: advanced every time a change is made with IndexWriter)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/ShardSearchingTestBase.java:605-612, :520-529 (the workaround's two record sites)

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

Repro files

About the diagrams

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.