All findings · Lucene finding · LFV-400 · geo3d-shape-query-rambytesused-ignores-shape

Geo3DPoint polygon and path queries report the same ramBytesUsed() whatever the shape size, so a query cache can far exceed its RAM budget

Every Geo3DPoint polygon or path query reports the same ramBytesUsed() (592 bytes with compressed oops): a 2,000-edge polygon holding 1.36 MB reports the same as an 8-edge one. LRUQueryCache trusts that number, so 8 such cached filters hold 10.9 MB under a 1 MiB budget and nothing is evicted.

Summary

Priority
Low, rank 731A constant ramBytesUsed() for large Geo3D shape queries lets LRUQueryCache go far over its RAM budget, bounded only by maxSize, with correct hits.
Impact
minor
Since
LUCENE-8855 (8.2.0, 2019)introduced 2019-06-26 · about 7 years
Affected
8.2.0 to 10.6.0 and main
Status
documented
Found by
semi-formal: an Accountable-query RAM differential, then a public-API JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A query cache asks each cached query how much heap it holds. PointInGeo3DShapeQuery answers with its own small fields plus a fixed 256-byte guess for the shape and another for the shape's bounds, so a polygon or path of any size reports 592 bytes.

Background

  1. ramBytesUsed(). Queries that implement Accountable report an estimate of the heap they hold. RamUsageEstimator.sizeOfObject sizes known types (strings, arrays, collections, Accountable objects) and gives any other object a flat 256 bytes, documented as "a few primitive fields and a couple short String-s".
  2. LRUQueryCache. It caches the matching documents of filters, keyed by query, and evicts the least recently used entries once it holds more than maxSize entries or more than maxRamBytesUsed bytes. Each cached Accountable query is charged its ramBytesUsed().
  3. Geo3DPoint shapes. The spatial3d polygon, large-polygon and path factories turn the input into a GeoShape made of planes, edges and search trees: about 280 bytes per edge for newPolygonQuery, 680 for newLargePolygonQuery and 1,450 per point for newPathQuery.
Diagram of the bug: an 8-edge and a 2,000-edge Geo3DPoint large-polygon query both report ramBytesUsed() = 592 bytes, while they retain about 6.4 KB and 1.36 MB. An LRUQueryCache with a 1 MiB budget charges 8 cached 2,000-edge filters 6,080 bytes in total and evicts nothing, while their query keys hold 10.9 MB.
What the cache charges for each cached query (592 bytes) next to the heap each one really holds (megabytes for large polygons), and the budget line the real total crosses without any eviction. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build Geo3DPoint.newLargePolygonQuery for a polygon with 8 edges and one with 2,000 edges, and ask each for ramBytesUsed(). The two queries hold about 6.4 KB and 1.36 MB of heap.
  2. You expect the 2,000-edge query to report far more: ramBytesUsed() is an estimate of the object's heap, and LRUQueryCache charges it against its maxRamBytesUsed budget.
  3. Instead both report 592 bytes (with compressed oops; 624 without). PointInGeo3DShapeQuery sizes its shape and the shape's bounds with RamUsageEstimator.sizeOfObject, and since neither a GeoShape nor an XYZBounds is Accountable, each of the two counts as a fixed 256-byte unknown object.
  4. The cache believes it: with a 1 MiB budget it keeps 8 cached 2,000-edge filters that hold 10.9 MB, reports 6,080 bytes and evicts nothing. Hits stay correct.
  5. Releases 8.2 to 10.6 give every IndexSearcher such a cache by default (for segments of 10,000+ documents); on main only applications that install one are hit.

Who is affected

Users of Geo3DPoint polygon, large-polygon or path filters with a query cache: by default in 8.2 to 10.6 (one JVM-wide cache, segments of 10,000+ documents, after 2 uses), on main only with an installed cache. The RAM budget stops bounding these keys; only the entry limit does. Hits stay correct.

Mechanism, terms and numbers534 words

Geo3DPoint.newPolygonQuery, newLargePolygonQuery and newPathQuery build a PointInGeo3DShapeQuery, which implements Accountable. Its ramBytesUsed() adds RamUsageEstimator.sizeOfObject(shape) and sizeOfObject(shapeBounds); no GeoShape or XYZBounds is Accountable, so each falls back to the fixed 256 bytes that RamUsageEstimator assigns to unknown objects, and every such query reports the same size whatever its shape, 592 bytes on a field named f with compressed oops (624 without): 4.9x too low for an 8-edge polygon, 47x for 99 edges, about 2,300x for a 2,000-edge large polygon (1,360,960 bytes retained). LRUQueryCache charges a cached Accountable query exactly this value, so its maxRamBytesUsed budget stops bounding these keys and only its entry limit does. Search hits are always correct. The code dates from LUCENE-8855 (8.2.0) and is unchanged on main and in 10.6.0.

