All findings · Lucene finding · LFV-905 · simplebindings-validate-cubic-on-binding-chains

SimpleBindings.validate() takes cubic time on a chain of expression bindings: 2,000 bindings take 31-48 s, against 99 ms on 8.5.2

If you chain named expressions with SimpleBindings (x1 = x0 + 1, x2 = x1 + 1, ...), validate() takes time that grows with the cube of the chain length since 8.6.0: 5.4 s for 1,000 bindings, against 45 ms on 8.5.2. The answer stays correct.

Summary

Priority
Low, rank 1031The cubic validate() cost on long SimpleBindings chains only slows things down, every answer stays correct, and it only reaches callers of the optional check, so it is Low.
Impact
minor
Since
8.6.0 (LUCENE-9338, 2020); 8.5.2 is the last good releaseintroduced 2020-04-24 · about 6 years
Affected
8.6.0 to 10.6.0, and main
Status
documented
Found by
testing: a timing probe while checking a cost model of validate()

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

Background: expressions and bindings

  1. A Lucene expression is a small formula such as x0 + 1, compiled with JavascriptCompiler and computed per document, for example to sort hits.
  2. SimpleBindings says what each variable name means: a field, the score, a constant, or another expression. So x2 can be defined as x1 + 1, and x1 as x0 + 1.
  3. validate() is an optional check you can run after adding bindings: it walks the graph of names and throws if a name is missing or the names form a cycle.
Diagram of the bug: a chain of expression bindings x0 <- x1 <- ... <- x(n-1), where each x_i is x(i-1) + 1. SimpleBindings.validate() starts a walk at every binding; at each step down the chain it copies the whole path so far into a new set, so the walk from x_k makes copies of 1, 2, ..., k names and the total grows as n^3: 1,000 bindings take 5.4 s on 8.6.0 and later, against 45 ms on 8.5.2. A depth-first search with one shared path and a record of checked bindings checks each binding once.
A chain of bindings: validate() walks down from every binding and, at every step, copies the path walked so far, so the copied names pile up as n x n x n. With one shared path, each binding is checked once. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Chain 1,000 bindings: x0 is the constant 1 and each x_i is the compiled formula x(i-1) + 1. Using them is cheap: resolving every binding once takes about 23 ms.
  2. Call bindings.validate(), the optional check for cycles and missing names. You expect about that cost again; on 8.5.2 it takes 45 ms.
  3. Instead it takes 5.4 s on 8.6.0 and 10.6.0 and 5.7 s on main; 2,000 bindings take 31-48 s. Each doubling of the chain multiplies what it allocates by about 7.9 (on 8.5.2 by 4.0). The answer is still correct.
  4. Why: validate() starts a fresh walk from every binding, and at every step of a walk copies the whole path so far into a new set. That is n walks, up to n steps each, and a copy of up to n names per step: n^3.

Who is affected

Only code that chains hundreds of named expressions to each other with SimpleBindings.add(String, Expression) and then calls the optional validate(): about 0.1 s at 250 bindings, 0.6-1.3 s at 500, 4-6 s at 1,000, 31-48 s at 2,000. Searching with the same bindings stays fast, and Lucene itself never calls validate().

Mechanism, terms and numbers486 words

SimpleBindings binds expression variable names to value sources, including other expressions, and its optional validate() "Traverses the graph of bindings, checking there are no cycles or missing references". Since 8.6.0 (LUCENE-9338) validate() starts a new walk from every binding, and at every step CycleDetectionBindings copies the whole path so far into a new LinkedHashSet; nothing records which bindings were already checked. On a plain chain x0 = 1, x_i = x(i-1) + 1 that costs O(n^3): 1,000 bindings take 5.4 s on 8.6.0 and 10.6.0 and 5.7 s on main, against 45 ms on 8.5.2, and 2,000 take 31-48 s against 99 ms; the bytes allocated grow x7.6-7.9 per doubling (x4.0 on 8.5.2). The answers stay correct. Swapping only SimpleBindings.java across the introducing commit moves the slowdown both ways: 8.5.2 with the new file is slow, 8.6.0 with the old file is fast. A depth-first search with one shared path set and a record of checked bindings validates the 2,000-binding chain in 1.3 ms and passes all 171 tests of the expressions module. Low priority: validate() is optional, Lucene never calls it, and the chain must be hundreds of bindings long before it shows.

