Skip to content

GROOVY-12333: findBalancedGroups: bound the cost of deeply nested input - #2872

Merged
paulk-asert merged 1 commit into
apache:masterfrom
paulk-asert:groovy12333
Sep 3, 2026
Merged

GROOVY-12333: findBalancedGroups: bound the cost of deeply nested input#2872
paulk-asert merged 1 commit into
apache:masterfrom
paulk-asert:groovy12333

Conversation

@paulk-asert

Copy link
Copy Markdown
Contributor

A few hundred KB of nested delimiters cost tens of seconds, then died - with a StackOverflowError if the stack ran out first, otherwise by exhausting the heap. Two independent quadratics, both in the nesting.

Depth was assigned eagerly during assembly. find() builds bottom-up, so a node is attached to a new parent once per enclosing delimiter, and each attach re-walked the whole subtree beneath it to restamp depths - quadratic, and one stack frame per level. Depth is now resolved from the parent chain when first asked for and cached, along with every ancestor walked on the way, so resolving a whole tree costs O(n) and any node is O(1) once resolved. The walk is iterative, so nesting no longer consumes stack. A chain 20,000 deep goes from 3646ms to 51ms, and one 10,000 deep whose depths are never read from 853ms to 4ms.

Attaching a node whose depth was already read caches it against a parent chain that is about to change, so the attach drops the cached depths beneath it. That walk is iterative too, prunes at any node whose depth was never read, and does not run during find(), which reads no depths while assembling.

Each node also held its own copy of its matched text. Groups nest, so their spans overlap and those copies duplicate most of the document once per level: 64KB of nesting retained about 990MB. A node now keeps the source and slices on demand, which is what the start/end offsets it already carried were for, so retention is proportional to the input - the same 64KB now retains 2MB. Reading every group's text costs what it did; reading one group's text repeatedly now slices each time rather than returning a stored copy, about 2ns per call.

The package-private six-argument constructor consequently takes the source rather than the already-sliced text, and its offsets index into it. Passing text detached from its offsets is no longer meaningful, so the range check that compared them is now a bounds check against the source. find() is the only production caller and always had the source to hand; it no longer allocates a substring per group.

The source must be effectively immutable for as long as a node lives. find() already flattens its input to a String before matching, which guarantees that.

A few hundred KB of nested delimiters cost tens of seconds, then died -
with a StackOverflowError if the stack ran out first, otherwise by
exhausting the heap. Two independent quadratics, both in the nesting.

Depth was assigned eagerly during assembly. find() builds bottom-up, so a
node is attached to a new parent once per enclosing delimiter, and each
attach re-walked the whole subtree beneath it to restamp depths -
quadratic, and one stack frame per level. Depth is now resolved from the
parent chain when first asked for and cached, along with every ancestor
walked on the way, so resolving a whole tree costs O(n) and any node is
O(1) once resolved. The walk is iterative, so nesting no longer consumes
stack. A chain 20,000 deep goes from 3646ms to 51ms, and one 10,000 deep
whose depths are never read from 853ms to 4ms.

Attaching a node whose depth was already read caches it against a parent
chain that is about to change, so the attach drops the cached depths
beneath it. That walk is iterative too, prunes at any node whose depth
was never read, and does not run during find(), which reads no depths
while assembling.

Each node also held its own copy of its matched text. Groups nest, so
their spans overlap and those copies duplicate most of the document once
per level: 64KB of nesting retained about 990MB. A node now keeps the
source and slices on demand, which is what the start/end offsets it
already carried were for, so retention is proportional to the input -
the same 64KB now retains 2MB. Reading every group's text costs what it
did; reading one group's text repeatedly now slices each time rather than
returning a stored copy, about 2ns per call.

The package-private six-argument constructor consequently takes the
source rather than the already-sliced text, and its offsets index into
it. Passing text detached from its offsets is no longer meaningful, so
the range check that compared them is now a bounds check against the
source. find() is the only production caller and always had the source
to hand; it no longer allocates a substring per group.

The source must be effectively immutable for as long as a node lives.
find() already flattens its input to a String before matching, which
guarantees that.
@testlens-app

testlens-app Bot commented Sep 3, 2026

Copy link
Copy Markdown

🚨 TestLens detected 3 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

Build and test / lts (17, windows-latest, 1) > :groovy-groovysh:test

Test Runs Flakiness
GroovyPosixCommandsTest > headAndGrepAndWcStripControlCharactersFromNames() 20% 🔴
GroovyPosixCommandsTest > lsStripsControlCharactersFromFileNames() 20% 🔴
GroovyPosixCommandsTest > lsStripsControlCharactersFromSymlinkTargets() 20% 🔴

🏷️ Commit: 3bcc275
▶️ Tests: 112784 executed
⚪️ Checks: 31/31 completed

Test Failures

