All findings · Lucene finding · LFV-216 · expression-valuesource-equals-ignores-function-map

ExpressionValueSource equals/hashCode ignore the compiled functions, so the same expression text compiled with different custom functions shares query-cache entries

Compile f(x) once with f = Math.abs and once with f = negate: the two value sources compare equal, so a query cache serves the abs query's 5 matches for the negate query, which should match 3. Since 7.3.0; only applications compiling one text with different functions are hit.

Summary

Priority
Low, rank 854ExpressionValueSource equality ignores the compiled function map, so identical expression text compiled per tenant shares query-cache entries and returns another query's matches, silent but needing that per-tenant setup.
Impact
silent wrong results
Since
7.3.0 (LUCENE-8099, 2017)introduced 2017-12-18 · about 8 years
Affected
7.3.0 to 10.6.0 and main
Status
documented
Found by
code reading of ExpressionValueSource.equals/hashCode

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

ExpressionValueSource decides equality from the expression's text, its variables and needsScores. The functions the text was compiled with are not part of it, so f(x) meaning abs(x) and f(x) meaning -x are "equal", and a query cache serves one's matches for the other.

Background in 3 steps

  1. JavascriptCompiler.compile(text, functions) compiles an expression such as f(x) into a Java class; the map decides what each function name means, for example f = Math.abs. compile(text) uses the built-in functions.
  2. expression.getDoubleValuesSource(bindings) gives one number per document; FunctionMatchQuery(source, predicate) keeps the documents whose number passes the predicate.
  3. A query cache stores a filter's matching documents per segment and finds them again by the query's equals and hashCode: a later query that is equal gets the stored matches.
Diagram of the repro. Five documents have x = -3, -2, -1, 1, 2. The expression text f(x) is compiled twice: with f = abs it matches all 5 documents for v > 0, with f = negate it should match the 3 documents with x < 0. ExpressionValueSource equals and hashCode compare only the text, so both FunctionMatchQuery objects map to one query-cache entry, and the negate query returns the cached 5 matches instead of 3.
The same text f(x), compiled once with f = abs and once with f = negate, gets one query-cache entry: the abs query's 5 matches are served for the negate query, which should match 3. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 5 documents with x = -3, -2, -1, 1, 2. Compile the same text f(x) twice with JavascriptCompiler.compile(text, functions): once with f = Math.abs, once with f = negate (-v). Wrap each in a FunctionMatchQuery with one shared predicate v > 0.
  2. You expect two different filters: abs(x) > 0 matches all 5 documents, -x > 0 matches the 3 with x < 0. Without a query cache, count gives 5 and 3.
  3. Give the searcher a query cache that caches every query (the repro installs one; on 7.3.0 to 10.6.0 the default cache can cache such filters too, on segments of 10,000+ documents that are large relative to the index, after about 5 uses). Count the abs query, then the negate query: you get 5, not 3, and no error.
  4. Why: ExpressionValueSource.equals and hashCode compare only the text, the bound variables and needsScores, never the functions the text was compiled with. The two queries are "equal", so the cache hands back the abs query's stored matches.

Who is affected

Only applications that compile the same expression text with different custom function maps or bindTo receivers (for example one per tenant), with equal variable bindings, and run the resulting filters through a query cache: on by default in 7.3.0 to 10.6.0 for large enough segments (10,000+ documents), opt-in on main. The default functions or one reused map are safe.

Mechanism, terms and numbers624 words

JavascriptCompiler.compile(String, Map<String, MethodHandle>) compiles an expression with custom functions, and its javadoc documents alternate function maps and MethodHandle#bindTo receivers. So the same text f(x) can mean abs(x) in one compilation and -x in another. ExpressionValueSource.equals and hashCode compare only the source text, the bound variables and needsScores; the compiled Expression keeps no record of its functions. The two value sources compare equal with equal hash codes, and so do the FunctionMatchQuery (or facet DoubleRange drill-down) queries built on them.

A query cache finds entries by query equality, so it serves one query's per-segment match set for the other: on 5 documents with x = -3, -2, -1, 1, 2, -x > 0 counts 5 instead of 3 after abs(x) > 0 was cached. On 7.3.0 to 10.6.0 the default IndexSearcher cache can do it (on segments of at least 10,000 documents that are large relative to the index); on main the application must install a cache.

LUCENE-8099 (2017, first released in 7.3.0) moved equality from the compiled object to the source text, which keeps recompiled identical text cacheable but is right only while one text always gets the same functions. Affected: 7.3.0 to 10.6.0 and main. Applications that compile with the default functions or one reused map are not exposed. Fix: record the functions an expression calls and compare them by identity on top of the text (validated).

The mechanism (main 645d4fe14a20)

  1. Custom functions are documented. "You can compile with an alternate set of functions via compile(String, Map)", and "you can use MethodHandle#bindTo(Object) to bind it to a receiver" (JavascriptCompiler.java:84-102, :173). compile(String) uses the static DEFAULT_FUNCTIONS map (:154-156, :866).
  2. The functions live only in the compiled class. Each called function gets an index (:471-479), and the hidden class receives the called MethodHandles as class data (defineHiddenClassWithClassData, :274-278). The Expression object keeps only sourceText and variables (Expression.java:71, :74, :82-85) and does not override equals or hashCode.
  3. Equality uses only the text. hashCode hashes expression.sourceText (ExpressionValueSource.java:140), equals compares expression.sourceText.equals(other.expression.sourceText) (:162), plus needsScores and the bound variable sources (:136-172). toString is expr(<text>) (:132-134), so both queries print as FunctionMatchQuery(expr(f(x))).
  4. Consumers inherit it. FunctionMatchQuery is equal when its source and its predicate are equal (FunctionMatchQuery.java:118-129), so the two queries collide when they share one predicate instance, or use distinct predicate objects whose equals returns true (lambdas have identity equality; the repro shares one positive variable). The facet DoubleRange.getQuery drill-down compares range, fast-match query and source (DoubleRange.java:118-137, :335-336) and needs no shared instance.
  5. The cache trusts equality. Query.equals is "required so that QueryCache works properly", and equal queries should have identical document-filtering properties (Query.java:96-107). LRUQueryCache keys entries per segment, so the wrong answer is always the other query's match set on the same segment.

Recording the called functions and comparing them by identity, and nothing else, makes the repro pass; in the history step, reverting only the two equality lines on 7.3.0 makes it pass too. So the equality is the cause.

The executed forms

form (study, main ea57360912ed unless stated)expectedactual
this doc's test: 5 documents, always-caching LRUQueryCache (main 645d4fe14a20, 10.6.0, 10.5.2)35
20,000-document segment, LRUQueryCache(1000, 32 MB) + UsageTrackingQueryCachingPolicy installed on main800016000
the same data with stock 10.5.2 IndexSearcher defaults, no cache settings800016000 (main: 8000, no default cache)
shift(x), one method bound to receivers t = 0 and t = 5 with bindTo; main with the 10.x cache set-up, and 10.5.2 defaults40009000
two facet DoubleRange drill-downs over the two sources (main, 10.5.2)24

Controls with different expression text pass.

Exposure and likelihood377 words
  • Silent. The second query returns the first query's match set, with no exception, and both print as FunctionMatchQuery(expr(f(x))).
  • Which applications. The optional expressions module; the same text compiled with different Map<String, MethodHandle> maps or with one method bound to different receivers (bindTo), for example per tenant or per request; equal variable bindings; and an equality-keyed consumer: FunctionMatchQuery with a shared predicate instance (or predicates that compare equal), or a facet DoubleRange drill-down. The class javadoc describes binding a function to a receiver with bindTo (JavascriptCompiler.java:99-102), and uschindler gave it as a "Small tip" on java-user@ (2025-05-07, in a thread that does not discuss caching). The compile(String, Map) javadoc says instead "Functions must be {@code public static}" (:162); the compiler accepts bound handles anyway (:904-925). The headline repro uses two public static methods and does not need bindTo.
  • Not exposed. Compiling with the default functions or one reused map: the same text then always gets the same functions. In the code paths checked (not plugins or older versions), Elasticsearch, OpenSearch and Solr compile expressions with the default functions. A handle bound to a receiver whose state changes is not covered by text equality, nor by fix A. Scores through LRUQueryCache are not affected: it caches only uses that need no scores (IndexSearcher.java:976-981), so a top-level scoring FunctionMatchQuery is never cached, while the same query in a FILTER clause or a count can be.
  • Cache reach by version. 7.3.0 to 10.6.0: every IndexSearcher starts with a JVM-wide LRUQueryCache(1000, min(32 MB, heap/20)) and UsageTrackingQueryCachingPolicy; it caches segments with at least 10,000 documents that are also large relative to the index (at least 3% of it up to 9.5, more than half the average segment size from 9.6.0), once a plain filter has been seen 5 times, if the value source is cacheable and the use needs no scores. Because the hash codes are equal, the two queries' uses add up. Executed with stock 10.5.2 defaults: 16000 instead of 8000. Main: "Caching is disabled by default." (GITHUB#14187, Lucene 11.0.0, unreleased), so the application must install a cache.
  • Likelihood: rare. A rarely used API shape, and the documented searches found no public report in the eight years since 7.3.0.