Mechanism (Lucene main 645d4fe14a20)

  • validate() (SimpleBindings.java:81-85) applies every binding with a fresh CycleDetectionBindings(origin).
  • An expression binding is stored as expression::getDoubleValuesSource (:64-66), which builds an ExpressionValueSource whose constructor resolves every variable through the given bindings at once (ExpressionValueSource.java:39-54, the call at :45).
  • CycleDetectionBindings.getDoubleValuesSource(name) checks the path for a cycle and the map for a missing name, then applies the referenced binding with new CycleDetectionBindings(seenFields, name) (:100-110, the call at :109). That constructor copies the whole path: seenFields.addAll(parents) (:95-98).
  • Nothing records which bindings were already checked.

Step by step, for the walk that starts at x_k

  1. validate() applies x_k with the set {x_k}.
  2. Building x_k resolves x(k-1): a new set copies 1 name and adds x(k-1).
  3. Building x(k-1) resolves x(k-2): another new set copies 2 names, and so on down to x0, where the last copy holds k names.
  4. So one walk builds k expression sources and inserts about k^2/2 names into fresh sets. Over all n start points that is about n^2/2 builds and n^3/6 insertions: about 1.3 billion insertions for n = 2,000 (derived from the code).

What changed in 8.6.0

8.5.2's validate() built every expression binding through the bindings themselves and turned a StackOverflowError into a cycle error: the same walk from every start point, but no path set, so about n^2/2 builds, the same work as resolving each binding once. LUCENE-9338 kept the walks and added the per-step copy. In the same walk, bindings that share sub-expressions are visited once per path, which is exponential in depth; that part is the same on every release tested since 5.5.5, so it is a secondary symptom, not part of the regression.

Exposure and likelihood183 words
  • Who. Code that chains named expressions to each other with SimpleBindings.add(String, Expression) and calls validate(). It shows from a few hundred bindings: about 0.1 s at 250, 0.6-1.3 s at 500, 4-6 s at 1,000 and 31-48 s at 2,000 on main (the spread is JIT flags and machine load).
  • Default or opt-in. validate() is optional. Nothing in Lucene calls it outside tests. No call was found in the Elasticsearch, OpenSearch and Solr code that uses SimpleBindings (GitHub code search).
  • Effect. CPU time and allocation at setup, before any search runs. The answer is correct and the call returns. Using the same bindings is not slowed by this bug: a sorted search by the top of a 4,001-binding chain (x0 to x4000) ran in 38 ms on main.
  • Likelihood: rare. It needs hundreds of chained expression bindings plus the optional call; the searches found no report of it since 8.6.0 (2020).
  • Workaround (by reading, not run): skip validate() for long chains, or check the name graph yourself; every Expression lists the names it uses in its public field variables.

Minimal repro

One JUnit test, public API only: a chain of 500 bindings. It times resolving each binding once, then validate(), in the same JVM, and asserts that validate() costs at most 10x as much. 8.5.2 passes; the bound is ours, since Lucene documents none.

