An int overflow in the table-size formula leaves 2 slots for a history of 715 million entries. The probe loop then never finds a free slot, and it runs while holding the policy's lock.
- With a query cache installed, the cache reports each cacheable query a search uses to its caching policy (
onUse) and asks the policy whether to cache it (shouldCache). - The default policy,
UsageTrackingQueryCachingPolicy, caches a query once it appears 2 to 5 times among the last N queries it was told about: N is 256 by default, or thehistorySizeyou pass to the expert constructor. - It keeps them in a
FrequencyTrackingRingBuffer: an array of the last N hash codes plus a hash table that counts each one, sized about 1.5 × N rounded up to a power of two so that it always has free slots. The history starts out full of a filler value.
int, so the counting table gets 2 slots: the filler value takes one, f:a the other, and onUse(f:b) probes slot 0, 1, 0, 1, ... forever while holding the policy's lock. Select the still to open it at full size. Generated with AI, checked against the code.- Build the caching policy with a huge history:
new UsageTrackingQueryCachingPolicy(715_827_883)instead of the default 256. You expect it to track that many recent queries, or to reject the size withIllegalArgumentException, as it does for sizes below 2. - The constructor succeeds, but the table that counts each query's hash code gets 2 slots instead of 2^30: its size is
maxSize * 3 / 2inint, and 715,827,883 × 3 = 2,147,483,649 wraps to -2,147,483,647, soMath.max(2, ...)picks 2. With 256 the table has 512 slots. - One slot holds the filler value the history starts with, and
onUse(PrefixQuery f:a)puts its hash code in the other.onUse(PrefixQuery f:b)then looks for a free slot that does not exist and probes slot 0, 1, 0, 1, ... forever (the executed run). - The loop runs inside
synchronized (this), so that search never returns, and other searches that ask the policy about a cacheable query block behind it; nothing can interrupt it. With assertions enabled, the constructor throws a bareAssertionErrorinstead.
Only applications that pass a history of 715,827,883 or more to the expert constructor (2.8 million times the default 256), with a heap for the 2.86 GB array; on main they must also install a query cache, which 9.x and 10.x do by default. The default and any history from 2 to 715,827,882 (given the heap) are safe.
Mechanism, terms and numbers711 words
FrequencyTrackingRingBuffer, the history behind UsageTrackingQueryCachingPolicy, checks only maxSize >= 2 and sizes its open-addressing counting table as Math.max(2, maxSize * 3 / 2) in int. For maxSize = 715,827,883 the product maxSize * 3 = 2^31 + 1 wraps negative, so the table gets 2 slots. The constructor fills one with the filler value (Integer.MIN_VALUE for the policy), the first query's hash code takes the other, and IntBag.add for the next distinct hash code (distinct from the filler and from the first, as in the repro) has no free slot to stop at. onUse calls it inside synchronized (this), so with assertions disabled the second distinct cacheable query never returns, and every other thread that calls onUse or shouldCache on the policy for a cacheable query blocks; through IndexSearcher.search with an LRUQueryCache the second search hangs and a parallel search is BLOCKED. With assertions enabled, assert capacity > maxSize fails in the constructor. Every accepted size from 715,827,883 up gets a table no larger than the history (2 slots up to 1,431,655,767); 715,827,882 works, and Integer.MAX_VALUE fails loudly with OutOfMemoryError. The trigger needs the expert constructor with a history 2.8 million times the default (256) and a 2.86 GB history array; on main a query cache (for example LRUQueryCache) must also be installed (9.x and 10.x cache by default). Every release from 5.0.0 through 10.6.0, and main, is affected; rejecting sizes above Integer.MAX_VALUE / 3 in the constructor fixes it.
FrequencyTrackingRingBuffer.java:45-59 on main 645d4fe14a20: the constructor rejects only maxSize < 2, allocates the int[maxSize] history, builds IntBag(maxSize) and adds the filler (sentinel) maxSize times.
FrequencyTrackingRingBuffer.java:108-117 (end-of-line comments added):
IntBag(int maxSize) {
// load factor of 2/3
int capacity = Math.max(2, maxSize * 3 / 2); // 715,827,883 * 3 wraps: -1,073,741,823 -> 2
// round up to the next power of two
capacity = Integer.highestOneBit(capacity - 1) << 1; // 2
assert capacity > maxSize; // fails only with -ea
keys = new int[capacity];
freqs = new int[capacity];
mask = capacity - 1; // 1
}FrequencyTrackingRingBuffer.java:136-145: open addressing with no exit when every slot holds another key.
int add(int key) {
for (int slot = key & mask; ; slot = (slot + 1) & mask) {
if (freqs[slot] == 0) {
keys[slot] = key;
return freqs[slot] = 1;
} else if (keys[slot] == key) {
return ++freqs[slot];
}
}
}frequency(int) (:125-133) has the same shape, so a shouldCache that got the lock could spin too (by reading; in the runs shouldCache blocks on the monitor).
- After the constructor: table length 2; slot 0 holds
Integer.MIN_VALUEwith count 715,827,883, slot 1 is empty.ramBytesUsed()is 2,863,311,656 bytes, 4.000 bytes per entry, against the 16 to 28 the class javadoc promises. onUse(f:a): the hash code -1,030,110,316 starts at slot 0 (taken by the filler) and lands in slot 1. Both slots are now taken.onUse(f:b):FrequencyTrackingRingBuffer.addfirst drops the oldest history entry, a filler, which only lowers slot 0's count and frees nothing; then the hash code -1,744,932,716 starts at slot 0 and probes 0, 1, 0, 1, ... forever. A second thread callingshouldCache(f:a)is BLOCKED.
UsageTrackingQueryCachingPolicy.onUse (:141-159) computes the hash code outside the lock and calls recentlyUsedFilters.add(hashCode) inside synchronized (this) (:156-158); frequency (:161-172), used by shouldCache, takes the same monitor. LRUQueryCache calls onUse once per query and search (LRUQueryCache.java:812-813). The loop has no blocking call and checks nothing, so neither Thread.interrupt() nor a query timeout can end it (by reading).
Every accepted maxSize from 715,827,883 up gets a table no larger than maxSize: exactly 2 slots up to 1,431,655,767, then a power of two below maxSize (4 at 1,431,655,768, 134,217,728 for 1.5e9, 2^30 for 2e9). Above 1,431,655,767 the second distinct query still returns; by reading, a later one spins once the table is full (not executed). 715,827,882 is the largest size whose table is right (2^30 slots); it was run with a 13 GB heap and gave correct shouldCache answers. Integer.MAX_VALUE fails loudly, with OutOfMemoryError: Requested array size exceeds VM limit while allocating the history.
Exposure and likelihood239 words
- Who. An application that calls
new UsageTrackingQueryCachingPolicy(historySize)with 715,827,883 or more, on a heap that holds the 2.86 GB history (about 4 GB with the default G1 collector). On main the query cache is off by default (GITHUB#14187), so it must also install a query cache (for exampleLRUQueryCache); 9.x and 10.x install anLRUQueryCacheby default, so setting the policy is enough there. Direct users of the public@lucene.internalFrequencyTrackingRingBufferare hit the same way. - Safe.
IndexSearcher's default policy uses a history of 256, and every size from 2 to 715,827,882 gets a correctly sized table, given the heap for it (715,827,882 needed a 13 GB heap). - Effect, assertions off. The search that records the second distinct cacheable query never returns and keeps one core busy; every other search that consults the same policy about a cacheable query blocks in
onUseorshouldCache(a policy set withIndexSearcher.setDefaultQueryCachingPolicyis shared by every searcher created afterwards). Lucene throws nothing and logs nothing, and the stuck thread keeps its core until the JVM exits. The stuck search returns no result; the index is not touched. - Effect, assertions on. A message-less
AssertionErrorfrom the constructor. - Likelihood: contrived. Nobody picks a history of 716 million queries by accident: the javadoc already warns about large histories,
Integer.MAX_VALUEfails loudly withOutOfMemoryError, the downstream engines checked use the default constructor, and the documented searches found no public report since 5.0.0 (2015).
One JUnit test on the public API: build the policy with a history of 715,827,883 and record two different PrefixQuerys with onUse, under a 30 s timeout. It needs a 5 GB test heap for the 2.86 GB history array, and it passes if the size is rejected with IllegalArgumentException.
/*
* 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 static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import java.time.Duration;
import org.apache.lucene.index.Term;
import org.junit.jupiter.api.Test;
public class TestUsageTrackingPolicyHugeHistory {
@Test
public void testSecondDistinctQueryReturns() {
// expected: the size is rejected up front or both onUse calls return; actual (-da): onUse(b) spins forever
assertTimeoutPreemptively(
Duration.ofSeconds(30),
() -> {
UsageTrackingQueryCachingPolicy policy;
try {
policy = new UsageTrackingQueryCachingPolicy(715_827_883);
} catch (IllegalArgumentException _) {
return;
}
policy.onUse(new PrefixQuery(new Term("f", "a")));
policy.onUse(new PrefixQuery(new Term("f", "b")));
},
"onUse must return");
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestUsageTrackingPolicyHugeHistory -Ptests.seed=DEADBEEF -Ptests.heapsize=5g -Ptests.asserts=false
- Expected
new UsageTrackingQueryCachingPolicy(715_827_883)throwsIllegalArgumentException, or bothonUsecalls return.- Observed
- Assertions off:
onUse must return ==> execution timed out after 30000 ms, stuck inFrequencyTrackingRingBuffer$IntBag.add(line 137); assertions on: a bareAssertionErrorin the constructor.
More files, environment, full output414 words
One JUnit Jupiter class, one method, public API only (it sits in org.apache.lucene.search but uses nothing package-private). No index, no searcher, no random values. assertTimeoutPreemptively runs the body on one daemon thread, because a hang can only be shown by bounding the wait; the spinning thread cannot be interrupted. The class does not extend Lucene's test base class, whose thread-leak checker would add a second, suite-level failure for that thread. PrefixQuery is used because onUse ignores TermQuery and other never-cached queries; 715,827,883 is the smallest overflowing size.
Heap. Under Lucene's gradle runner use -Ptests.heapsize=5g: with 4g the test JVM, which runs ParallelGC, failed with OutOfMemoryError while allocating the history array. A plain JVM with the default G1 collector needs about 4 GB.
Regression test: this same test, with one change for Lucene's build: its comment line is longer than 100 columns, so checkGoogleJavaFormat asks to wrap it after onUse(b). With the fix it passes even at the default 512 MB test heap, because the size is rejected before anything is allocated; without the fix it fails, by a timeout or AssertionError with enough heap, or by OutOfMemoryError with too little.
At the searcher level (an independent probe, not the repro): with setQueryCache(new LRUQueryCache(256, 50 MB)) and this policy on an IndexSearcher, the second search(new ConstantScoreQuery(new PrefixQuery(...)), 10) hangs and a parallel search is BLOCKED, on main and 10.5.2; with a history of 1,000,000 all searches return.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0: fails; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the arithmetic is plain int) |
cp $REPRO/TestUsageTrackingPolicyHugeHistory.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestUsageTrackingPolicyHugeHistory -Ptests.seed=DEADBEEF -Ptests.heapsize=5g -Ptests.asserts=false
# 46 s in the recorded run (which also passed --no-daemon --offline -Ptests.jvms=1, on a built worktree); without -Ptests.asserts=false (assertions on) it fails with AssertionError (36 s)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.heapsize=5g -Ptests.asserts=false
TestUsageTrackingPolicyHugeHistory > testSecondDistinctQueryReturns() FAILED
org.opentest4j.AssertionFailedError: onUse must return ==> execution timed out after 30000 ms
at app//org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(Assertions.java:3590)
at app//org.apache.lucene.search.TestUsageTrackingPolicyHugeHistory.testSecondDistinctQueryReturns(TestUsageTrackingPolicyHugeHistory.java:30)
Caused by:
org.junit.jupiter.api.timeout.PreemptiveTimeoutUtils$ExecutionTimeoutException: Execution timed out in thread junit-timeout-thread-1
at app//org.apache.lucene.util.FrequencyTrackingRingBuffer$IntBag.add(FrequencyTrackingRingBuffer.java:137)
at app//org.apache.lucene.util.FrequencyTrackingRingBuffer.add(FrequencyTrackingRingBuffer.java:77)
at app//org.apache.lucene.search.UsageTrackingQueryCachingPolicy.onUse(UsageTrackingQueryCachingPolicy.java:157)
at app//org.apache.lucene.search.TestUsageTrackingPolicyHugeHistory.lambda$testSecondDistinctQueryReturns$0(TestUsageTrackingPolicyHugeHistory.java:40)
1 test completed, 1 failed
# the same, assertions on (default)
TestUsageTrackingPolicyHugeHistory > testSecondDistinctQueryReturns() FAILED
java.lang.AssertionError
at org.apache.lucene.util.FrequencyTrackingRingBuffer$IntBag.<init>(FrequencyTrackingRingBuffer.java:113)
at org.apache.lucene.util.FrequencyTrackingRingBuffer.<init>(FrequencyTrackingRingBuffer.java:52)
at org.apache.lucene.search.UsageTrackingQueryCachingPolicy.<init>(UsageTrackingQueryCachingPolicy.java:105)
at org.apache.lucene.search.TestUsageTrackingPolicyHugeHistory.lambda$testSecondDistinctQueryReturns$0(TestUsageTrackingPolicyHugeHistory.java:35)
1 test completed, 1 failed
# line 40 is the second onUse; the first (line 39) returned.
# release 10.6.0 (standalone runner): the same timeout in IntBag.add:137, and the same AssertionError at IntBag.<init>:113- The ring buffer validates its size and asserts
capacity > maxSize; here the assert fails and it uses 4 bytes per entry, not the 16 to 28 its javadoc states. - The constructor's "Beware of passing too large values" is about caching quality, not a limit.
PriorityQueue,LongHeapand the internal hash containers reject sizes they cannot handle.
The full argument297 words
- The class's own invariant breaks.
IntBag'sassert capacity > maxSize(FrequencyTrackingRingBuffer.java:113) states the invariant the int product breaks, and its javadoc says the maximum size "is known up-front" so there is "no need to worry about resizing" (:96-97). The class javadoc says it "requires 22 bytes per entry on average (between 16 and 28)" (:27); at 715,827,883 it allocates 4.000 bytes per entry. The buffer "will contain at mostmaxSizeitems" (:42-43); that still holds for the ring itself, but no javadoc states an upper limit, and the constructor checks onlymaxSize < 2. - The warning is about tuning. "Beware of passing too large values for the size of the history": rarely used filters get cached, or filters are "slow to make it to the cache" (
UsageTrackingQueryCachingPolicy.java:95-102).@param historySizegives no bound. - Lucene rejects sizes it cannot handle elsewhere.
PriorityQueue(PriorityQueue.java:121-124) andLongHeap(LongHeap.java:57-63) throwIllegalArgumentException"to prevent confusing OOME"; the internal hppcHashContainersthrowsBufferAllocationExceptionwhen a table would exceed its maximum length (HashContainers.java:92-93). LUCENE-10607 (#11643), an int overflow at an extreme input size, was labelledtype:bugand fixed in 9.3. - Nothing covers it.
TestFrequencyTrackingRingBufferuses sizes up to 10,001; no Lucene code or test passes a history size other than the default 256 (the no-arg constructor'sthis(256)); the LUCENE-6118 review discussed onlyIntBag.remove. - Out of scope, but a valid hardening fix. Lucene's SECURITY.md (line 46) says issues outside its security scope "are still valid bug reports and hardening suggestions"; its example, "a change that prevents an endless loop", is framed around broken indexes, and robustness improvements "must not slow down the hot paths". The recommended check runs once per constructor call, off the hot path.
The devil's-advocate check175 words
The strongest case for "not a bug" (the devil's-advocate verifier): the javadoc already says "Beware of passing too large values"; the policy is @lucene.experimental and the buffer @lucene.internal; the size is absurd; the assert makes Lucene's own tests, which run with assertions on, fail fast; and on main the query cache is off by default. A committer could answer "don't do that". The hunt's own noise audit made this objection first ("absurd parameter") and then kept the bug, because the failure is a silent spin under a lock, not a loud one.
Why it fails: the warning names tuning costs, not a limit or a failure; @lucene.experimental is about API stability, not permission to hang, and the trigger is the public policy constructor; "absurd" argues for low impact, not for intent; an assertion is not argument validation, and production runs with assertions off, where the accepted value becomes a silent spin under a lock instead of an honest failure such as the OutOfMemoryError that Integer.MAX_VALUE gives. Verdict: a defect, small and low priority.
Recommended Reject maxSize above Integer.MAX_VALUE / 3 in FrequencyTrackingRingBuffer (validated)
Reject sizes above Integer.MAX_VALUE / 3 (715,827,882) in the FrequencyTrackingRingBuffer constructor with IllegalArgumentException, before anything is allocated (+7 lines). With it the repro passes, even at the default test heap, and TestFrequencyTrackingRingBuffer, TestUsageTrackingFilterCachingPolicy and TestLRUQueryCache pass with assertions on and off, as do the core format and lint checks.
/** Largest supported {@code maxSize}: {@code IntBag} computes {@code maxSize * 3} as an int. */
static final int MAX_SIZE = Integer.MAX_VALUE / 3;
...
if (maxSize > MAX_SIZE) {
throw new IllegalArgumentException(
"maxSize must be at most " + MAX_SIZE + ", got " + maxSize);
}All options and trade-offs4 options · 560 words
One fix was built and validated through gradle in a scratch worktree of 645d4fe14a20; the other options are sketches, not built.
Add a bound next to the existing maxSize < 2 check, before the history is allocated. It is the finder's fix (bound (1 << 30) / 3 * 2, the same 715,827,882) with the bound written as Integer.MAX_VALUE / 3 and the comment fitted to google-java-format.
/** Largest supported {@code maxSize}: {@code IntBag} computes {@code maxSize * 3} as an int. */
static final int MAX_SIZE = Integer.MAX_VALUE / 3;
...
if (maxSize > MAX_SIZE) {
throw new IllegalArgumentException(
"maxSize must be at most " + MAX_SIZE + ", got " + maxSize);
}Trade-offs
- Changes only sizes whose table is no larger than the history. Before the fix they construct (assertions off) but work only while the table has a free slot: up to 1,431,655,767 that is one distinct hash code besides the filler, so the first
onUsereturns and the next distinct one spins. Now they fail at construction with "maxSize must be at most 715827882, got 715827883", without allocating the 2.86 GB history. 715,827,882 still works. - Compatibility: it rejects one size a
long-based sizing could serve, 715,827,883 (exact table 2^30 slots; the long-arithmetic option below). Every larger size would need 2^31 slots or more. - One check covers the policy, direct users of the class and the
StripedFrequencyTrackerthat open PR #16306 builds from it. - No hot-path cost: the check runs once per constructor call. The limit could also be documented on the policy constructor's
@param historySize(not written).
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off. - With the fix: the repro,
TestFrequencyTrackingRingBuffer,TestUsageTrackingFilterCachingPolicyandTestLRUQueryCacherun 48 tests, 3 skipped (@AwaitsFixinTestLRUQueryCache), 0 failures, with assertions on and off; the repro alone passes at the default 512 MB test heap. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:corepass with the regression-test form of the repro (comment wrapped); that form fails on stock main both ways. The full upstream test suite was not run.
long capacity = Math.max(2, (long) maxSize * 3 / 2), round up with Long.highestOneBit, reject when it exceeds 2^30.
Trade-offs
Accepts exactly one more size, 715,827,883 (table 2^30), and rejects 715,827,884 and up, whose table would need 2^31 slots. Capping the table at 2^30 slots instead would accept sizes up to 2^30 - 1 but let the table run almost full, with long probe chains. Neither buys anything real over the recommended option. Not built.
Reject historySize above the bound in the policy constructor.
Trade-offs
Removes the public policy trigger but leaves the public @lucene.internal FrequencyTrackingRingBuffer, and the StripedFrequencyTracker of #16306, accepting broken sizes. Not built.
Defensive: throw instead of spinning when a probe comes back to its start slot.
Trade-offs
Guards against any future sizing slip, but adds work to every onUse and shouldCache and on its own still lets the constructor accept a size it cannot serve; if used, combine it with the recommended option. Not built.
The downstream engines checked (Elasticsearch, OpenSearch) build this policy with the default history of 256 and expose no setting for its size, so their users cannot reach this through configuration.
Code reading. A sweep of public APIs read helper classes that no bug report had named, checking their size arithmetic against Java's int and array limits: maxSize * 3 / 2 in FrequencyTrackingRingBuffer overflows exactly above 715,827,882. A public-API program on the stock jar then showed the hang.
The method in full201 words
Method: non-formal (bounded code reading with the array limits as preconditions, then dynamic checks).
- Reading. The sweep went through public API classes and the internal helpers behind them that no issue or PR had named, and checked every size computation against
intrange and the maximum array length.IntBag'smaxSize * 3 / 2overflows exactly above floor((2^31 - 1) / 3) = 715,827,882, well below the maximum array length, so the history array can still be allocated. - Confirmation. A public-API program (
new UsageTrackingQueryCachingPolicy(715_827_883), three distinctPrefixQuerys throughonUse, bounded wait) hung atIntBag.addwith assertions off on main, 10.5.2 and 9.12.3, and threwAssertionErrorwith assertions on (main; the independent reproducer later ran-eaon 10.5.2 and 9.12.3); a size check made it pass. - Earlier miss. An earlier hunt unit probed the class only with small sizes and with sizes of 2^30 and more, where allocation fails with
OutOfMemoryError(judged expected); the window from 715,827,883 to about 1.07e9, where allocation succeeds and the table wraps, was not probed. - Then an independent reproducer, a cross-model review (OpenAI GPT-5.6), a devil's-advocate verifier, an adjudication, a follow-up (history, prior reports, security), a credibility audit and a repro minimization followed.
No prior report found (2026-10-28: three scripted prior-report search runs of 51 queries over GitHub, the JIRA archive, the mailing lists, downstream trackers and the web, 48 manual GitHub searches and the files of all 410 open PRs; refreshed 2026-10-31). Open PR #16306 (lock striping) would hide the policy-level trigger as a side effect.
Searches2 searches
Verdict new: no issue, PR, review comment, commit, JIRA entry, list thread or downstream issue describes the IntBag int overflow, the 2-slot table, the endless probe or the onUse hang. Related, not reports: open PR #16306 (lock striping, milestone 12.0.0) would split the history into 16 stripes of max(2, historySize / 16), which hides the policy-level trigger as a side effect, but its StripedFrequencyTracker and FrequencyTrackingRingBuffer would still accept overflowing sizes; #7180 (LUCENE-6118) introduced the code; #7410, #10081, #14986 and #7697 are about lock scope, concurrency or caching effectiveness, and #9053 about the cost of Class.getSimpleName in the policy; elastic/elasticsearch#16031 asks for histories longer than 256 without approaching this size.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; downstream repositories; all of GitHub), JIRA, mailing lists, web | 2026-10-28 | three scripted prior-report search runs of 51 queries each (identifiers, symptoms, the overflow class), 48 manual GitHub searches (34 on apache/lucene, 14 on downstream repositories and all of GitHub: 715827883, "maxSize * 3 / 2", IntBag, historySize, hang, infinite loop), JIRA text searches, list queries over dev@, java-user@, issues@ and commits@, and the changed files of all 410 open PRs | no report; related: #16306 (open, lock striping) |
| GitHub (apache/lucene) | 2026-10-31 | searches for FrequencyTrackingRingBuffer, IntBag, 715827883 and symptom phrases; the changed files of the 20 PRs created since 2026-10-28 | only #16306 and #7180 (the introducing issue); no new PR touches either file |
Introduced with the counting table itself by LUCENE-6118 (5a9639c3e62, 2014-12-19) and first released in 5.0.0. 5.0.0, 5.5.5, 6.1.0, 8.11.4, 9.12.3, 10.5.2, 10.6.0 and main were run and all fail; 4.10.4 has none of the classes. Never fixed, not a regression.
Versions tested7 rows
Introduced by 5a9639c3e629d6af02d20acfee58d33d2d35b1a6 (trunk) and 9e89cb92ae29fb3c6f94c3daaac66b231c6e8043 (branch_5x), LUCENE-6118 (issue title "Improve efficiency of the history structure for filter caching", #7180; commit subject "LUCENE-6118: Improve the efficiency of the history structure for recently-used filters."; Adrien Grand, +1 from Ryan Ernst on JIRA), 2014-12-19. It replaced the unreleased LUCENE-6077 history (a deque plus a hash map, with no size check) with the int[] ring and the IntBag sized maxSize * 3 / 2 in int, and added maxSize >= 2 as its only size check. No PR (Subversion era); the review discussed only IntBag.remove. git blame -w -M -C gives this commit for the check, the sizing and the probe loop; later commits to the file are a license-header move (2016) and a reformat (2020).
Public trigger by era: UsageTrackingFilterCachingPolicy(float, int historySize, int, int, int) in 5.0.x; UsageTrackingQueryCachingPolicy(float, int) or (int, float, int) in 5.1.0-6.0.1; UsageTrackingQueryCachingPolicy(int historySize) since 6.1.0; FrequencyTrackingRingBuffer(int, int) throughout. The sizing line is in all 98 release tags from 5.0.0 to 10.5.2 of the history clone.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal test: 30 s timeout in IntBag.add with assertions off, AssertionError with assertions on |
| 10.5.2, 9.12.3 | affected | hang with assertions off, AssertionError with assertions on; on 10.5.2 IndexSearcher.search hangs with a second search BLOCKED |
| 8.11.4, 6.1.0 | affected | the second onUse hangs (assertions off) |
| 5.5.5 | affected | direct ring-buffer probe: the second add hangs |
| 5.0.0 | first release | 5.0 filter-policy API: hang with assertions off, AssertionError with assertions on |
| 4.10.4 | not affected | none of the classes exists |
upstream main 2f424fd428d, branch_10_6 | read | both files byte-identical to 645d4fe14a20 |
The finder, an independent reproducer, a devil's-advocate verifier, an adjudication, a credibility audit, the repro minimization and this doc each reproduced it with their own program or test. The cross-model review (OpenAI GPT-5.6) could not run code or read the source and gave a procedural not-real verdict, not a technical refutation.
Checks and verdicts334 words
Every executed check ran on stock builds. No cross-model reviewer executed Java for this bug; a separate check run with the review reproduces it.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, public-API program) | candidate | Hang at IntBag.add:137 on main with assertions off, 10.5.2 and 9.12.3; AssertionError with assertions on (main); the size check passes. |
| Independent reproducer | real | Own check: hang with a second thread BLOCKED, 4.000 bytes per entry, the same on 10.5.2 and 9.12.3; boundary control 715,827,882 (13 GB heap) and 1,000 pass; the fix passes. |
| Cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real (procedural) | Could not run commands or read the source and wrote that its verdict "is not a technical refutation"; a separate check hangs on main, throws with assertions on and passes at 100M. |
| Devil's advocate | real (high; low impact) | Built and rejected the intended-behaviour case; searcher-level probe hangs IndexSearcher.search on main and 10.5.2 with a second search BLOCKED; 1M control passes. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's check on main: hang with assertions off, AssertionError with assertions on. |
| Follow-up and credibility audit | history, prior art, security; keep | 6 releases (5.0.0 to 10.5.2) and main run, all affected; 4.10.4 has no such classes; prior-art verdict new; the audit's re-run fails both ways on main. |
| Noise audit (2026-10-29) | noise-candidate, kept | The triage first marked it noise ("absurd parameter": about 2.8 million times the default history, a multi-GB array, assertions off). The challenger kept it: the absurd-parameter rule covers only loud failures, and this one is a silent spin under the policy's lock. |
| Repro minimization (2026-10-29) | fails as claimed | The one-test repro fails on 645d4fe14a20 with assertions on and off; with the fix shadowed it passes. |
| This doc | reproduced, fix validated | Gradle on 645d4fe14a20: the repro fails both ways on stock and passes with the fix, as do the closest suites and the core lint checks; release 10.6.0 fails the repro both ways. |
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 553 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,512 words
- Registry title
- UsageTrackingQueryCachingPolicy(historySize) with historySize 715,827,883 to 1,431,655,767: FrequencyTrackingRingBuffer's IntBag sizes its hash table as maxSize * 3 / 2 in int, the product overflows to a 2-slot table, and the second distinct query recorded by onUse loops forever while holding the policy's monitor (larger sizes overflow too, to a table no larger than the history; assertions disabled; with -ea the constructor throws AssertionError instead) (since 5.0.0)
- Area
- search / query caching policy (UsageTrackingQueryCachingPolicy over util FrequencyTrackingRingBuffer)
- Priority
- Low, rank 829: The hang needs -da and a UsageTrackingQueryCachingPolicy history of 715M or more entries, which no one chooses.
- Impact class
- hang
- Impact note
- Only an application that builds UsageTrackingQueryCachingPolicy with the expert constructor and a history of at least 715,827,883 entries (the default is 256, about 2.8 million times smaller; the history int[] alone is 2.7 GiB) is hit, in every release from 5.0.0 to 10.6.0 and on main: with assertions disabled (the production setting) the second distinct cacheable query that reaches the policy spins forever in FrequencyTrackingRingBuffer$IntBag.add while holding the policy's monitor, so that search never returns and every other search thread that consults the same policy blocks behind it, with no timeout or interrupt able to stop it; with -ea the constructor throws AssertionError; on main the query cache is off by default, so an LRUQueryCache must also be installed (9.x and 10.x cache by default).
- Summary
- You create new UsageTrackingQueryCachingPolicy(715_827_883) (the same int overflow also breaks every larger size your heap can allocate) and install it on an IndexSearcher with a query cache, expecting it either to track that many recent queries or to reject the size up front with IllegalArgumentException, as FrequencyTrackingRingBuffer already does for sizes below 2. Instead the constructor succeeds (with assertions disabled), the first query is recorded, and the second distinct query loops forever inside onUse while holding the policy's lock, so that search hangs and every other search thread that uses the policy blocks. The cause is that FrequencyTrackingRingBuffer's internal IntBag computes its hash-table size as Math.max(2, maxSize * 3 / 2) in int: above 715,827,882 the product overflows, the table gets only 2 slots (the sentinel takes one, the first query hash the other), and IntBag.add's probe loop has no exit when every slot holds another key.
- Introduced
- 5a9639c3e629d6af02d20acfee58d33d2d35b1a6 (trunk, svn r1646674; branch_5x 9e89cb92ae29fb3c6f94c3daaac66b231c6e8043, svn r1646675), LUCENE-6118 'Improve efficiency of the history structure for filter caching' (JIRA, migrated as apache/lucene#7180; patch by jpountz, +1 by rjernst on JIRA; no PR, pre-GitHub) [read: git blame -w -M -C at 7cecf795236 and git log -S 'maxSize * 3 / 2' / -S 'assert capacity > maxSize']: it replaced the unreleased LUCENE-6077 history (ArrayDeque plus ConcurrentHashMap, 2014-11-27) with an int[] ring plus the open-addressing IntBag sized Math.max(2, maxSize * 3 / 2) in int, and added maxSize >= 2 as its only size check (the LUCENE-6077 constructor had none); the review discussed only IntBag.remove chain relocation, no size limit. Not a regression: the defect was born with IntBag. Later commits to the file are only a license-header move (813ca77250d, 2016) and a reformat (2d6ad2fee6d, 2020). (2014-12-19 (author date = commit date: 11:22:13Z trunk, 11:25:30Z branch_5x); first released in 5.0.0)
- First affected version
- 5.0.0 [exec] (hang with -da and AssertionError with -ea through the 5.0 public API UsageTrackingFilterCachingPolicy(float, int historySize, int, int, int), and a hang through FrequencyTrackingRingBuffer directly); 4.10.4 has none of the classes [exec], so no earlier release is affected
- Affected versions
- every release from 5.0.0 to 10.6.0 and main: [exec] 5.0.0 (-da hang, -ea AssertionError), 5.5.5 (direct FrequencyTrackingRingBuffer probe, -da hang), 6.1.0 and 8.11.4 (-da hang), 9.12.3 and 10.5.2 (-da hang, -ea AssertionError; the intended-behaviour check's searcher-level probe also hangs IndexSearcher.search on 10.5.2), main a9779ef3c193 (-da hang, -ea AssertionError, re-run 2026-10-28 by the registration check), and the minimal test on the 10.6.0 release jars and on main 645d4fe14a20 (-da 30 s timeout in IntBag.add, -ea AssertionError, 2026-10-31); [read] the sizing line is in all 98 release tags 5.0.0-10.5.2 of the history clone (8.8.2-8.11.4 are not tagged there; 8.11.4 was executed), and the 10.6.0 tag 4ae946e5a9b (branch_10_6) has both files identical to main and to upstream main 2f424fd428d [read 2026-10-31]. Public trigger by era: UsageTrackingFilterCachingPolicy(..., historySize, ...) in 5.0.x, UsageTrackingQueryCachingPolicy(float, int) / (int, float, int) in 5.1.0-6.0.1, UsageTrackingQueryCachingPolicy(int historySize) since 6.1.0, and FrequencyTrackingRingBuffer(int, int) (public, @lucene.internal) throughout. Not fixed on upstream main c5ab3e57b9ba (2026-10-28) or 2f424fd428d (2026-10-30).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (the prior-report record verdict new, searched 2026-10-28, refreshed 2026-10-28, read-only): no issue, PR, review comment, commit message, JIRA entry, mailing-list message, downstream report or web page describes the IntBag int-overflow sizing, the 2-slot table for maxSize >= 715,827,883, the endless probe in IntBag.add, or the resulting onUse hang or AssertionError. Searched: 3 scripted prior-report searches (each 16 GitHub issue/PR, 3 commit, 2 downstream-repository, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA, plus web; identifiers, symptoms and the overflow class), 48 manual GitHub searches (34 on apache/lucene, 14 on elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and all of GitHub; 715827883, 'maxSize * 3 / 2', IntBag, historySize, hang, infinite loop), JIRA text searches, the changed files of all 410 open apache/lucene PRs (only #16306 touches UsageTrackingQueryCachingPolicy.java, none touches FrequencyTrackingRingBuffer.java), the file lists of the PRs updated since 2026-10-26, and upstream commits by author date (last change to FrequencyTrackingRingBuffer.java 2d6ad2fee6d, 2020-12-23, reformat; to UsageTrackingQueryCachingPolicy.java b0ebaaed565, 2025-02-12, errorprone cleanup). Refresh for this row: gh search prs 'FrequencyTrackingRingBuffer' (only #16306), gh search issues 'UsageTrackingQueryCachingPolicy' (newest #10081, #15379, #14986, all unrelated), the 15 PRs created and 27 open PRs updated since 2026-10-26/27 (none related; #16919, merged 2026-10-28, converts AssertionErrors to exceptions in four other source files and one test). Related, not reports: open stale PR #16306 (prudhvigodithi, lock striping, milestone 12.0.0) would replace the policy's single buffer with 16 stripes of max(2, historySize / 16) entries, which hides the policy-level trigger as a side effect but leaves the IntBag arithmetic and its new StripedFrequencyTracker(numStripes, historyPerStripe, sentinel) accepting overflowing sizes; LUCENE-6118 (#7180) introduced the code; LUCENE-6349 (#7410), LUCENE-9038 (#10081), #14986, LUCENE-8005 (#9053), LUCENE-6639 (#7697) and elastic/elasticsearch#16031 are about lock scope, concurrency or caching effectiveness. Not fixed upstream: main c5ab3e57b9ba (2026-10-28) still has the sizing line.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree with assertions on (AssertionError) and off (30 s timeout inIntBag.add), and through a standalone runner; with the recommended fix it passes (also at the default 512 MB test heap), as doTestFrequencyTrackingRingBuffer,TestUsageTrackingFilterCachingPolicyandTestLRUQueryCachewith assertions on and off, and the core module's format, ECJ and forbidden-apis checks. Release 10.6.0 fails the minimal test both ways; 5.0.0, 5.5.5, 6.1.0, 8.11.4, 9.12.3 and 10.5.2 hang with assertions off (earlier repros of the same mechanism); 4.10.4 has none of the classes. Upstream main2f424fd428d(2026-10-30),branch_10_6and the 10.6.0 tag have byte-identicalFrequencyTrackingRingBuffer.javaandUsageTrackingQueryCachingPolicy.java[read].
- Finding record
lucene/core/src/java/org/apache/lucene/util/FrequencyTrackingRingBuffer.java:27 ('requires 22 bytes per entry on average (between 16 and 28)'), :42-55 (constructor: 'will contain at most maxSize items', checks only maxSize < 2, fills the bag with the sentinel), :77 (add -> frequencies.add), :108-117 (IntBag(int): capacity = Math.max(2, maxSize * 3 / 2) in int at :110, 'assert capacity > maxSize' at :113), :125-133 (frequency loop), :136-145 (add loop with no exit when every slot holds another key)lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:31 (SENTINEL = Integer.MIN_VALUE), :96-106 (expert constructor javadoc 'Beware of passing too large values for the size of the history' about caching quality only; no bound on historySize), :112-113 (default history 256), :141-159 (onUse: recentlyUsedFilters.add(hashCode) inside synchronized (this) at :157)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84 (DEFAULT_QUERY_CACHE = null on main), :274 (setQueryCache), :297 (setQueryCachingPolicy)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FrequencyTrackingRingBuffer.java:27, :42-59, :70-83, :96-97, :108-117, :125-133, :136-145a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:31, :56-60, :104-106, :141-182a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:812-813
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-87, :181-183; lucene/CHANGES.txt:343 (GITHUB#14187)a Lucene clone releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:95-99 (default LRUQueryCache)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FrequencyTrackingRingBuffer.java:27, :42-48, :96-97, :113a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:95-106a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:121-124; lucene/core/src/java/org/apache/lucene/util/LongHeap.java:57-63; lucene/core/src/java/org/apache/lucene/internal/hppc/HashContainers.java:92-93a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestFrequencyTrackingRingBuffer.java:55, :71github.com/apache/lucene/issues/11643 (LUCENE-10607, labels type:bug, fix version 9.3; read 2026-10-31)SECURITY.md line 46
- Suggested fix
arithmetic: long 715,827,883 * 3 / 2 = 1,073,741,824 -> table 2^30; 715,827,884 -> 2^31 (checked in the brief)
- TestUsageTrackingPolicyHugeHistory.java (one test class):
repro/LFV-888/TestUsageTrackingPolicyHugeHistory.java(44 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and an executed slot-state probe.
assets/LFV-888/bug.png(553 KB)
