All findings · Lucene finding · LFV-888 · frequencytrackingringbuffer-capacity-overflow-hang

A query-cache history of 715,827,883 entries overflows an int, and the second distinct query loops forever

With assertions disabled, new UsageTrackingQueryCachingPolicy(715_827_883) is accepted, but an int overflow gives its counting table 2 slots, so the second distinct query loops forever inside the policy's lock, and other searches with cacheable queries block behind it. Every release since 5.0.0 is affected; rejecting such sizes up front fixes it.

Summary

Priority
Low, rank 829The hang needs -da and a UsageTrackingQueryCachingPolicy history of 715M or more entries, which no one chooses.
Impact
hang
Since
5.0.0 (LUCENE-6118, 2014)introduced 2014-12-19 · over 11 years
Affected
every release 5.0.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading of the size arithmetic in the policy's helper class

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

How the usage-tracking caching policy counts queries

  1. 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).
  2. 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 the historySize you pass to the expert constructor.
  3. 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.
Diagram of the bug: new UsageTrackingQueryCachingPolicy(715_827_883). The table size maxSize * 3 / 2 is computed in int; 715,827,883 × 3 = 2,147,483,649 wraps to -2,147,483,647, so the counting table gets 2 slots instead of 2^30. Slot 0 holds the filler Integer.MIN_VALUE, slot 1 the hash code of PrefixQuery f:a. onUse(f:b) probes slot 0, slot 1, slot 0, ... forever inside synchronized (this), and a second thread calling shouldCache waits, BLOCKED.
715,827,883 × 3 overflows 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.

The finding, step by step

  1. 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 with IllegalArgumentException, as it does for sizes below 2.
  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 / 2 in int, and 715,827,883 × 3 = 2,147,483,649 wraps to -2,147,483,647, so Math.max(2, ...) picks 2. With 256 the table has 512 slots.
  3. 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).
  4. 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 bare AssertionError instead.

Who is affected

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.

The only size check

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.

The table size

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
}

The probe loop

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

An executed trace (main, assertions off)

  1. After the constructor: table length 2; slot 0 holds Integer.MIN_VALUE with 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.
  2. 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.
  3. onUse(f:b): FrequencyTrackingRingBuffer.add first 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 calling shouldCache(f:a) is BLOCKED.

The lock

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

Which sizes break

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 example LRUQueryCache); 9.x and 10.x install an LRUQueryCache by default, so setting the policy is enough there. Direct users of the public @lucene.internal FrequencyTrackingRingBuffer are 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 onUse or shouldCache (a policy set with IndexSearcher.setDefaultQueryCachingPolicy is 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 AssertionError from the constructor.
  • Likelihood: contrived. Nobody picks a history of 716 million queries by accident: the javadoc already warns about large histories, Integer.MAX_VALUE fails loudly with OutOfMemoryError, the downstream engines checked use the default constructor, and the documented searches found no public report since 5.0.0 (2015).

Minimal repro

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.

TestUsageTrackingPolicyHugeHistory.java (one test class)44 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 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");
  }
}
Command
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) throws IllegalArgumentException, or both onUse calls return.
Observed
Assertions off: onUse must return ==> execution timed out after 30000 ms, stuck in FrequencyTrackingRingBuffer$IntBag.add (line 137); assertions on: a bare AssertionError in the constructor.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0: fails; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the arithmetic is plain int)
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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, LongHeap and the internal hash containers reject sizes they cannot handle.
The full argument297 words
  • The class's own invariant breaks. IntBag's assert 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 most maxSize items" (:42-43); that still holds for the ring itself, but no javadoc states an upper limit, and the constructor checks only maxSize < 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 historySize gives no bound.
  • Lucene rejects sizes it cannot handle elsewhere. PriorityQueue (PriorityQueue.java:121-124) and LongHeap (LongHeap.java:57-63) throw IllegalArgumentException "to prevent confusing OOME"; the internal hppc HashContainers throws BufferAllocationException when a table would exceed its maximum length (HashContainers.java:92-93). LUCENE-10607 (#11643), an int overflow at an extreme input size, was labelled type:bug and fixed in 9.3.
  • Nothing covers it. TestFrequencyTrackingRingBuffer uses sizes up to 10,001; no Lucene code or test passes a history size other than the default 256 (the no-arg constructor's this(256)); the LUCENE-6118 review discussed only IntBag.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.

Suggested fix

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.

Reject maxSize above Integer.MAX_VALUE / 3 in FrequencyTrackingRingBuffer (validated) (recommended)

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 onUse returns 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 StripedFrequencyTracker that 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, TestUsageTrackingFilterCachingPolicy and TestLRUQueryCache run 48 tests, 3 skipped (@AwaitsFix in TestLRUQueryCache), 0 failures, with assertions on and off; the repro alone passes at the default 512 MB test heap.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:core pass 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.

Compute the table size in long and reject only what cannot be allocated

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.

Check only in UsageTrackingQueryCachingPolicy(int)

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.

Also make IntBag.add and frequency stop when the probe wraps around

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.

Downstream note

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.

How it was found

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 int range and the maximum array length. IntBag's maxSize * 3 / 2 overflows 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 distinct PrefixQuerys through onUse, bounded wait) hung at IntBag.add with assertions off on main, 10.5.2 and 9.12.3, and threw AssertionError with assertions on (main; the independent reproducer later ran -ea on 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; downstream repositories; all of GitHub), JIRA, mailing lists, web2026-10-28three 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 PRsno report; related: #16306 (open, lock striping)
GitHub (apache/lucene)2026-10-31searches for FrequencyTrackingRingBuffer, IntBag, 715827883 and symptom phrases; the changed files of the 20 PRs created since 2026-10-28only #16306 and #7180 (the introducing issue); no new PR touches either file

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal test: 30 s timeout in IntBag.add with assertions off, AssertionError with assertions on
10.5.2, 9.12.3 affectedhang 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 affectedthe second onUse hangs (assertions off)
5.5.5 affecteddirect ring-buffer probe: the second add hangs
5.0.0 first release5.0 filter-policy API: hang with assertions off, AssertionError with assertions on
4.10.4 not affectednone of the classes exists
upstream main 2f424fd428d, branch_10_6 readboth files byte-identical to 645d4fe14a20

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, public-API program) candidateHang 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 realOwn 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; keep6 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, keptThe 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 claimedThe one-test repro fails on 645d4fe14a20 with assertions on and off; with the fix shadowed it passes.
This doc reproduced, fix validatedGradle 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.

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

Finding record

LFV-888, snapshot of 2026-11-06

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 in IntBag.add), and through a standalone runner; with the recommended fix it passes (also at the default 512 MB test heap), as do TestFrequencyTrackingRingBuffer, TestUsageTrackingFilterCachingPolicy and TestLRUQueryCache with 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 main 2f424fd428d (2026-10-30), branch_10_6 and the 10.6.0 tag have byte-identical FrequencyTrackingRingBuffer.java and UsageTrackingQueryCachingPolicy.java [read].

Lucene and upstream sources

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-145
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:31, :56-60, :104-106, :141-182
  • a 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, :113
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:95-106
  • a 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-93
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestFrequencyTrackingRingBuffer.java:55, :71
  • github.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)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and an executed slot-state probe.

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.