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.
SearcherManagerhands out the currentIndexSearcherand, after index changes, opens a new one (maybeRefresh()).SearcherLifetimeManagerkeeps older searchers open for follow-up requests:record(searcher)returns a token (the reader version), andacquire(token)returns the same searcher for the next page, or null once it was pruned ("session timed out").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 thanmaxAge, so the newest searcher, age 0, should always stay.
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.- You follow the class javadoc: each new request takes the latest searcher from
SearcherManagerand callsrecord()for a paging token; a reopen thread callsprune(new PruneByAge(maxAge)). - 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. - You expect
pruneto 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. - Instead
prunesorts 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.
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 sort key is the first-record time.
SearcherTrackerstoresrecordTimeSec = System.nanoTime() / NANOS_PER_SECin its constructor (SearcherLifetimeManager.java:97-104), andcompareToisDouble.compare(other.recordTimeSec, recordTimeSec)under the comment "Newer searchers are sort before older ones" (:106-110). - 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 laterrecord()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). - Ages come from the sorted neighbour.
prunecopies and sorts the trackers (:239-243), gives the first age 0 (:248-249) and each later onenow - 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). - 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.PruneByAgereturnsageSec > maxAgeSec(:222-224), so S2 is removed and its reader released (:257-262) oncenow - t1 > maxAge;acquire(version)then finds no tracker and returns null (:177-185). - Why it heals. The token is the reader version (
:164). While S2 is SearcherManager's current searcher its reader stays open, so the nextrecord(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.
- 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-daon 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 whileacquire(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-basedPruner, where one request's acquire-to-record window can span a refresh plus another request'srecord()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 betweenacquire()andrecord()(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 nextrecord()adds it back, so the user sees a spurious timeout or the application restarts the search. The older reader stays open longer thanmaxAge: it is dropped onlymaxAgeafter 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-basedPruner("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 aftermaybeRefresh(), asShardSearchingTestBasedoes) avoid the trigger: a request's laterrecord()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
maxAgeafterwards. The saved searches found no public report.
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.
/*
* 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();
}
}
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, soPruneByAge(0)keeps it.- Observed
acquire(token)returns null (AssertionErrorat line 42): prune dropped the current searcher and kept the older one.
More files, environment, full output401 words
- Deterministic. Each
record()of a new version readsSystem.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 seedsDEADBEEFand5EED1066fail 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 againstPruneByAge(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
IndexWriteropen. With the documented seeds the run reports only the oneAssertionError. WhennewDirectory()picks a file-system directory (rare outside nightly runs; seen in one of ten iterations with seed1066FAC7, and with seedDEADBEEFwhen the directory is forced toMMapDirectory) it also reports a suite-level "file handle leaks" failure forwrite.lock. With a fix it passes and closes everything. As a regression test, use this same test.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on 10.6.0 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
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)
# 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 testprunepromises "newest to oldest";PruneByAgedrops 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.txtsays 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, andrecord()is safe to call from concurrent threads (putIfAbsent, "Another thread beat us",:151-155). - Tests.
TestSearcherManageruses the acquire-then-record pattern from search threads beside a reopen thread but accepts a null and never asserts which searcher is pruned;ShardSearchingTestBaserecords 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.
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.
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.
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.
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.
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
LiveFieldValuesand the reopen thread, droveSearcherLifetimeManagerrecord/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
SearcherManagerandSearcherLifetimeManagerover 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive), downstream repositories, mailing lists, JIRA | 2026-11-01 | two 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-01 | 30 manual queries (PruneByAge, SearcherLifetimeManager prune/pruned/bug, recordTimeSec SearcherTracker), plus a last-48-hours window | no report |
| Open apache/lucene PRs | 2026-11-01 | changed files of all 409 open PRs | none touches SearcherLifetimeManager.java |
| Mailing lists and the web | 2026-11-01 | 8 dev@ and java-user@ threads naming the class read; web searches | no report; a 2023 thread gives the workaround |
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:
cbdaea3486eon trunk and346799474a2on 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 asDouble.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 mainf815e679bf(2026-11-01) is unchanged at the fix site. Not new in 10.6.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle and the standalone runner, assertions on and off) |
| 10.6.0 | affected | minimal test and first repro fail |
| 10.5.2 | affected | first repro fails |
| 9.12.3 | affected | minimal 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 | affected | framework-free probe of the same scenario fails |
| 3.6.2, 3.6.0 | affected | probe fails (3.6.0: first release with LUCENE-3639) |
| 3.5.0 | affected | fails with 3.5 timing: older searcher first recorded 2.2 s after the newer one, PruneByAge(1) |
| 3.4.0 | not affected | not affected: SearcherManager and SearcherLifetimeManager do not exist |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (judge unit) | reproduced, Low | Reduced 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 | real | Own 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, Low | Minimal repro fails 1/1 (-ea) and 20/20 (-da) on main and passes with the fix. |
| credibility audit | confirmed | Re-ran the repro with a fresh seed (fails); impact not inflated. |
| this doc's gradle validation | fails on stock, passes with fix C | Scratch worktree of 645d4fe14a20: stock fails with assertions on and off; fix C passes with TestSearcherManager, nightly TestShardSearching and the format and lint checks. |
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 353 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,306 words
- 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, withTestSearcherManager, nightlyTestShardSearching,checkGoogleJavaFormatand 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 mainf815e679bf(2026-11-01) is unchanged at the fix site.
- 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-256lucene/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-200lucene/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)
- TestSearcherLifetimeManagerPruneOrder.java:
repro/LFV-1066/TestSearcherLifetimeManagerPruneOrder.java(51 lines)
assets/LFV-1066/bug.png(353 KB)