GroovyPosixCommandsTest > headAndGrepAndWcStripControlCharactersFromNames() (:groovy-groovysh:test in Build and test / lts (17, windows-latest, 1))
java.nio.file.InvalidPathException: Illegal char <�> at index 2: ok�[2Ktrap.txt
	at java.base/sun.nio.fs.WindowsPathParser.normalize(WindowsPathParser.java:182)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:153)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:77)
	at java.base/sun.nio.fs.WindowsPath.parse(WindowsPath.java:92)
	at java.base/sun.nio.fs.WindowsFileSystem.getPath(WindowsFileSystem.java:232)
	at java.base/java.nio.file.Path.resolve(Path.java:515)
	at java.base/jdk.internal.reflect.GeneratedMethodAccessor33.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.codehaus.groovy.reflection.CachedMethod.invokeReflective(CachedMethod.java:505)
	at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:455)
	at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:298)
	at org.codehaus.groovy.vmplugin.v8.IndyInterface.invokeColdReflective(IndyInterface.java:650)
	at org.apache.groovy.groovysh.jline.GroovyPosixCommandsTest.headAndGrepAndWcStripControlCharactersFromNames(GroovyPosixCommandsTest.groovy:123)
GroovyPosixCommandsTest > lsStripsControlCharactersFromFileNames() (:groovy-groovysh:test in Build and test / lts (17, windows-latest, 1))
java.nio.file.InvalidPathException: Illegal char <�> at index 2: ok�[2Ktrap.txt
	at java.base/sun.nio.fs.WindowsPathParser.normalize(WindowsPathParser.java:182)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:153)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:77)
	at java.base/sun.nio.fs.WindowsPath.parse(WindowsPath.java:92)
	at java.base/sun.nio.fs.WindowsFileSystem.getPath(WindowsFileSystem.java:232)
	at java.base/java.nio.file.Path.resolve(Path.java:515)
	at java.base/jdk.internal.reflect.GeneratedMethodAccessor33.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.codehaus.groovy.reflection.CachedMethod.invokeReflective(CachedMethod.java:505)
	at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:455)
	at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:298)
	at org.codehaus.groovy.vmplugin.v8.IndyInterface.invokeColdReflective(IndyInterface.java:650)
	at org.apache.groovy.groovysh.jline.GroovyPosixCommandsTest.lsStripsControlCharactersFromFileNames(GroovyPosixCommandsTest.groovy:97)
GroovyPosixCommandsTest > lsStripsControlCharactersFromSymlinkTargets() (:groovy-groovysh:test in Build and test / lts (17, windows-latest, 1))
java.nio.file.InvalidPathException: Illegal char <�> at index 2: ok�[2Ktrap.txt
	at java.base/sun.nio.fs.WindowsPathParser.normalize(WindowsPathParser.java:182)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:153)
	at java.base/sun.nio.fs.WindowsPathParser.parse(WindowsPathParser.java:77)
	at java.base/sun.nio.fs.WindowsPath.parse(WindowsPath.java:92)
	at java.base/sun.nio.fs.WindowsFileSystem.getPath(WindowsFileSystem.java:232)
	at java.base/java.nio.file.Path.resolve(Path.java:515)
	at java.base/jdk.internal.reflect.GeneratedMethodAccessor33.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.codehaus.groovy.reflection.CachedMethod.invokeReflective(CachedMethod.java:505)
	at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:455)
	at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:298)
	at org.codehaus.groovy.vmplugin.v8.IndyInterface.invokeColdReflective(IndyInterface.java:650)
	at org.apache.groovy.groovysh.jline.GroovyPosixCommandsTest.lsStripsControlCharactersFromSymlinkTargets(GroovyPosixCommandsTest.groovy:108)

Rerun Controls

Select tests to mute in this pull request:

  • GroovyPosixCommandsTest > headAndGrepAndWcStripControlCharactersFromNames()
  • GroovyPosixCommandsTest > lsStripsControlCharactersFromFileNames()
  • GroovyPosixCommandsTest > lsStripsControlCharactersFromSymlinkTargets()

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.10345% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.9736%. Comparing base (b0fb70b) to head (3bcc275).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/main/java/groovy/util/regex/BalancedGroup.java 93.1034% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2872        +/-   ##
==================================================
+ Coverage     70.9621%   70.9736%   +0.0115%     
- Complexity      37181      37196        +15     
==================================================
  Files            1578       1578                
  Lines          135189     135215        +26     
  Branches        25038      25046         +8     
==================================================
+ Hits            95933      95967        +34     
+ Misses          30569      30562         -7     
+ Partials         8687       8686         -1     
Files with missing lines Coverage Δ
src/main/java/groovy/util/regex/BalancedGroup.java 94.9275% <93.1034%> (-0.9058%) ⬇️

... and 13 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@paulk-asert
paulk-asert merged commit 9da117d into apache:master Sep 3, 2026
31 of 32 checks passed
@paulk-asert
paulk-asert deleted the groovy12333 branch September 3, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants