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.
JavascriptCompiler.compile(text, functions)compiles an expression such asf(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.expression.getDoubleValuesSource(bindings)gives one number per document;FunctionMatchQuery(source, predicate)keeps the documents whose number passes the predicate.- A query cache stores a filter's matching documents per segment and finds them again by the query's
equalsandhashCode: a later query that is equal gets the stored matches.
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.- Index 5 documents with x = -3, -2, -1, 1, 2. Compile the same text
f(x)twice withJavascriptCompiler.compile(text, functions): once with f =Math.abs, once with f = negate (-v). Wrap each in aFunctionMatchQuerywith one shared predicatev > 0. - You expect two different filters:
abs(x) > 0matches all 5 documents,-x > 0matches the 3 with x < 0. Without a query cache,countgives 5 and 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.
- Why:
ExpressionValueSource.equalsandhashCodecompare only the text, the bound variables andneedsScores, never the functions the text was compiled with. The two queries are "equal", so the cache hands back the abs query's stored matches.
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).
- Custom functions are documented. "You can compile with an alternate set of functions via
compile(String, Map)", and "you can useMethodHandle#bindTo(Object)to bind it to a receiver" (JavascriptCompiler.java:84-102,:173).compile(String)uses the staticDEFAULT_FUNCTIONSmap (:154-156,:866). - The functions live only in the compiled class. Each called function gets an index (
:471-479), and the hidden class receives the calledMethodHandles as class data (defineHiddenClassWithClassData,:274-278). TheExpressionobject keeps onlysourceTextandvariables(Expression.java:71,:74,:82-85) and does not overrideequalsorhashCode. - Equality uses only the text.
hashCodehashesexpression.sourceText(ExpressionValueSource.java:140),equalscomparesexpression.sourceText.equals(other.expression.sourceText)(:162), plusneedsScoresand the bound variable sources (:136-172).toStringisexpr(<text>)(:132-134), so both queries print asFunctionMatchQuery(expr(f(x))). - Consumers inherit it.
FunctionMatchQueryis 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 whoseequalsreturns true (lambdas have identity equality; the repro shares onepositivevariable). The facetDoubleRange.getQuerydrill-down compares range, fast-match query and source (DoubleRange.java:118-137,:335-336) and needs no shared instance. - The cache trusts equality.
Query.equalsis "required so thatQueryCacheworks properly", and equal queries should have identical document-filtering properties (Query.java:96-107).LRUQueryCachekeys 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.
form (study, main ea57360912ed unless stated) | expected | actual |
|---|---|---|
this doc's test: 5 documents, always-caching LRUQueryCache (main 645d4fe14a20, 10.6.0, 10.5.2) | 3 | 5 |
20,000-document segment, LRUQueryCache(1000, 32 MB) + UsageTrackingQueryCachingPolicy installed on main | 8000 | 16000 |
the same data with stock 10.5.2 IndexSearcher defaults, no cache settings | 8000 | 16000 (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 defaults | 4000 | 9000 |
two facet DoubleRange drill-downs over the two sources (main, 10.5.2) | 2 | 4 |
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:FunctionMatchQuerywith a shared predicate instance (or predicates that compare equal), or a facetDoubleRangedrill-down. The class javadoc describes binding a function to a receiver withbindTo(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). Thecompile(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 needbindTo. - 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
LRUQueryCacheare not affected: it caches only uses that need no scores (IndexSearcher.java:976-981), so a top-level scoringFunctionMatchQueryis never cached, while the same query in a FILTER clause or acountcan be. - Cache reach by version. 7.3.0 to 10.6.0: every
IndexSearcherstarts with a JVM-wideLRUQueryCache(1000, min(32 MB, heap/20))andUsageTrackingQueryCachingPolicy; 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.
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.
/*
* 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));
}
}
}
}
# 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-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)Query.equalsexists "so thatQueryCacheworks properly" (Query.java:96-107); these queries filter 5 and 3 documents.- Custom function maps and
bindToare 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.equalsexists "so thatQueryCacheworks 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/hashCodeare 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
bindTois 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,
equalscompared the compiledExpressionobjects, which have identity equality. LUCENE-8099 (b01e6023e1cd) switched both lines tosourceText; 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.testDoubleValuesSourceEqualscompiles once and reuses oneExpression(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).
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.
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.*'(includesTestFunctionScoreQuery, which uses expressions): 111 tests, 0 failures.:lucene:expressions:check -x test(google-java-format, ecj lint, forbidden APIs) passes aftertidyof the test file;renderJavadoc, and the compile ofbenchmark-jmh(which subclassesExpression) anddemo, pass.
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:testpasses (172 tests, including the repro and the scratch check). In the study, the finder's variant passed all 170 expressions tests.
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.
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.
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
bindToform, facet drill-downs). The one-test repro was cut from the finder's test on 2026-10-29.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, review threads and commits | 2026-10-26 | the 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 searches | only related items (#6284, #9147, #8787, #8774, #9065, #8933) |
| Open PRs | 2026-10-26 and 2026-10-31 | the changed files of all 413, then all 410, open apache/lucene PRs | none touches ExpressionValueSource.java, Expression.java or JavascriptCompiler.java |
| Lists, JIRA, web | 2026-10-26 | 53 queries over dev@, java-user@, issues@ and commits@; 8 JQL queries and 13 direct JIRA reads; 10 web queries | no report |
| Re-search | 2026-10-31 | 151 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-25 | one new unrelated hit, #16880; no maintainer or outside comment on LFV-216 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 6.6.6, 7.0.0, 7.2.1 | before the change | pass: separate compilations never compare equal; on 7.2.1 the cache holds 2 entries and the negate query counts 3 |
| 7.3.0 | first affected | fails, 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 | affected | fail (the ported test or the study's 20,000-document probe); intermediate releases by reading only |
| 10.5.2, 10.6.0 | affected | this doc's test fails (both with assertions on and off); stock 10.5.2 IndexSearcher defaults give 16000 instead of 8000 |
| main 645d4fe14a20 | affected | this doc's test fails through gradle, assertions on and off; passes with fix A or B |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Code 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 | real | Own 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; real | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 518 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,418 words
- 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 on645d4fe14a20on 2026-10-31. The hunt, verifiers and judge ran on mainea57360912ed(the involved files are unchanged up to645d4fe14a20); 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 main2f424fd428da(2026-10-30) still has the same code.
- 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, :866lucene/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-336lucene/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-129lucene/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
- TestExpressionFunctionsQueryCacheKey.java (JUnit test):
repro/LFV-216/src/org/apache/lucene/expressions/TestExpressionFunctionsQueryCacheKey.java(91 lines)
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.
assets/LFV-216/bug.png(518 KB)