TestSimpleBindingsValidateChainCost.java (one test class)49 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 org.apache.lucene.expressions.js.JavascriptCompiler;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSimpleBindingsValidateChainCost extends LuceneTestCase {

  public void testValidateOfChainCostsAboutAsMuchAsResolvingEachBinding() throws Exception {
    int n = 500;
    SimpleBindings bindings = new SimpleBindings();
    bindings.add("x0", DoubleValuesSource.constant(1));
    for (int i = 1; i < n; i++) {
      bindings.add("x" + i, JavascriptCompiler.compile("x" + (i - 1) + " + 1"));
    }
    long start = System.nanoTime();
    for (int i = 0; i < n; i++) {
      bindings.getDoubleValuesSource("x" + i);
    }
    long resolveEach = System.nanoTime() - start;
    start = System.nanoTime();
    bindings.validate();
    long validate = System.nanoTime() - start;
    // Expected: validate() within 10x of resolving each binding once (as on 8.5.2); actual ~100x.
    assertTrue(
        "validate() took "
            + validate / 1_000_000
            + " ms, resolving each binding once took "
            + resolveEach / 1_000_000
            + " ms",
        validate <= 10 * resolveEach);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:expressions:test --tests org.apache.lucene.expressions.TestSimpleBindingsValidateChainCost -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
validate() of the 500-binding chain costs at most 10x resolving each binding once, as on 8.5.2.
Observed
AssertionError: validate() took 1288 ms, resolving each binding once took 14 ms (1121 ms vs 11 ms with assertions off); with the fix the test passes.

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

More files, environment, full output404 words

One test class, one method, single thread, nothing random. The bound compares validate() with resolving each binding once through getDoubleValuesSource(name), the work 8.5.2's validate() did (itself quadratic on a chain, because each resolution builds every binding below it, so the bound is lenient), measured in the same JVM, so it does not depend on machine speed.

Timing caveat. A cost-only bug needs a timing oracle. Through gradle (Lucene's test JVM uses -XX:TieredStopAtLevel=1) stock main measured about 92x and 102x; across earlier stock runs the ratio was 12x-162x (the 12x run was on 10.6.0 at a load average of about 31 on 32 cores, and its re-run gave 50x). With the fix it is about 0.05x. A pass on a heavily loaded machine is not a refutation. The worst stock run (12x) was just above the bound, so the bound has little margin under heavy load; larger chains or another oracle were not tested. A deterministic allocation check exists but needs com.sun.management, which Lucene's forbidden-apis rejects, so it is evidence, not part of the test.

Lint: the test passes the expressions module's checkGoogleJavaFormat, ecjLintTest and forbiddenApisTest as is. Pass -Ptests.asserts explicitly, because Lucene's build picks it at random from the seed; the result is the same both ways.

Regression test: this same test. It fails on stock main and passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 9.0.0, 8.11.4, 8.6.0: fail; 8.5.2: passes; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestSimpleBindingsValidateChainCost.java $LUCENE/lucene/expressions/src/test/org/apache/lucene/expressions/
cd $LUCENE && ./gradlew :lucene:expressions:test --tests org.apache.lucene.expressions.TestSimpleBindingsValidateChainCost -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestSimpleBindingsValidateChainCost > testValidateOfChainCostsAboutAsMuchAsResolvingEachBinding FAILED
    java.lang.AssertionError: validate() took 1288 ms, resolving each binding once took 14 ms
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E9B1B9D029CD38AD]: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.expressions.TestSimpleBindingsValidateChainCost.testValidateOfChainCostsAboutAsMuchAsResolvingEachBinding(TestSimpleBindingsValidateChainCost.java:41)
1 test completed, 1 failed

# -Ptests.asserts=false
    java.lang.AssertionError: validate() took 1121 ms, resolving each binding once took 11 ms

# with the recommended fix: 1 test completed, 0 failures, both ways

# chain timing probe, main 645d4fe14a20, -da, default JIT: validate() / resolve each once
n=250   stock 96.4 ms / 7.9 ms       fix 2.0 ms / 6.5 ms
n=500   stock 606.5 ms / 5.2 ms      fix 0.8 ms / 7.7 ms
n=1000  stock 3915.9 ms / 22.7 ms    fix 0.5 ms / 32.6 ms
n=2000  stock 30831.5 ms / 127.5 ms  fix 1.3 ms / 146.8 ms

Why it looks unintended

  • The javadoc promises a traversal for cycles and missing references; expression-to-expression bindings are a documented use, with no cost or size limit.
  • 8.5.2 ran the same check at the cost of resolving each binding once; LUCENE-9338's review never discussed its cost.
  • Copying adds nothing to the cycle check: one shared path finds the same cycles, 2,000 bindings in 1.3 ms.
The full argument247 words
  • The contract. validate() "Traverses the graph of bindings, checking there are no cycles or missing references" (SimpleBindings.java:76-80). Expression-to-expression bindings are the documented use: the class javadoc example binds "another expression" (:38-39), and add(String, Expression) "can be used to reference expressions from other expressions" (:62). No cost, size or depth limit is documented in the javadoc, package docs, CHANGES or MIGRATE.
  • Tests call it routinely. All 8 tests of TestExpressionValidation call validate(); testValidExternals calls it after each expression it adds, three times. The only documented limit, JavascriptCompiler.DEFAULT_MAX_NESTING_DEPTH = 1024, bounds the nesting inside one expression; each binding here is a one-variable expression.
  • History. LUCENE-9338 is recorded as "Refactors SimpleBindings to improve type safety and cycle detection" (8.6.0 CHANGES). In its review (apache/lucene-solr#1444), jpountz noted that catching stack overflows "is usually a bad idea", then posted a test with a cycle that does not pass through the start, which defeated the PR's first version (8.5.2 rejected such a cycle only by catching the StackOverflowError). The author (romseygeek) "updated the cycle detection logic to handle multiple levels of recursion" in PR commit f31cb0601615, which added the copying constructor. Nobody discussed what validate() costs.
  • The copy buys nothing for the cycle check. One shared path set catches the same cycles: with the recommended fix, a bounded-exhaustive check gave the same accept or reject answer as the stock jar on all 730,272 enumerated graphs, and all 171 expressions tests pass.
The devil's-advocate check174 words

The strongest case for "not a bug": no complexity bound is documented; validate() is optional, @lucene.experimental and has no in-tree caller; the results are correct; 8.5.2 was already quadratic and found cycles only by catching StackOverflowError, which LUCENE-9338 replaced after review; in that review the author said "it's part of query setup, not query execution"; and the cross-model refute review (OpenAI GPT-5.6) said "not real", arguing that maintainers "could regard this as an accepted simplicity-versus-setup-cost trade-off".

Why it fails: the "query setup" reply answered madrob's nit about containsKey versus get, a constant factor, not a change from n^2 to n^3. No source shows a trade-off was weighed, and the copy buys nothing for the cycle check, since the fix keeps LUCENE-9338's path-set check and its answers without the copy. The cross-model reviewer could not run anything; its "not real" is procedural, and on reading it agreed with every point of the mechanism. The remaining points (optional, experimental, correct results) argue for Low priority, which is how it is classed, not for intent.

Suggested fix

Recommended One depth-first search with a shared path and a record of checked bindings

Check the graph with one depth-first search: a single path set that names are added to and removed from, instead of a copy at every step, plus a map of bindings already checked, so each binding is applied once. All 171 expressions tests pass, the repro included; a 2,000-binding chain validates in 1.3 ms instead of 31 s.

DoubleValuesSource check(String name) {
  DoubleValuesSource done = verified.get(name);
  if (done != null) {
    return done;
  }
  path.add(name);
  try {
    DoubleValuesSource source = map.get(name).apply(this);
    verified.put(name, source);
    return source;
  } finally {
    path.remove(name);
  }
}
All options and trade-offs3 options · 527 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; a smaller variant that only stops the copying passes the same tests but stays quadratic; going back to 8.5.2's check is not recommended.

One depth-first search with a shared path and a record of checked bindings (validated by the checks) (recommended)

SimpleBindings.java +28/-12. validate() creates one CycleDetectionBindings holding a shared LinkedHashSet path and a HashMap of checked bindings, and calls check(name) for every name. getDoubleValuesSource(name) keeps its cycle and missing-name checks and then calls check(name) instead of building a new CycleDetectionBindings. The heart of it:

DoubleValuesSource check(String name) {
  DoubleValuesSource done = verified.get(name);
  if (done != null) {
    return done;
  }
  path.add(name);
  try {
    DoubleValuesSource source = map.get(name).apply(this);
    verified.put(name, source);
    return source;
  } finally {
    path.remove(name);
  }
}

Trade-offs

  • Each binding is applied once: the chain becomes linear (1.3 ms at 2,000 bindings), and the exponential cost on shared sub-expressions goes too (48 builds instead of 786,308 for 49 bindings).
  • Same exception types and message format; a bounded-exhaustive check gave the same accept or reject answers as the stock jar on 730,272 graphs.
  • Cost: one DoubleValuesSource per binding, with its array of variable sources (O(bindings + references) in all), is held until validate() returns; the walk still recurses as deep as the chain, like the current code.
  • Scope (by reading, not run): Expression.getDoubleValuesSource(Bindings) is public and not final, so a custom Expression subclass that overrides it would be called fewer times and would get one shared, mutable Bindings object instead of a fresh one per step; the exhaustive check covers accept or reject answers, not such side effects. A binding that resolves to null is not recorded and is applied again when referenced.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off.
  • With the fix: the repro passes both ways; the whole :lucene:expressions:test (14 suites, 171 tests, including TestExpressionValidation with its cycle and missing-reference tests) passes with 0 failures with assertions on and off; the module's checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.
  • The tie-breaker and the current-main reproducer also passed the repro with it through the standalone runner, with assertions on and off.

Keep one walk per binding, but share the path instead of copying it (validated by the checks)

SimpleBindings.java +12/-10: one LinkedHashSet per start point; getDoubleValuesSource adds the name, applies the binding with this, and removes the name in a finally.

Trade-offs

Smaller diff; back to 8.5.2's O(n^2) on a chain: 434 ms at 2,000 bindings, against 31 s stock and 1.3 ms with the recommended fix. Keeps the exponential cost on shared sub-expressions (by reading).

What the checks validated

The whole :lucene:expressions:test, the repro included, with assertions off: 171 tests, 0 failures. Not run with assertions on.

Go back to 8.5.2's check (not recommended) (not run)

8.5.2 built every binding and turned a StackOverflowError into a cycle error.

Trade-offs

Quadratic on a chain, but jpountz's review point that catching stack overflows "is usually a bad idea" is why LUCENE-9338 replaced it.

What the checks validated

Not run.

Downstream note

Elasticsearch, OpenSearch and Solr use SimpleBindings in their expression support but never call validate() (code search, 2026-10-28), so their expression scripts do not pay this cost.

How it was found

An idea from a cross-model ideation round (OpenAI GPT-5.6) led to a probe that found validate() slow on bindings that share sub-expressions. While checking an exact cost model of validate(), a timing side probe on plain chains showed the cubic growth, and runs on old releases dated it to 8.6.0.

The method in full171 words

Method: testing (the hunt's non-formal tier: the chain result came from a timing probe, not from the model).

  • Lead. An idea from an independent model from another family (OpenAI GPT-5.6) in a cross-model ideation round: expression bindings that share sub-expressions may be expanded once per path. Its probe timed validate() at 20.0-20.4 s for 61 such bindings on main.
  • Cost model. The finder wrote an exact cost model of validate() (expression builds = the sum over expression start points of the number of paths) and checked it against the stock jar by bounded-exhaustive enumeration: 730,272 graphs, 79,987,206 checks, 0 mismatches; a linear-traversal mutant of the model is rejected.
  • Chain. While bounding that model, a wall-clock side probe on plain chains showed the cubic cost, and the same probe on 8.5.2 and 8.6.0 dated it.
  • Then an independent reproducer, cross-model reviews (which could not run code), a devil's-advocate check, an adjudication, the registration check, an audit, a repro-minimization step, a current-main reproducer, a tie-breaker, the version-history runs and a prior-report search followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28 and 2026-10-31: 87 tool queries over GitHub, the JIRA archive, mailing lists, downstream trackers and the web, 35 manual GitHub searches and the files of all 410 open PRs). The closest item, a 2020 review remark about caching names that occur several times, concerns the secondary symptom.

Searches2 searches

Verdict new for the plain-chain regression: the searches found no issue, PR, commit, JIRA entry, list post or web page that reports the cost of validate() or the per-step copy. Closest, not this bug: msokolov's 2020 comment on the validate() line of apache/lucene-solr#1444 ("nice to know if the same name occurs multiple times in the dependency tree; maybe one should cache its value"), deferred by the author "for a follow-up" that was never filed; #9620 (LUCENE-8574): per-hit cost of shared bindings at search time, and #16880 (open), which asks whether to remove the unused CachingExpressionValueSource that upstream testFibonacciExpr needs (both relate to the separate finding LFV-899); #16423 (merged 2026-07-27): expression complexity guards at compile time only. The only open PRs under lucene/expressions, #16847 and #681, do not touch SimpleBindings.java.

The shared-sub-expression cost on its own is not new: that symptom predates 8.6.0 and overlaps msokolov's remark and the note in upstream testFibonacciExpr.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, the web2026-10-28two scripted prior-report search runs (75 queries), 28 manual GitHub searches, GitHub code search, direct reads of apache/lucene-solr#1444 and of #10378, #9620, #16880, #6295, #6271, #9002, #10380; the changed files of all 410 open PRsno report; partial overlap for the secondary symptom only (#1444 review remark)
the same sources, plus every dev@, java-user@ and issues@ subject mentioning SimpleBindings2026-10-31scripted prior-report search (87 queries), 35 manual GitHub searches and 10 PR-list searches, the changed files of all 410 open PRs, all 76 items updated since 2026-10-28, code search for CycleDetectionBindings and seenFields.addAll(parents)no report; nothing new since 2026-10-28

History

Introduced by LUCENE-9338 (ed3caab2d86b, 2020-04-24), first released in 8.6.0. 8.5.2 passes the repro; 8.6.0 to 10.6.0 and main fail, and swapping only SimpleBindings.java between 8.5.2 and 8.6.0 moves the slowdown both ways. Not fixed upstream.

Versions tested9 rows

Introduced by ed3caab2d86b "LUCENE-9338: Clean up type safety in SimpleBindings (#1444)" (romseygeek, 2020-04-24), the squash merge of apache/lucene-solr#1444 (issue now #10378); the branch_8x backport b66b970d1b06, with the same SimpleBindings.java, shipped first, in 8.6.0. The copy itself came in PR commit f31cb0601615 "Detect recursion at more than one level" (2020-04-23). Every logic line of validate() and CycleDetectionBindings on main still comes from ed3caab2d86b. A bytecode check of all 122 lucene-expressions releases finds the old validate() in the 72 releases 4.6.0-8.5.2 and the copying CycleDetectionBindings in the 50 releases 8.6.0-10.6.0. Not fixed upstream: upstream main 2f424fd428da (2026-10-30) has the same file, last changed by #15573 (javadoc only).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle: 1288 ms vs 14 ms (-ea), 1121 ms vs 11 ms (-da); 1,000 bindings 5,664 ms
10.6.0, 10.5.2 affectedminimal test fails with -ea and -da; 10.6.0: 1,000 bindings 5,412 ms, the same bytes as 8.6.0
9.0.0, 8.11.4 affectedminimal test fails: 873 ms vs 13 ms; 880 ms vs 15 ms
8.6.0 affectedminimal test fails (899 ms vs 14 ms); 1,000 bindings 5,443 ms; bytes grow x7.6-7.9 per doubling
8.5.2 not affectedminimal test passes; 1,000 bindings 45 ms; bytes grow x4.0 per doubling
8.5.2 jars + ed3caab2d86b's SimpleBindings.java A/Bfails: 928 ms vs 14 ms
8.6.0 jars + its parent's SimpleBindings.java A/Bpasses
4.6.0-8.5.2 (72 releases) bytecodethe old validate(), quadratic on a chain
upstream main 2f424fd428da readthe same SimpleBindings.java as 645d4fe14a20

Independent checks

Reproduced by the finder, an independent reproducer, a devil's-advocate check, the adjudication, an audit, a current-main reproducer, a tie-breaker with a deterministic allocation probe, and this doc's gradle runs. The cross-model reviews (OpenAI GPT-5.6) could not run code; one gave a procedural "not real".

Checks and verdicts382 words

Every reproduction ran on stock builds; the fix checks ran with the patched SimpleBindings. The cross-model reviewers executed nothing themselves; a separate run of the repro on 10.5.2 with assertions off failed as claimed.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (cost-model unit) candidatePublic-API JUnit fails 2 of 2 on main, branch_10_6, 10.5.2 and 9.12.3 (2,000-binding chain 41-45 s); exact cost model matched the jar on 730,272 graphs; the DFS sketch passes.
Independent reproducer realOwn JUnit with an allocation oracle: 389.9x the bytes of resolving each binding once at 1,000 bindings; fails 3 of 4 on main, 10.5.2, 9.12.3 and 8.6.0; 8.5.2 passes the chain tests; with the fix 4 of 4 pass.
Cross-model adversary (OpenAI GPT-5.6, reading only) uncertain, leaning defectCould not run code; a second read-only review of the sources said real (medium). The verifier's own run of the repro failed (42.6 s).
Devil's advocate real, LowWeighed every works-as-designed point and found none that makes the chain cost intended; re-ran the repro, fail 2 of 2.
Adjudication confirmed, mediumRe-ran the independent reproduction's JUnit: fail 3 of 4 (46.3 s vs 161 ms); impact minor, priority Low.
Audit keepRe-runs on main; end-to-end probe: a sorted search on a 4,001-binding chain in 38 ms while validate() takes 5.4 s at 1,000.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with -ea and -da; the fix passes.
Current-main reproducer realFails on 645d4fe14a20 and 10.5.2 with -ea and -da; the fix passes.
Cross-model refute review (OpenAI GPT-5.6, reading only) not real, medium (procedural)Could not run any command, so its "not real" is procedural; on reading it agreed with the mechanism. A separate run of the repro on 10.5.2 with -da failed, 872 ms vs 18 ms.
Tie-breaker real (high on the facts)Allocation probe on 8.5.2, 8.6.0, 10.6.0, main and main with the fix (x4.0 vs x7.6-7.9 per doubling); minimal test on main and 10.6.0; the 46 package tests pass with the fix.
This doc reproduced, fix validatedGradle on 645d4fe14a20: stock fails both ways; with the fix all 171 module tests, the repro included, and lint pass; timing probe for stock and two fixes.

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

Finding record

LFV-905, snapshot of 2026-11-06

Registry title
SimpleBindings.validate() copies the whole binding path into a new set at every step and re-checks every binding once per origin, so validating a plain chain of 2000 expression bindings takes about 45 s instead of about 100 ms (cubic since 8.6.0, a regression from LUCENE-9338)
Area
expressions / SimpleBindings.validate() (cycle and missing-reference check, CycleDetectionBindings)
Priority
Low, rank 1031: The cubic validate() cost on long SimpleBindings chains only slows things down, every answer stays correct, and it only reaches callers of the optional check, so it is Low.
Impact class
minor
Impact note
Low priority, cost only (validate() still accepts and rejects exactly the right graphs): applications on 8.6.0 through 10.6.0 or main that bind long chains of named expressions to each other with SimpleBindings.add(String, Expression) and call the optional setup-time check validate() pay time and allocation cubic in the chain length, about 30 ms at 200 bindings, 0.8 s at 500, 5 s at 1000, 41-48 s at 2000 and 575 s at 4000 (8.5.2: 99 ms at 2000), while resolving every binding of the same chain once takes about 0.1-0.2 s; validate() has no non-test caller in Lucene, and its exponential cost on bindings that share sub-expressions is the same on every release tested back to 5.5.5 (a secondary symptom, not part of the regression).
Summary
You build a chain of named expressions with SimpleBindings.add(String, Expression), each using the previous one (x1 = x0 + 1, x2 = x1 + 1, ..., x2000 = x1999 + 1), and call bindings.validate(), whose javadoc says it 'Traverses the graph of bindings, checking there are no cycles or missing references'. You expect a graph check that costs about as much as resolving every binding once, as on 8.5.2, where validate() of these 2000 bindings took about 100 ms. Instead, since 8.6.0 validate() starts a fresh walk from every binding, re-walks everything below it, and copies the whole current path into a new LinkedHashSet at every step, so its cost grows with the cube of the chain length: about 45 s for 2000 bindings and about 575 s for 4000 (at 1000 bindings it allocates 389.9 times the bytes of resolving every binding once), although the answer is still correct.
Introduced
ed3caab2d86b69ec4b3ed8e787827c0931b43d1b on main (squash merge of apache/lucene-solr#1444, LUCENE-9338 'Clean up type safety in SimpleBindings', now #10378, by romseygeek) and its branch_8x backport b66b970d1b06e56b0d685613855be5ef6d1a2c60, the copy that shipped in 8.6.0. It replaced the old validate() (build every Expression binding through the bindings and treat a StackOverflowError as a cycle: no path set, quadratic on a chain) with CycleDetectionBindings; the per-step path copy (new LinkedHashSet + addAll(parents)) came in PR commit f31cb0601615 'Detect recursion at more than one level', written to catch a cycle that does not pass through the origin, which a reviewer (jpountz) had found [read: git log --follow, git blame -w past the LUCENE-9570 reformat, git log --all -S 'seenFields.addAll(parents)' in the full-history clone; exec: commit-level A/B, the introducing commit's SimpleBindings.java on the 8.5.2 jars gives the 8.6.0 numbers exactly and its parent's file on the 8.6.0 jars gives the 8.5.2 numbers exactly] (2020-04-24 (ed3caab2d86b: author date = commit date 2020-04-24T09:23:50Z; b66b970d1b06: same author date, committed 71 s later; PR commit f31cb0601615 authored 2020-04-23T13:44:37Z))
First affected version
8.6.0 [exec] (first main-line release 9.0.0 [exec]; last unaffected release 8.5.2 [exec])
Affected versions
every lucene-expressions release from 8.6.0 to 10.5.2 (49 releases: 8.6.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2), 10.6.0 (the branch_10_6 head 4ae946e5a9b9, the RC1 revision) and main a9779ef3c193 [exec: the independent reproduction's JUnit fails its chain tests on 8.6.0, 8.11.4, 9.0.0 and 9.12.3 (-ea) and on 10.5.2 and main (-ea and -da); the finder's JUnit fails on main (-ea, -da), branch_10_6, 10.5.2 and 9.12.3; the others by a javap check of SimpleBindings in every expressions jar on Maven Central]. Not affected: 4.6.0-8.5.2 (72 releases) carry the older validate(), quadratic on chains [exec on 7.7.3 and 8.5.2: about 100 ms at 2000 bindings]; before 4.6.0 there is no expressions module. The secondary symptom (786,308 expression builds for 49 bindings that share sub-expressions) is identical on every version tested, 5.5.5 to main [exec], so it predates the regression. Not fixed upstream: SimpleBindings.java last changed by 378ca8d1bbf3 (#15573, javadoc markup, author and committer date 2026-01-15), and the logic of validate() and CycleDetectionBindings is unchanged since ed3caab2d86b (later commits only reformatted the file or changed javadoc and lambda parameters); no open PR touches the file (checked 2026-10-28) [read]. All runs with JDK 25 on aarch64 Linux.
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 “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found for the plain-chain cubic regression (searched 2026-10-28, read-only; refreshed 2026-10-28): two scripted prior-report searches (75 queries over GitHub issues and PRs, commit messages, downstream trackers, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web), 28 manual GitHub searches, GitHub code search, direct reads of the LUCENE-9338 review (apache/lucene-solr#1444: inline review comments, conversation comments and reviews), #10378, #9620, #16880, #6295, #6271, #9002 and #10380, and the changed files of all 410 open PRs and of the 63 PRs updated since 2026-10-26. No issue, PR, commit, JIRA entry, list post or web page reports the cost of validate(). The introducing review never discussed it: jpountz's multi-level cycle case motivated the path set, and madrob's containsKey-versus-get efficiency nit was answered 'it's part of query setup, not query execution'. Partial overlap for the secondary symptom only: in the same review, on the validate() line, msokolov wrote 'It's also nice to know if the same name occurs multiple times in the dependency tree; maybe one should cache its value in that case', and romseygeek deferred caching to a follow-up that was never filed; upstream's testFibonacciExpr notes the exponential cost of shared expression bindings at evaluation time. Not fixed upstream: SimpleBindings.java last changed by 378ca8d1bbf3 (#15573, javadoc, 2026-01-15 by author and committer date), validate() and CycleDetectionBindings logic unchanged since ed3caab2d86b; the only open PRs touching lucene/expressions are #681 (module-info.java) and #16847 (ExpressionRescorer); #16423 (configurable expression complexity guards, merged 2026-07-27) limits parse nesting at compile time only.
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on (1288 ms vs 14 ms) and off (1121 ms vs 11 ms); with the recommended fix all 171 tests of the expressions module, the repro included, pass both ways, and the module's format, ECJ and forbidden-apis checks pass. Earlier steps ran the test on 8.5.2 (passes), 8.6.0, 8.11.4, 9.0.0, 10.5.2 and 10.6.0 (fail), and an allocation probe on 8.5.2, 8.6.0, 10.6.0 and main.

Lucene and upstream sources

Finding record
  • lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:26-46 (at a9779ef3c193) (class javadoc, 'another expression' example at :39, @lucene.experimental at :45), :59-66 (add(String, Expression): 'This can be used to reference expressions from other expressions'), :76-85 (validate() javadoc 'Traverses the graph of bindings, checking there are no cycles or missing references' and the per-origin loop), :87-110 (CycleDetectionBindings: new LinkedHashSet + addAll(parents) per step at :95-98, re-apply per reference at :109); ExpressionValueSource.java:39-54 (the constructor resolves each variable through the Bindings at :45)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:26-46, 59-66, 76-85, 87-111
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:39-54; Expression.java:102-104
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:81 (the only non-test occurrence of validate()); Expression.java:73-74
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:38-39, 45, 59-66, 76-80
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionValidation.java:28-40 (testValidExternals); a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:138
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5859-5860
Prior reports
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionValueSource.java:134-156 (testFibonacciExpr; comment at 141-142)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:81-85, 95-98, 109

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs. The timing chart's main points were measured on main a9779ef3c193; lucene/expressions is identical at 645d4fe14a20, the commit named in the frame footer.

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.