Minimal repro

The test shows that f(x) compiled with f = Math.abs and f(x) compiled with f = negate give equal ExpressionValueSources, so through a query cache the second FunctionMatchQuery(..., v > 0) gets the first one's cached matches.

TestExpressionFunctionsQueryCacheKey.java (JUnit test)91 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.expressions;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Map;
import java.util.function.DoublePredicate;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.expressions.js.JavascriptCompiler;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.queries.function.FunctionMatchQuery;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.LRUQueryCache;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.QueryCachingPolicy;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestExpressionFunctionsQueryCacheKey extends LuceneTestCase {

  public static double negate(double v) {
    return -v;
  }

  public void testSameTextDifferentFunctionThroughQueryCache() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (long x : new long[] {-3, -2, -1, 1, 2}) {
          Document doc = new Document();
          doc.add(new NumericDocValuesField("x", x));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        MethodType type = MethodType.methodType(double.class, double.class);
        MethodHandle abs = MethodHandles.publicLookup().findStatic(Math.class, "abs", type);
        MethodHandle negate =
            MethodHandles.publicLookup()
                .findStatic(TestExpressionFunctionsQueryCacheKey.class, "negate", type);
        SimpleBindings bindings = new SimpleBindings();
        bindings.add("x", DoubleValuesSource.fromLongField("x"));
        DoublePredicate positive = v -> v > 0;
        Query absQuery =
            new FunctionMatchQuery(
                JavascriptCompiler.compile("f(x)", Map.of("f", abs)).getDoubleValuesSource(bindings),
                positive);
        Query negateQuery =
            new FunctionMatchQuery(
                JavascriptCompiler.compile("f(x)", Map.of("f", negate))
                    .getDoubleValuesSource(bindings),
                positive);
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(new LRUQueryCache(100, 1 << 20, _ -> true, Float.POSITIVE_INFINITY));
        searcher.setQueryCachingPolicy(
            new QueryCachingPolicy() {
              @Override
              public void onUse(Query query) {}

              @Override
              public boolean shouldCache(Query query) {
                return true;
              }
            });
        searcher.count(absQuery);
        // Expected: -x > 0 matches the 3 docs with x < 0; actual: the cache returns abs(x) > 0's 5 matches.
        assertEquals(3, searcher.count(negateQuery));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-216/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/expressions/build/libs/lucene-expressions-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.expressions.TestExpressionFunctionsQueryCacheKey
# the module(s) expressions also need their third-party runtime jars
Expected vs actual
Expected count = 3 for -x > 0 on x = -3, -2, -1, 1, 2; actual: 5, the abs(x) > 0 result (expected:<3> but was:<5>), with -ea and -da.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<3> but was:<5>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.expressions.TestExpressionFunctionsQueryCacheKey.testSameTextDifferentFunctionThroughQueryCache(TestExpressionFunctionsQueryCacheKey.java:87)

Why it looks unintended

  • Query.equals exists "so that QueryCache works properly" (Query.java:96-107); these queries filter 5 and 3 documents.
  • Custom function maps and bindTo are documented features; nothing says equality ignores them, and no test compares two compilations.
  • Before 7.3.0 two compilations never compared equal; the same mistake with variable bindings (LUCENE-5220, 2013) was fixed as a bug.
The full argument243 words
  • The query contract. Query.equals exists "so that QueryCache works properly"; "Typically a query will be equal to another only if it's an instance of the same class and its document-filtering properties are identical to those of the other instance" (Query.java:96-107).
  • Value-source equality is meant to track values. In LUCENE-7736 (#8787) jpountz wrote "I think it's important for equals to behave correctly and never return true for values sources that could return different values". DoubleValuesSource.equals/hashCode are abstract (DoubleValuesSource.java:126-129, LUCENE-7723 / #8774).
  • The introducing change chose expressions for their trustworthy equality. In LUCENE-8099 (#9147) romseygeek wrote that a closure-based source "messed up equality tests (essentially, you can't check Java closures for equality). I think expressions are a good solution? They allow for hashcode/equals tests". A function bound with bindTo is such a closure.
  • Precedent. LUCENE-5220 (#6284, rmuir, 2013): ExpressionSortField "will bogusly report equals(true) if the expression is the same: but bindings could be different!". That was variable bindings in a class deleted in 2017; this is function bindings.
  • History. Up to 7.2.1, equals compared the compiled Expression objects, which have identity equality. LUCENE-8099 (b01e6023e1cd) switched both lines to sourceText; its commit message, CHANGES entry and issue do not mention the change or function maps. Reading it as "keep recompiled identical text cacheable" is an inference.
  • No documentation, no test. No javadoc says equality ignores the functions; TestExpressionValueSource.testDoubleValuesSourceEquals compiles once and reuses one Expression (TestExpressionValueSource.java:100-132).
The devil's-advocate check126 words

The strongest case for "intended" (the cross-model review and the intended-behaviour check): text equality was chosen on purpose, so that recompiling the same text stays equal and cacheable; Expression and JavascriptCompiler are @lucene.experimental (Expression.java:66, JavascriptCompiler.java:111); DoubleValuesSource.equals has no detailed contract; and main has no default query cache.

Why it fails: that purpose covers recompiling text with the same functions, and the recommended fix keeps it; different function maps and bindTo receivers are documented features nobody discussed; @lucene.experimental warns about API changes, not wrong results; and the one stated contract, query equality for the query cache, is broken, with default searcher settings on 7.3.0 to 10.6.0. Verdict: not intended, a defect (all verifiers, the refute review and the adjudication; the adjudication with high confidence).

Suggested fix

Recommended A: record the called functions and compare them by identity

Record the functions an expression calls in the compiled Expression and compare them by identity on top of the text (3 files, +35/-7). Recompiling the same text with the default functions or one reused map stays equal; different functions or bindTo receivers become unequal. The repro and all 170 upstream expressions tests pass, as do the module's lint checks.

// Expression.java
final List<MethodHandle> functions;
protected Expression(String sourceText, String[] variables, List<MethodHandle> functions) {
  ...  this.functions = List.copyOf(functions);
}
// JavascriptCompiler.java: the generated constructor passes the called MethodHandles on
// ExpressionValueSource.java
result = prime * result + ((expression == null) ? 0 : expression.functions.hashCode());
...
} else if (!expression.functions.equals(other.expression.functions)) {
  // MethodHandle has identity equality: the same text compiled with other functions differs
  return false;
}
All options and trade-offs3 options ยท 554 words

Two fixes were built and validated through gradle on 645d4fe14a20 (scratch worktree, OpenJDK 25.0.3, seed DEADBEEF, 2026-10-31), both with the test-only build.gradle line the regression test needs. Workarounds until a fix ships: give each function variant its own name, pass per-tenant values as variable bindings (which equality compares) instead of bindTo receivers, or do not cache such queries.

A: record the called functions and compare them by identity (validated by the checks) (recommended)

Expression gets a package-private final List<MethodHandle> functions and a new protected constructor Expression(String, String[], List<MethodHandle>) that stores List.copyOf(functions); the existing 2-argument constructor passes List.of(), so hand-written subclasses behave as today. JavascriptCompiler's generated constructor takes the list of called MethodHandles it already hands to defineHiddenClassWithClassData and passes it on. ExpressionValueSource compares and hashes the list. MethodHandle does not override equals/hashCode, so the handles are compared by identity; DEFAULT_FUNCTIONS and a reused map hold the same instances across compilations.

// Expression.java
final List<MethodHandle> functions;
protected Expression(String sourceText, String[] variables, List<MethodHandle> functions) {
  ...  this.functions = List.copyOf(functions);
}
// JavascriptCompiler.java: the generated constructor passes the called MethodHandles on
// ExpressionValueSource.java
result = prime * result + ((expression == null) ? 0 : expression.functions.hashCode());
...
} else if (!expression.functions.equals(other.expression.functions)) {
  // MethodHandle has identity equality: the same text compiled with other functions differs
  return false;
}

Trade-offs

One API addition: a protected constructor on the public @lucene.experimental Expression class. Two conservative cases become unequal: the same receiver bound twice with bindTo, and the same static method looked up afresh with findStatic for each compilation. They cost cache hits, never correctness; they were observed on JDK 25 (distinct handles from two lookups or two bindTo calls are not an API guarantee). Applications that build handles per request lose cache reuse; how common that is is unknown. Default functions, one reused map, and one Expression with two value sources stay equal (executed scratch check; on stock all 7 cases compare equal).

What the checks validated

  • The repro fails on stock and passes with the fix, assertions on and off.
  • The whole :lucene:expressions:test: 14 suites, 171 tests (170 upstream + the repro), 0 failures.
  • :lucene:queries:test --tests 'org.apache.lucene.queries.function.*' (includes TestFunctionScoreQuery, which uses expressions): 111 tests, 0 failures.
  • :lucene:expressions:check -x test (google-java-format, ecj lint, forbidden APIs) passes after tidy of the test file; renderJavadoc, and the compile of benchmark-jmh (which subclasses Expression) and demo, pass.

B: compare the compiled Expression by identity (the pre-7.3.0 behaviour) (validated by the checks)

In ExpressionValueSource.equals, replace the sourceText comparison with expression != other.expression (1 line; hashCode can stay text-based). The finder's sketch is a variant that keeps text equality for hand-written subclasses.

Trade-offs

Every recompilation is unequal, even of the same text with the default functions, which undoes what LUCENE-8099 apparently wanted: fewer cache hits for applications that recompile per request. No API change.

What the checks validated

  • The repro passes, assertions on and off; the whole :lucene:expressions:test passes (172 tests, including the repro and the scratch check). In the study, the finder's variant passed all 170 expressions tests.

C: document it (not recommended) (not run)

State in the JavascriptCompiler and Expression javadoc that value-source equality uses only the text and the variables.

Trade-offs

No behaviour change: cached results stay wrong for the exposed applications.

What the checks validated

Not built.

Downstream note

Not a Lucene security issue (the functions are application code), but an application that compiles one access-filter text per tenant with tenant-specific functions behind a shared query cache can serve one tenant's matches to another, as an executed probe showed.

How it was found

Code reading: while reviewing the expressions compiler, an AI tool compared what ExpressionValueSource.equals and hashCode look at with what decides an expression's values, and found the compiled functions missing. It then wrote a public-API JUnit with an always-caching query cache. No model checker or solver was involved.

The method in full106 words

Method: non-formal (a hunt unit, expressions compiler).

  • During the unit's secondary contract review of ExpressionValueSource.equals/hashCode, the finder, an AI tool, noticed that equality uses the source text while the values depend on the function map the text was compiled with.
  • It wrote a deterministic public-API JUnit on the stock main jars (5 documents, an always-caching LRUQueryCache), which fails with assertions on and off, and a fix sketch that makes it pass.
  • Independent verifiers then wrote their own tests from the claim alone (a 20,000-document cache set-up, the bindTo form, facet drill-downs). The one-test repro was cut from the finder's test on 2026-10-29.

Prior reports, history and checks

Prior reports

No prior report: 179 logged search entries on 2026-10-26 and 151 more on 2026-10-31 (GitHub issues, PRs and review threads, open PRs' files, commits, mailing lists, JIRA, downstream repositories, web). Closest: LUCENE-5220 (2013), the same mistake with variable bindings in a since-deleted class, fixed as a bug.

Searches4 searches

Verdict new: no hit is this bug; tracking issue #16937 links to the public list but does not name LFV-216. Related, not reports of this bug: #6284 / LUCENE-5220 (the 2013 sibling), #9147 / LUCENE-8099 (the introducing change; it discusses value-source equality in general but not the ExpressionValueSource equality change or function maps), #8787 / LUCENE-7736 and #8774 / LUCENE-7723 (the equality rule for value sources), #10413 / LUCENE-9373 (dsmiley: FunctionMatchQuery includes its predicate in equality, which is why the trigger needs a shared predicate), #9065 / LUCENE-8017 (FunctionMatchQuery cache pollution with a different cause), #16880 (open, about removing the unused CachingExpressionValueSource). Gaps: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, review threads and commits2026-10-26the scripted prior-report search (39 issue/PR, 6 commit and 4 downstream queries), 16 manual searches, 21 threads read through the API (search does not index PR review comments), 23 searches by the finder, and the verifiers' own searchesonly related items (#6284, #9147, #8787, #8774, #9065, #8933)
Open PRs2026-10-26 and 2026-10-31the changed files of all 413, then all 410, open apache/lucene PRsnone touches ExpressionValueSource.java, Expression.java or JavascriptCompiler.java
Lists, JIRA, web2026-10-2653 queries over dev@, java-user@, issues@ and commits@; 8 JQL queries and 13 direct JIRA reads; 10 web queriesno report
Re-search2026-10-31151 fresh queries and reads: 111 scripted prior-report search requests, 27 manual searches, 9 web searches, all 122 issues and PRs updated since 2026-10-25one new unrelated hit, #16880; no maintainer or outside comment on LFV-216

History

Introduced by LUCENE-8099 (2017, first released in 7.3.0), which moved equality from the compiled object to the source text. 7.2.1 passes and 7.3.0 fails; reverting only the two equality lines on 7.3.0 makes it pass. Executed up to 10.6.0 and main; not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-8099 "Deprecate CustomScoreQuery, BoostedQuery, BoostingQuery" (#9147, romseygeek; the patch was attached to the JIRA issue, no PR): master b01e6023e1cd, branch_7x a41643285e8e (the commit in 7.3.0), authored 2017-12-14, committed 2017-12-18. It changed hashCode from expression.hashCode() to expression.sourceText.hashCode() and equals from expression.equals(other.expression) to expression.sourceText.equals(other.expression.sourceText). Custom function maps exist since 4.6.0, but up to 7.2.1 two separate compilations never compared equal. Upstream main 2f424fd428da (2026-10-30), branch_10_6 and branch_11x still have the text-only lines; the last commit touching ExpressionValueSource.java is 43c0d72b94c (2023-09-16, javadoc).

Versions and branches executed, with results
Version or commitVerdictResult
6.6.6, 7.0.0, 7.2.1 before the changepass: separate compilations never compare equal; on 7.2.1 the cache holds 2 entries and the negate query counts 3
7.3.0 first affectedfails, 5 instead of 3 (1 cache entry, 1 hit), assertions on and off; passes with only the 2 equality lines reverted, and 7.2.1 fails with only them applied
7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedfail (the ported test or the study's 20,000-document probe); intermediate releases by reading only
10.5.2, 10.6.0 affectedthis doc's test fails (both with assertions on and off); stock 10.5.2 IndexSearcher defaults give 16000 instead of 8000
main 645d4fe14a20 affectedthis doc's test fails through gradle, assertions on and off; passes with fix A or B

Independent checks

Independent verifiers (one of them OpenAI GPT-5.6), a refute review by OpenAI GPT-5.6 and an adjudication all rated it real; the adjudication confirmed it with high confidence. The one-test repro still fails on main 645d4fe14a20, 10.6.0 and 10.5.2.

Checks and verdicts268 words

Before the finding was recorded, a refute review by OpenAI GPT-5.6 ran its own test (ceil vs floor bound to one name), agreed on the root cause and rated the intended-behaviour argument moderate. Its test was re-run on 645d4fe14a20 on 2026-10-31: it still fails, its different-text control passes, and it passes with the finder's sketch.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateCode reading; own JUnit fails both methods on main (assertions on and off), its main() form fails on 10.5.2; the fix sketch passes it and all 170 expressions tests.
Refute review (OpenAI GPT-5.6, executed) real (high)Own test with ceil vs floor under one name fails with assertions on and off; different-text control passes; intended argument moderate.
Independent reproducer realOwn JUnit fails 3 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; stock 10.5.2 defaults give 16000 instead of 8000; facet drill-downs 4 instead of 2.
Works-as-designed lens (OpenAI GPT-5.6, executed) real (high)Own tests fail on main (hunt re-run, assertions on and off): 10001 instead of 5001; intended argument moderate.
Devil's advocate real (moderate to high)bindTo form: 9000 instead of 4000 on main and on 10.5.2 defaults; its control shows the finder's sketch breaks recompile equality.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit on main (4 run, 3 failed, assertions on and off; control passes).
Repro minimization and current check verified; realThe one-test repro fails on 645d4fe14a20 (two seeds, assertions on and off), 10.5.2 and 10.6.0; with the finder's sketch shadowed it passes.

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

Finding record

LFV-216, snapshot of 2026-11-06

Registry title
Expressions: ExpressionValueSource.equals/hashCode ignore the functions an expression was compiled with, so the same expression text compiled with different custom functions compares equal and the query cache can return one query's matches for the other
Area
expressions / ExpressionValueSource equality (cache keys of FunctionMatchQuery and facet DoubleRange drill-down queries built on expressions)
Priority
Low, rank 854: ExpressionValueSource equality ignores the compiled function map, so identical expression text compiled per tenant shares query-cache entries and returns another query's matches, silent but needing that per-tenant setup.
Impact class
silent wrong results
Impact note
Applications using the optional expressions module that compile the same expression text with different custom function maps or MethodHandle#bindTo receivers (for example one per tenant or per request), with equal variable bindings, and use the value sources in an equality-keyed consumer (FunctionMatchQuery with a shared or non-capturing predicate, or facet DoubleRange drill-downs) behind a query cache silently get the other query's match set on a cached segment, with -ea and -da; on 7.3.0-10.5.2 and, by reading, 10.6.0 the default IndexSearcher cache is enough once a segment has at least 10000 docs and the first query has been used about 5 times, while main has no default query cache (GITHUB#14187, unreleased 11.0), so there the application must install one; scores through LRUQueryCache are not affected, and applications that compile with the default functions or one reused function map are not exposed.
Summary
You compile the same expression text 'f(x)' twice with custom function maps that bind f to different functions (Math.abs in one, negation in the other), wrap each in a FunctionMatchQuery with the same 'v > 0' predicate, and expect two different queries, because on documents with x = -3, -2, -1, 1, 2 the first matches 5 documents and the second 3. Instead the two value sources, and therefore the two queries, compare equal with equal hash codes: ExpressionValueSource.equals/hashCode look only at the source text, the bound variables and needsScores, not at the functions the text was compiled with. A query cache then answers the second query with the first query's cached matches, so count returns 5 instead of 3, with no error.
Introduced
b01e6023e1cd (b01e6023e1cd3c62260b38c05c8d145ba143a2ac on master; the same change is a41643285e8e on branch_7x, the commit contained in 7.3.0), LUCENE-8099 'Deprecate CustomScoreQuery, BoostedQuery, BoostingQuery' = apache/lucene#9147, by romseygeek, no PR (pre-GitHub-PR era, patch attached to the JIRA issue): it changed ExpressionValueSource.hashCode from expression.hashCode() to expression.sourceText.hashCode() and equals from expression.equals(other.expression) (object identity, since Expression has never overridden equals) to expression.sourceText.equals(other.expression.sourceText); neither the commit message, the CHANGES entry nor the issue mentions the equality change [read: diff, git blame (line 140 via --ignore-rev 7f8b7ffbcad2, the LUCENE-9570 reformat) and git log -L / -S in the history clone] [exec: shadow-class runs, reverting only the two equality lines on 7.3.0 passes and applying only them on 7.2.1 fails] (2017-12-18 (committed on master and branch_7x; author date 2017-12-14))
First affected version
7.3.0 [exec]; 7.2.1 is the last unaffected release [exec] (custom function maps exist since 4.6.0, but up to 7.2.1 two separate compilations never compared equal)
Affected versions
7.3.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2 (the last release before 10.6.0), 10.6.0 [exec: the one-test repro, which installs its own always-caching query cache, fails with -ea and -da] and main ea57360912ed. [exec] the follow-up's two-stage version probe (equality of 'f(x)' with f = Math.abs vs f = negate, then FunctionMatchQuery counts through LRUQueryCache(1000, 32 MB) with an always-cache policy on a 20000-doc segment) fails on 7.3.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-da; 7.3.0, 10.5.2 and main also -ea: equal, same hash, cached 20000 instead of 10000) and passes on 6.6.6, 7.0.0 and 7.2.1; the independent reproduction's JUnit fails on main, 10.5.2 and 9.12.3 (-ea and -da); with stock IndexSearcher defaults it reproduces on 10.5.2 (the independent reproduction, the cross-model review, the intended-behaviour check) but not on main, where the query cache is off by default. [read] the equality lines are present at tags 7.7.3, 8.0.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and git log -L shows no logic change after b01e6023e1c; intermediate releases (7.3.1-7.6.0, 8.1.0-8.11.3, 9.1.0-9.12.2, 10.1.0-10.5.1) were not executed; branch_10x and branch_10_6 carry the same code (finder read; the last commit touching ExpressionValueSource.java on main, branch_10x and branch_10_6 is 43c0d72b94c, 2023-09-16, javadoc only). Not fixed upstream.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, 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 (2026-10-26, follow-up prior-report record verdict new: 179 logged queries: a scripted prior-report search with 39 GitHub issue/PR, 6 commit, 4 downstream, 53 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA and 10 web queries; 16 manual gh searches; 13 direct JIRA reads; 21 GitHub threads read directly through the API, because GitHub search does not index PR review comments; the changed files of all 413 open apache/lucene PRs; plus the finder's 23 unit-wide gh searches (among them 'ExpressionValueSource equals', 'ExpressionValueSource hashCode' and 'expressions custom functions equals') and the intended-behaviour check's gh searches and 24 scripted prior-report search list and JIRA queries): nobody reports or discusses ExpressionValueSource equality ignoring the compiled functions or the resulting query-cache collision; not fixed: upstream main HEAD equals the tested ea57360912ed, the last commit touching ExpressionValueSource.java on main, branch_10x and branch_10_6 is 43c0d72b94c (2023-09-16, javadoc), and no open PR touches ExpressionValueSource.java, Expression.java or JavascriptCompiler.java; related, not reports of this bug: #9147 (LUCENE-8099, the introducing change, which never discusses the equality change or function maps; in it romseygeek preferred expressions because they 'allow for hashcode/equals tests' while 'you can't check Java closures for equality'), #6284 (LUCENE-5220, a 2013 sibling defect: ExpressionSortField compared equal despite different bindings, fixed then, class deleted in 2017; precedent that maintainers treat 'same text, different inputs, equal' as a bug), #8787 (LUCENE-7736: jpountz, equals must 'never return true for values sources that could return different values') and #8774 (LUCENE-7723: equals/hashCode made abstract on DoubleValuesSource), #9065 (LUCENE-8017: FunctionMatchQuery cache pollution with a different root cause), #8933 (LUCENE-7882: compiled-expression cache proposal, closed, no equality discussion); gaps: GitHub search is lexical, private reports to security@lucene.apache.org are not visible, and a final GitHub re-check was rate-limited
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle in a scratch worktree with assertions on and off (seed DEADBEEF), and on the standalone runner; the same test on the 10.6.0 and 10.5.2 release jars (fails). Fixes A and B were run through gradle on 645d4fe14a20 on 2026-10-31. The hunt, verifiers and judge ran on main ea57360912ed (the involved files are unchanged up to 645d4fe14a20); the history step ran 7.2.1, 7.3.0 (with and without the two equality lines), 8.0.0 and 10.0.0, and the study's probe 6.6.6 to 10.5.2. Upstream main 2f424fd428da (2026-10-30) still has the same code.

Lucene and upstream sources

Finding record
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:34 (package-private class), :136-144 (hashCode: expression.sourceText.hashCode() at :140, needsScores, Arrays.hashCode(variables)), :146-172 (equals: expression.sourceText.equals(other.expression.sourceText) at :162, needsScores, Arrays.equals(variables))
  • lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:66-85 (@lucene.experimental abstract class holding only sourceText and variables; no equals or hashCode override in the file)
  • lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:84-102 (class javadoc: alternate function maps via compile(String, Map), non-public methods, MethodHandle#bindTo receivers), :111 (@lucene.experimental), :154 (compile(String) uses the default functions), :173 (compile(String, Map<String, MethodHandle>)), :866 (DEFAULT_FUNCTIONS, a static map)
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:118-129 (equals/hashCode = source and filter)
  • lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (equals 'is required so that QueryCache works properly'; equal 'only if ... its document-filtering properties are identical')
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 ('Caching is disabled by default.', DEFAULT_QUERY_CACHE = null on main)
The finding in 30 seconds
  • lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:84-102, (at 645d4fe14a20) :154-156, :173, :274-278, :471-479, :866
  • lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:66-85 (at 645d4fe14a20)
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:132-172 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:76-78, (at 645d4fe14a20) :118-129; lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:118-137, :335-336
  • lucene/core/src/java/org/apache/lucene/search/Query.java:96-107 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; (at 645d4fe14a20) lucene/CHANGES.txt:343 (GITHUB#14187)
  • a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:83-99, LRUQueryCache.java:176, :180-198, UsageTrackingQueryCachingPolicy.java:122-137
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:96-107; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:126-129
  • lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionValueSource.java:100-132; (at 645d4fe14a20) Expression.java:66; js/JavascriptCompiler.java:84-102, :111
Suggested fix
  • lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:237 (at 645d4fe14a20) (Map.copyOf), :274-278, :866
Diagrams
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:136-172; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:118-129

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

Repro files

About the diagrams

A picture of the one-test repro and the equality check that lets both queries share one cache entry, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.