The code (main 645d4fe14a20)

  1. The factories. Geo3DPoint.java:168-172 (newPolygonQuery, documented for "a limited number of edges (less than 100)", :157-159), :186-190 (newLargePolygonQuery, "optimized for very large numbers of polygon edges", :175-177) and :203-212 (newPathQuery, no documented limit) build a GeoShape and call newShapeQuery (:241-243): new PointInGeo3DShapeQuery(field, shape). newDistanceQuery and newBoxQuery go the same way, but their shapes do not grow with the input.
  2. The query. PointInGeo3DShapeQuery.java:44 implements Accountable; it keeps the shape and an XYZBounds (:48-58), and isCacheable always returns true (:113-116).
  3. The estimate. :160-166: BASE_RAM_BYTES + sizeOfObject(field) + sizeOfObject(shape) + sizeOfObject(shapeBounds).
  4. The fallback. RamUsageEstimator.java:393-395: sizeOfObject(Object) passes UNKNOWN_DEFAULT_RAM_BYTES_USED (256, :82-86) as the default; the type switch (:407-444) knows Accountable, strings, primitive arrays, queries, maps and collections, and any other object gets the default (:435-441). A GeoComplexPolygon, GeoConvexPolygon or GeoStandardPath and an XYZBounds are none of those, so 512 of the 592 bytes are the two fixed guesses.
  5. The cache. LRUQueryCache.java:465-471 charges RamUsageEstimator.sizeOf(query, 32) ("Deep RAM cost of the query object itself"), which returns ramBytesUsed() for an Accountable query (RamUsageEstimator.java:378-386); the charge is added when a query is first cached in a partition (LRUQueryCache.java:1121-1124, through onQueryCache, :1037-1039), and eviction runs only while size > maxSize || this.ramBytesUsed > maxRamBytesUsed (:1214-1220). 10.5.2 and 10.6.0 charge LINKED_HASHTABLE_RAM_BYTES_PER_ENTRY + ramBytesUsed() (10.6.0 LRUQueryCache.java:428-433).
  6. Why these queries get cached. UsageTrackingQueryCachingPolicy.java:33-54: any class named Point*Query is "costly" and is cached after 2 uses (:122-125).

The numbers

Retained heap measured with the test framework's RamUsageTester, not counting the shared PlanetModel (compressed oops; on 645d4fe14a20):

queryramBytesUsed()retained
newPolygonQuery, 8 edges5922,920
newPolygonQuery, 99 edges59227,696
newLargePolygonQuery, 8 edges5926,400
newLargePolygonQuery, 2,000 edges5921,360,960
newLargePolygonQuery, 5,000 edges5923,400,960
newPathQuery, 200 points592290,168

One segment of 100 points, new LRUQueryCache(1000, 1 MiB, ctx -> true, Float.POSITIVE_INFINITY, partitions, null) with an always-cache policy, 8 different 2,000-edge newLargePolygonQuery filters each run once as a ConstantScoreQuery: 8 entries, 0 evictions, cache.ramBytesUsed() = 6,080 bytes (8 x 760: 592 for each query key, 168 for the entry and its cached doc id set), while the 8 query keys retain 10,888,352 bytes (with 1 and with 16 partitions); 296 hits in total, the same as with the fix.

Exposure and likelihood318 words
  • Who. Applications that filter or count with Geo3DPoint polygon, large-polygon or path queries (the spatial3d module, @lucene.experimental) on an index searched through a query cache.
  • 8.2.0 to 10.6.0: by default. IndexSearcher installs one static LRUQueryCache(1000, min(32 MB, heap / 20)) for every searcher in the JVM (10.6.0 IndexSearcher.java:95-100), caching only on segments of at least 10,000 documents that also hold more than half the average documents per segment (10.6.0 LRUQueryCache.java:175-177, :188-196), with UsageTrackingQueryCachingPolicy, which caches these queries after 2 uses.
  • main and branch_11x: "Caching is disabled by default" (IndexSearcher.java:82-84); only applications that install a cache are hit.
  • How much. The under-count grows by about 280 bytes per edge for newPolygonQuery, 680 for newLargePolygonQuery and 1,450 per path point (310, 810 and 1,770 without compressed oops). The study's heap probe, on main a9779ef3c19 and 10.5.2 (the files involved are unchanged since): with a 256 MB heap and LRUQueryCache(1000, 13.4 MB) plus the stock caching policy, distinct 3,000-edge newLargePolygonQuery filters, each sent twice, ran the JVM out of heap after 128 polygons while the cache reported 415,744 bytes and had evicted nothing; without a cache all 400 ran. With a 3 GB heap only the entry limits held: on main the cache (16 partitions of at most 62 entries) ended at 964 entries after 136 evictions, all driven by the entry count (it counted 3.1 MB of its 32 MiB budget), holding 1,986,237,856 bytes; the 10.5.2 default cache ended at its maxSize of 1,000 entries, holding 2,057,955,792 bytes.
  • What it does not do. Hits were identical with and without a cache in every run; nothing is bypassed, exposed or corrupted. Small polygons are off by little in absolute terms (8 edges: about 2.3 KB).
  • How likely: rare. It takes shapes with hundreds to thousands of vertices, repeated often enough to be cached, in enough distinct copies to matter for the heap.

Minimal repro

One JUnit test, no index and no randomness: build newLargePolygonQuery for an 8-edge and a 2,000-edge polygon and assert that the larger one reports more heap. Public API only; the cast is to the public Accountable interface.

TestGeo3DShapeQueryRamBytesUsed.java (one test)46 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.spatial3d;

import org.apache.lucene.geo.Polygon;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Accountable;

public class TestGeo3DShapeQueryRamBytesUsed extends LuceneTestCase {

  private static long ramBytesUsed(int edges) {
    double[] lats = new double[edges + 1];
    double[] lons = new double[edges + 1];
    for (int i = 0; i < edges; i++) {
      lats[i] = 30 * Math.sin(-2 * Math.PI * i / edges);
      lons[i] = 40 * Math.cos(-2 * Math.PI * i / edges);
    }
    lats[edges] = lats[0];
    lons[edges] = lons[0];
    Polygon polygon = new Polygon(lats, lons);
    return ((Accountable) Geo3DPoint.newLargePolygonQuery("f", PlanetModel.WGS84, polygon))
        .ramBytesUsed();
  }

  public void testRamBytesUsedGrowsWithPolygonSize() {
    long small = ramBytesUsed(8);
    long large = ramBytesUsed(2000);
    // expected: the 2000-edge query reports more heap than the 8-edge one; actual: both report 592
    assertTrue("8 edges: " + small + " bytes, 2000 edges: " + large + " bytes", large > small);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests TestGeo3DShapeQueryRamBytesUsed -Ptests.seed=DEADBEEF
Expected
The 2,000-edge query (about 1.36 MB retained) reports more ramBytesUsed() than the 8-edge one (about 6.4 KB).
Observed
AssertionError: 8 edges: 592 bytes, 2000 edges: 592 bytes, with assertions on and off.

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

More files, environment, full output231 words

The two queries differ only in the number of edges and come from the same factory, the one documented for very large polygons. The assertion is the weakest one any heap estimate must pass, so it needs no tolerance. The retained sizes quoted on this page come from a separate diagnostic with RamUsageTester, not from the repro.

Release jars: the test fails on 10.6.0 with assertions on and off; the finder's earlier tests failed the same way on 8.2.0 to 10.5.2.

Regression test: this same test; it passes with the recommended fix and fits as one more method of spatial3d's TestGeo3DPoint.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release 10.6.0: fails
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestGeo3DShapeQueryRamBytesUsed.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests TestGeo3DShapeQueryRamBytesUsed -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestGeo3DShapeQueryRamBytesUsed > testRamBytesUsedGrowsWithPolygonSize FAILED
    java.lang.AssertionError: 8 edges: 592 bytes, 2000 edges: 592 bytes
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:F9C51CF1D77A9822]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.spatial3d.TestGeo3DShapeQueryRamBytesUsed.testRamBytesUsedGrowsWithPolygonSize(TestGeo3DShapeQueryRamBytesUsed.java:44)

# the numbers behind it (a diagnostic test on the same build, RamUsageTester, PlanetModel excluded; not the repro)
newLargePolygonQuery    8 edges: reported 592, retained     6,400
newLargePolygonQuery 2000 edges: reported 592, retained 1,360,960
# with the fix: reported 9,560 and 2,049,368; the test passes

Why it looks unintended

  • ramBytesUsed() is "an estimate of the JVM heap memory used by this object"; one that ignores the shape cannot track it.
  • The 256-byte fallback is documented for "a few primitive fields and a couple short String-s".
  • LUCENE-8855 added Accountable for queries "much larger than the default estimate (10x or more)"; #16405 fixed a far smaller under-count as a bug.
The full argument206 words
  • The contract. Accountable.java:34-40: "Returns an estimate of the JVM heap memory used by this object in bytes." LRUQueryCache.java:137-140: the cache holds "at most maxSize cache entries ... with at most maxRamBytesUsed bytes of memory"; the class javadoc says it evicts "in order to remain under a given maximum size and number of bytes used" (:60-62).
  • The fallback's own definition. RamUsageEstimator.java:82-86: "Approximate memory usage that we assign to all unknown objects - this maps roughly to a few primitive fields and a couple short String-s."
  • The introducing change's goal. LUCENE-8855 (#9898), the 2019-06-19 patch note that introduced the design that was committed: it "adds Accountable to Queries that may be much larger than the default estimate (10x or more)". On the same issue Adrien Grand wrote: "For the caching use-case I suspect it's better to overestimate memory usage a bit?"
  • Upstream tests. TestPointQueries.java:2653-2669 and TestTermInSetQuery.java:377-389 hold ramBytesUsed() within 5% of RamUsageTester.ramUsed(query); TestLRUQueryCache.testCachingAccountableQuery (:702-726) checks that the cache reflects big Accountable queries. No spatial3d test calls ramBytesUsed.
  • Precedent. #16405 ("PointInSetQuery#ramBytesUsed under-reports memory", type:bug, fixed by #16406); #15097 ("Incorrect size estimation of BooleanQuery in LRUQueryCache causing heap exhaustion", fixed by #15124, a fix that does not reach Accountable queries).
The devil's-advocate check259 words

The strongest case: ramBytesUsed() is only an estimate and sizeOfObject documents its 256-byte fallback; on LUCENE-8855 its author wrote "It's not a strict calculation anyway"; Accountable is @lucene.internal and Geo3DPoint, the query and LRUQueryCache are @lucene.experimental; GeoShape is an open interface, so no estimate fits every shape; caching is off by default on main; and on the same issue Adrien Grand first argued against making queries Accountable at all, naming Weight#isCacheable (keep large queries out of the cache) or a rough visitor-based estimate as alternatives. Open issue #16113 also proposes counting constructor inputs "only to the extent they are owned".

Why it fails: neither Accountable nor the cache promises an accuracy, but the value is not rough, it is constant; it does not move when the edge count grows 250-fold (the retained heap about 213-fold). The constant is documented for a few primitive fields, and the change that wrote this method aimed at queries 10x or more above the default. Adrien Grand then proposed the design that was merged themselves (ramBytesUsed() for Accountable queries, a constant for the rest), and this class was made Accountable under it. The isCacheable route was not taken either: isCacheable is always true, so large shapes neither opt out nor get sized. An open GeoShape explains the fallback for user shapes, not for the shapes Geo3DPoint builds itself; under #16113 those still count, since the factory builds each shape for that query alone and the cache keeps it alive. The verifier panel rated the case moderate and convincing to none.

Suggested fix

Recommended Size the shape from its vertex count in the Geo3DPoint factories

Let the Geo3DPoint polygon, large-polygon and path factories pass a size to the query: vertices times 512, 1,024 or 2,048 bytes. Every measured polygon and path of 3+ vertices then reports 1.16x to 1.86x its real heap. The repro passes with assertions on and off, and so do the spatial3d module's 181 tests.

// Geo3DPoint: per-vertex heap estimates for the shapes built below (comment shortened)
private static final long POLYGON_BYTES_PER_VERTEX = 512;
private static final long LARGE_POLYGON_BYTES_PER_VERTEX = 1024;
private static final long PATH_BYTES_PER_POINT = 2048;
...
return new PointInGeo3DShapeQuery(
    field, shape, numVertices(polygons) * LARGE_POLYGON_BYTES_PER_VERTEX);

// PointInGeo3DShapeQuery.ramBytesUsed()
return BASE_RAM_BYTES
    + RamUsageEstimator.sizeOfObject(field)
    + shapeRamBytesUsed
    + RamUsageEstimator.sizeOfObject(shapeBounds);
All options and trade-offs4 options · 939 words

Option A was built and run through gradle. B (make the GeoShape classes Accountable) is potentially the most accurate but a much larger change, not built; C (return false from isCacheable for large shapes, one of the two alternatives Adrien Grand named on LUCENE-8855 before proposing the merged design) stops the cache overrun but leaves the value wrong and can complement A; D (document it) leaves the budget broken. Workarounds that need no fix: cap the vertices per shape for request-built queries, install a QueryCachingPolicy that declines these queries, or run without a query cache (the default on main).

Size the shape from its vertex count in the Geo3DPoint factories (validated by the checks) (recommended)

The polygon, large-polygon and path factories know how many vertices they were given (holes included). They pass vertices x bytes per vertex to a new package-private PointInGeo3DShapeQuery constructor, which uses it instead of sizeOfObject(shape), never less than the old 256. newShapeQuery(field, shape) and the existing two-argument constructor keep sizeOfObject(shape), which already uses a caller's shape's ramBytesUsed() when that shape is Accountable. 2 files, +35/-4.

// Geo3DPoint: per-vertex heap estimates for the shapes built below (comment shortened)
private static final long POLYGON_BYTES_PER_VERTEX = 512;
private static final long LARGE_POLYGON_BYTES_PER_VERTEX = 1024;
private static final long PATH_BYTES_PER_POINT = 2048;
...
return new PointInGeo3DShapeQuery(
    field, shape, numVertices(polygons) * LARGE_POLYGON_BYTES_PER_VERTEX);

// PointInGeo3DShapeQuery.ramBytesUsed()
return BASE_RAM_BYTES
    + RamUsageEstimator.sizeOfObject(field)
    + shapeRamBytesUsed
    + RamUsageEstimator.sizeOfObject(shapeBounds);

Trade-offs

  • How close. Each constant is the next power of two above the measured heap per edge or point for shapes of 8 or more edges or 20 or more path points (about 280-430, 680-960 and 1,400-1,770 bytes, with and without compressed oops). Smaller shapes cost more per edge (3 edges: up to 643 and 1,232 bytes) but are still over-reported. With the fix every measured polygon and path of 3 or more vertices reports 1.16x to 1.86x its retained heap on both layouts (8 edges: 9,560 vs 6,408 bytes; 2,000 edges: 2,049,368 vs 1,360,968); the larger gaps are short paths, 1 point (2,392 vs 624) and 2 points (4,440 vs 1,888). No measured polygon or path is under-reported, in line with Adrien Grand's remark on LUCENE-8855, made about the constant for unknown queries, that for caching it is "better to overestimate".
  • Constants. They follow today's spatial3d.geom internals and would need re-measuring if those change; a RamUsageTester test with a tolerance, like TestPointQueries.testRamBytesUsedMatchesActualWithinTolerance, could guard them.
  • Tiling fallback. newPolygonQuery falls back to GeoComplexPolygon when it cannot tile the polygon (GeoPolygonFactory.java:352-362), about 680 bytes per vertex, so 512 under-counts that case by about 1.3x.
  • Unchanged. Shapes that callers pass to newShapeQuery, and circles and boxes, are priced as before.
  • Effect on the cache. Large shapes now count against the budget: in the cache diagnostic each 2,000-edge key is charged about 2 MB, more than the whole 1 MiB budget, so the cache evicts all 8 (0 entries, 8 evictions, the same 296 hits). By arithmetic (not run), the 10.x default cache (one budget of at most 32 MiB) has room for about 16 such keys before other overhead, instead of up to 1,000; on main the budget is split across partitions, so a key larger than one partition's share is not kept. Re-running an evicted filter costs one BKD intersection per segment.
  • Cost. One pass over the input vertex arrays when the query is built.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31; the worktree was removed afterwards):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • the finder's original two tests: the size test (reported within 2x of retained for 8- and 99-edge polygons, a 2,000-edge large polygon and a 200-point path) passes; the cache test now fails at its first assertion, which expects all 8 keys to stay cached (cache entries=0 evictions=8): the cache evicts them, the intended effect, so that test is not a regression test for the fix;
  • the closest suite TestGeo3DPoint: 17 tests, 0 failures, 2 skipped;
  • the whole :lucene:spatial3d:test: 19 classes, 181 tests, 0 failures, 3 skipped;
  • checkGoogleJavaFormat, ecjLintMain, forbiddenApisMain and renderJavadoc for spatial3d pass.

Make the GeoShape implementations Accountable

Implement ramBytesUsed() in GeoComplexPolygon, GeoConvexPolygon, GeoConcavePolygon, the composite shapes and GeoStandardPath (and other shapes as needed) from their arrays, edges, planes and trees; sizeOfObject(shape) then picks it up with no change to the query.

Trade-offs

Potentially the most accurate option (not built or measured), with no constants, and it also covers newShapeQuery with built-in shapes. A much larger change across spatial3d.geom (the large polygon alone holds edges, planes and three search trees), with a RamUsageTester test per shape type, and an ownership rule so that shared objects (the PlanetModel, parts that composite shapes share) are not counted twice. Not run.

Keep large shapes out of the cache (complement)

Return false from isCacheable (PointInGeo3DShapeQuery.java:113-116) for large shapes, for example above a vertex count the factories pass. Adrien Grand named this route on LUCENE-8855 ("rely on the existing Weight#isCacheable logic, which disables caching on large queries") as one of two alternatives to making queries Accountable, before proposing the compromise that was merged.

Trade-offs

Stops the cache overrun without constants, but ramBytesUsed() stays wrong, the minimal test still fails, and repeated large filters are re-evaluated every time. Can be combined with the recommended option. Not run.

Document the limitation (not recommended)

Say in the javadoc that the estimate leaves out the shape.

Trade-offs

The cache budget stays broken. Not run.

Downstream note

Services that build Geo3DPoint polygon or path filters from request input should cap the vertices per shape or keep these queries out of the query cache until a fix ships, because the cache can otherwise hold up to maxSize large shapes, far beyond its RAM budget.

How it was found

A hunt unit compared each Accountable query's ramBytesUsed() with the heap it really retains, for 26 public-API constructions of 12 Accountable query classes in core, join and spatial3d. It flagged 9 Geo3DPoint polygon and path constructions (4.9x to 5,745x too low); reading PointInGeo3DShapeQuery.ramBytesUsed() showed why, and a JUnit on the stock jars confirmed it.

The method in full191 words

Method: semi-formal (the hunt's tier for a systematic differential against an independent measurement). The unit hunted identity and accounting defects outside query semantics.

  • Differential. A harness built 26 constructions of 12 Accountable query classes through public APIs (the join module's GlobalOrdinalsQuery and GlobalOrdinalsWithScoreQuery were not covered) and compared Accountable#ramBytesUsed() with the heap each query object retains (the test framework's RamUsageTester, not following readers, directories or the PlanetModel singleton), before and after a search through an always-caching LRUQueryCache. The AutomatonQuery NFA case of LFV-192 served as a positive control.
  • Signal. 11 of the 26 were flagged (more than 2x under-reported): all 9 Geo3DPoint polygon and path constructions (4.9x to 5,745x), the AutomatonQuery NFA positive control (3.2x) and the numeric score join (2.4x, the separate LFV-402). The others were at most 1.9x under (the Geo3DPoint box), most within 1.1x.
  • Confirmation. Reading PointInGeo3DShapeQuery.ramBytesUsed() showed the sizeOfObject(shape) fallback, and a JUnit on the stock jars failed on main, 10.5.2 and 9.12.3. A cross-model review's objection (a 2,000-edge newPolygonQuery exceeds its documented "less than 100" edges) led to using newLargePolygonQuery for large polygons. A 2026-10-29 minimization cut the repro to the one test above.

Prior reports, history and checks

Prior reports

No prior report found: 129 logged queries on 2026-10-26 (GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream repositories, the web) plus the changed files of all 411 open PRs, and a GitHub re-check on 2026-10-31. Closest: LUCENE-8855 (#9898), which introduced the code, and #16405, a fixed under-count elsewhere.

Searches4 searches
  • #9898 / LUCENE-8855 introduced the code; it never mentions geo3d. #16113 / #16114 (open) discuss Accountable ownership semantics, with no geo3d. #16405 / #16406 (PointInSetQuery under-count, fixed) and #15097 / #15124 (non-Accountable BooleanQuery cache overrun, fixed; it does not reach Accountable queries) are the same symptom class elsewhere. #9053 / LUCENE-8005 (open) is about the Point*Query name match in the caching policy. #12614, #16657, #16743 and #15026 are other accounting paths.
  • Open PRs: the only one touching PointInGeo3DShapeQuery.java, #709, changes a DocIdSetBuilder call.
  • Related, different root cause: on main a non-Accountable LatLon polygon query is charged a flat 32 bytes by the cache's query visitor (LRUQueryCache.java:468-470, RamUsageEstimator.java:314-357; read, not executed). Maintainers may raise it.
  • Limits: GitHub search does not index PR diffs or review comments; the list search matches words, not phrases; private security reports are invisible.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch and Solr repositories)2026-10-26the scripted prior-report search (51 queries over GitHub, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and JIRA; 1,329 and 1,155 distinct hits screened), 39 manual GitHub searches (class, factory and constant names, symptom words), the changed files of all 411 open apache/lucene PRs18 items recorded, none the same bug
Mailing lists and JIRA archive2026-10-2624 queries over dev@, java-user@ and issues@; 9 JQL queries over LUCENE and SOLRno report
Web2026-10-266 queries10 hits, none relevant
GitHub issues and PRs (apache/lucene)2026-10-31PointInGeo3DShapeQuery; Geo3DPoint ramBytesUsed; geo3d ramBytesUsed; spatial3d Accountable; GeoShape ramBytesUsed; geo3d query cache memory; UNKNOWN_DEFAULT_RAM_BYTES_USED; and items updated since 2026-10-20 for three symptom phrases14 old geo3d items (test failures, integration history, #9053) and #16743, #16113, #9898; none about this bug (re-run by the independent fact check the same day, same hit counts)

History

Introduced in 8.2.0 by LUCENE-8855 (a76c962ee61, 2019), which made the query Accountable but sized its shape with the 256-byte fallback. The cache charges that value since 8.11.0 (LUCENE-10103). Unchanged through 10.6.0, branch_11x and main.

Versions tested5 rows

Introduced by a76c962ee61f5185d30ff5bd94a23c16d21a5a98 (main) and the branch_8x backport 81208427d25f4541957eeeb03d971e1c45226428, LUCENE-8855 "Add Accountable to some Query implementations" (Andrzej Bialecki, 2019-06-26; a JIRA patch with no pull request, now GitHub issue #9898). The only later change to the method is the LUCENE-9570 reformatting 2cbf261032d; no GeoShape class has ever been Accountable (git log -S Accountable on the geom package).

The cache consequence through this method starts in 8.11.0 with LUCENE-10103 (#346, 6a41bc6310e, Patrick Zhai, 2021-10-13), which made LRUQueryCache charge Accountable queries their ramBytesUsed(). From 8.2.0 to 8.10.1 the cache charged every query a flat 1,024 bytes plus its per-entry overhead, which under-counted large shapes for a different reason.

Default cache: 8.2.0 to 10.6.0 install a JVM-wide default cache; main and branch_11x disable caching by default.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and the standalone runner); passes with the fix
10.6.0 affectedminimal test fails, assertions on and off (release jars)
8.2.0, 8.10.1, 8.11.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 affectedramBytesUsed() = 592 for all four test shapes; the cache keeps 8 large keys within its count but far over its 1 MiB budget
8.1.1 API absentthe query is not Accountable; the cache's flat 1,024-byte charge under-counts for a different reason
upstream main 2f424fd428da, branch_11x 603af8703753, branch_10_6 7e5aec4370e0 readsame method

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier and an adjudication. Of read-only reviews from another model family (OpenAI GPT-5.6), two said not real on procedure only, since they could not run code; all agreed on the root cause. A credibility audit, the minimization and this doc's gradle run reproduced it again.

Checks and verdicts262 words

Every executed check agrees; the only dissents are procedural, from reviewers that could not execute anything.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (Accountable-query RAM differential, then JUnit) candidate9 Geo3DPoint constructions flagged (4.9x to 5,745x); its 2-test JUnit fails 2/2 on main (assertions on and off), 10.5.2 and 9.12.3.
reviews (OpenAI GPT-5.6, read-only) refute: not real (procedural, low); intended: real (medium)Could not run code; the one real objection (a 2,000-edge newPolygonQuery exceeds its documented limit) was fixed by switching to newLargePolygonQuery.
Independent reproducer realOwn 4-test class fails 2/4 on main, 10.5.2 and 9.12.3 (up to 3,448x; 16.3 MB cached under 1 MiB); a LongPoint.newSetQuery control is at 1.02x.
Refute lens (OpenAI GPT-5.6, read-only) not real (procedural, low)Root cause agrees, inputs in contract; a separate GC heap-delta check (no RamUsageTester) measured 1,369,074 bytes per 2,000-edge large polygon against 592 reported.
Intended-behaviour devil's advocate real (high)Re-ran on main with assertions on and off; no intended, documented or misuse argument survives.
Adjudication confirmed (high), class minorRe-ran the independent reproduction's tests on main; overruled the candidate's "silent wrong results": hits are correct.
Credibility audit keep, minorRe-ran the finder's tests on main with a fresh seed; the recorded numbers matched.
Minimal repro verifiedThe one-test class fails on 645d4fe14a20 with assertions on and off and two seeds.
This doc real; fix validatedGradle in a scratch worktree of 645d4fe14a20: stock fails, the fix passes the test and the spatial3d module; 10.6.0 jars fail.

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

Finding record

LFV-400, snapshot of 2026-11-06

Registry title
Geo3DPoint polygon and path queries report a constant 592-byte ramBytesUsed() regardless of shape size, because PointInGeo3DShapeQuery prices its GeoShape as a 256-byte unknown object, so LRUQueryCache can hold many times its maxRamBytesUsed (since 8.2.0)
Area
spatial3d / PointInGeo3DShapeQuery RAM accounting (Accountable#ramBytesUsed; core LRUQueryCache RAM budget)
Priority
Low, rank 731: A constant ramBytesUsed() for large Geo3D shape queries lets LRUQueryCache go far over its RAM budget, bounded only by maxSize, with correct hits.
Impact class
minor
Impact note
Applications that filter with Geo3DPoint polygon, large-polygon or path queries (the spatial3d module) and have a query cache are hit: the IndexSearcher default cache in 8.2-10.5.x (segments of at least 10,000 docs, after 2 uses of an equal query, because the default policy treats Point*Query classes as costly) or an application-installed cache on main, where caching is off by default; every such query is charged 592 bytes while a 2,000-edge newLargePolygonQuery retains about 1.36 MB and a 200-point newPathQuery about 290 KB, so maxRamBytesUsed no longer bounds the heap of the cached keys (only maxSize, 1,000 entries by default, does); hits are always correct, small polygons are off by only a few KB, and no extreme scale is needed (executed: 8 keys retain 10.9 MB under a 1 MiB budget).
Summary
You filter with a Geo3DPoint polygon or path query, for example newLargePolygonQuery with 2,000 edges or newPathQuery with 200 points, and expect its ramBytesUsed() to estimate the heap it holds, which is what Accountable promises and what LRUQueryCache charges against its maxRamBytesUsed budget. Instead PointInGeo3DShapeQuery.ramBytesUsed() passes the shape and its bounds to RamUsageEstimator.sizeOfObject, which prices every object that is not Accountable (no GeoShape is) at a fixed 256 bytes, so every such query reports 592 bytes whatever its size: 4.9x too low for an 8-edge polygon, 47x for 99 edges, about 2,300x for the 2,000-edge large polygon (1,360,960 bytes retained). An LRUQueryCache with a 1 MiB budget therefore keeps 8 such filters that retain 10.9 MB, accounts about 6 KB for them and evicts nothing; the hits are correct.
Introduced
a76c962ee61f5185d30ff5bd94a23c16d21a5a98 (main; branch_8x backport 81208427d25f4541957eeeb03d971e1c45226428), LUCENE-8855 'Add Accountable to some Query implementations' (GitHub issue apache/lucene#9898; a JIRA patch by GitHub sigram, reviewed on the issue by jpountz and atris; no pull request): it made PointInGeo3DShapeQuery Accountable with ramBytesUsed() = BASE_RAM_BYTES + sizeOfObject(field) + sizeOfObject(shape) + sizeOfObject(shapeBounds) [read: git log -L on the method lists only this commit and the LUCENE-9570 reformatting 2cbf261032d; git blame -w ignoring the reformatting attributes every line to it; git log -S Accountable on the file finds only LUCENE-8855] [exec: 8.1.1 has no Accountable on the class, the value test is skipped; 8.2.0 fails]. Not a regression of later work: no GeoShape class has ever implemented Accountable (2019-06-26)
First affected version
8.2.0 [exec] (8.1.1 and earlier: PointInGeo3DShapeQuery is not Accountable and has no ramBytesUsed() [exec: test skipped]). The cache-budget consequence through this method starts in 8.11.0 (LUCENE-10103, apache/lucene#346: LRUQueryCache charges Accountable queries their ramBytesUsed()); in 8.1.1-8.10.1 the cache charged a flat 1,024 bytes per query, which under-counted the same keys for a different reason [exec]
Affected versions
8.2.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2 and main ea57360912ed. [exec, all fail]: 8.2.0, 8.10.1, 8.11.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 and main (-ea and -da); ramBytesUsed() is 592 for every shape in every executed version. [read]: the other releases in those ranges (method body unchanged since 8.2.0); upstream main cf398bfb142 (2026-10-26) has the same method. Cache charge for 8 cached 2,000-edge keys [exec]: 9,168 bytes in 8.1.1-8.10.1 (flat 1,024 per key), 5,712 in 8.11.x, 5,904 in 9.0.0-10.5.2, 6,080 on main, against 10.9-11.0 MB retained. Not executed: 8.3.0-8.9.0, 9.2.0-9.11.1, 10.0.0-10.5.1, maintenance branches, x86
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
semi-formal
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, high confidence on absence of evidence; all read-only): scripted prior-report searches with 51 queries over apache/lucene issues, PRs and commits, the Elasticsearch, OpenSearch and Solr repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (1,329 and 1,155 distinct hits screened), plus 6 web queries; 39 manual GitHub searches; 24 mailing-list and 9 JIRA queries; the changed files of all 411 open apache/lucene PRs; the file's commit history; 18 issue, PR and JIRA reads; 18 items recorded, all same_bug false. Not fixed: upstream main cf398bfb142 (2026-10-26) has the same ramBytesUsed(), and the only open PR touching PointInGeo3DShapeQuery.java (#709) changes only a DocIdSetBuilder call. Closest items, not this bug: #9898 / LUCENE-8855 (introduced the code; stated goal: account queries 'much larger than the default estimate (10x or more)'; never mentions geo3d), #16113 / #16114 (open, Accountable ownership semantics, no geo3d), #16405 / #16406 (PointInSetQuery under-count, fixed: precedent), #15097 / #15124 (non-Accountable BooleanQuery cache overrun, fixed; the fix does not reach Accountable queries), #15026, #12614, #16657 and #16743 (other accounting paths). Gaps: GitHub search does not index PR diffs or review comments, the list API matches words rather than phrases, and private security@ reports are invisible
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, and so do the spatial3d module's tests. Release jars 10.6.0: the minimal test fails. Earlier checks used the finder's tests (ported where the API differs) on main ea57360912ed and on 9 releases (8.1.1, 8.2.0, 8.10.1, 8.11.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2), and the study's heap probe on main a9779ef3c19 and 10.5.2; the files involved are unchanged between those commits and 645d4fe14a20. Upstream main 2f424fd428da, branch_11x 603af8703753 and branch_10_6 7e5aec4370e0 carry the same method (read, 2026-10-30 refs).

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:44 (implements Accountable), :113-116 (isCacheable returns true), :160-166 (ramBytesUsed: sizeOfObject(shape) and sizeOfObject(shapeBounds)) @ ea57360912ed
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:81-86 (UNKNOWN_DEFAULT_RAM_BYTES_USED = 256, 'a few primitive fields and a couple short String-s'), :378-386 (sizeOf(Query, long) returns ramBytesUsed() for an Accountable), :388-395 (sizeOfObject(Object) uses the 256 default), :434-441 (default branch) @ ea57360912ed
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:62 ('remain under a given maximum size and number of bytes used'), :137-140 ('at most maxRamBytesUsed bytes of memory'), :465-471 (queryRamBytesUsed = RamUsageEstimator.sizeOf(query, 32)) @ ea57360912ed
  • lucene/core/src/java/org/apache/lucene/util/Accountable.java:34-40 ('Returns an estimate of the JVM heap memory used by this object in bytes') @ ea57360912ed
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:157-172 (newPolygonQuery: 'less than 100' edges), :174-189 (newLargePolygonQuery: 'optimized for very large numbers of polygon edges'), :191-203 (newPathQuery) @ ea57360912ed
  • lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:33-53 (isPointQuery 'Point*Query', isCostly) and lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 ('Caching is disabled by default', DEFAULT_QUERY_CACHE = null) @ ea57360912ed
  • SECURITY.md:28, :38, :44-46 @ ea57360912ed
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:44, (at 645d4fe14a20) :48-58, :113-116, :160-166
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:157-159, (at 645d4fe14a20) :168-172, :175-177, :186-190, :203-212, :241-243
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:82-86, (at 645d4fe14a20) :378-386, :393-395, :407-444 (:435-441)
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:465-471, (at 645d4fe14a20) :1037-1039, :1096-1124, :1214-1220; UsageTrackingQueryCachingPolicy.java:33-54, :122-125
  • a Lucene clone releases/lucene/10.6.0: lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:428-433 (read)
Who is affected
  • a Lucene clone releases/lucene/10.6.0: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:95-100, LRUQueryCache.java:175-177, :188-196 (read)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 (at 645d4fe14a20)
  • a Lucene clone: git diff a9779ef3c19 645d4fe14a20 for lucene/spatial3d, LRUQueryCache.java, RamUsageEstimator.java, UsageTrackingQueryCachingPolicy.java, IndexSearcher.java, Accountable.java: empty
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:150, (at 645d4fe14a20) :191-196 (16 partitions, maxSize / 16 entries each)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/Accountable.java:30, (at 645d4fe14a20) :34-40; RamUsageEstimator.java:82-86; lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:60-62, :117, :137-140
  • lucene/core/src/test/org/apache/lucene/search/TestPointQueries.java:2653-2669; (at 645d4fe14a20) TestTermInSetQuery.java:377-389; TestLRUQueryCache.java:702-726; lucene/spatial3d/src/test (grep ramBytesUsed: none)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:42, (at 645d4fe14a20) :113-116; Geo3DPoint.java:42
  • github apache/lucene#9898 (LUCENE-8855), read 2026-10-31 with gh issue view --comments: Andrzej Bialecki's 2019-06-19 patch note ('10x or more'; two more patch versions followed on 2019-06-24 and 2019-06-25), 'It's not a strict calculation anyway', Adrien Grand's comments of 2019-06-14 (Weight#isCacheable or a rough visitor-based estimate), 2019-06-18 (the visitor-plus-Accountable compromise), 2019-06-20 ('a better trade-off', 'better to overestimate')
Suggested fix
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPolygonFactory.java:352-362 (at 645d4fe14a20) (tiling fallback); lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:150, :191-196 (partitions)
  • a Lucene clone releases/lucene/10.6.0: LRUQueryCache.java:283-290 (one budget, no partitions); IndexSearcher.java:95-100 (32 MB cap)
  • github apache/lucene#9898 (LUCENE-8855): Adrien Grand's comments of 2019-06-14 (Weight#isCacheable or a visitor-based estimate), 2019-06-18 (the merged compromise) and 2019-06-20 ('better to overestimate') (read 2026-10-31)
Prior reports
  • gh search issues --repo apache/lucene --include-prs, read-only, 2026-10-31 (this doc)
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:314-357 (at 645d4fe14a20) (RamUsageQueryVisitor); LRUQueryCache.java:468-470
History
  • a Lucene clone refs of 2026-10-30: origin/main, origin/branch_11x, origin/branch_10_6, releases/lucene/10.6.0 (PointInGeo3DShapeQuery.java:163-165 unchanged; IndexSearcher default cache)

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, the minimal repro and the diagnostic runs of this doc (reported and retained sizes; the cache numbers).

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.