Skip to content

Groovy connectors: runScriptOnConnector recompiles the script on every call and keeps every compiled class until the configuration is released #165

Description

@maximthomas

Summary

ScriptedConnectorBase.runScriptOnConnector compiles the script text into a new class on every call, even when the text has not changed. The engine's GroovyClassLoader, which lives as long as the connector configuration, keeps a reference to every such class. In the reproducer, 5,000 calls with the same one-line script left 5,000 classes in that loader's class cache, and Metaspace grew from 7.9 MB to 50.8 MB. Each call took several milliseconds, almost all of it compilation, while running an already compiled class takes ~3 µs.

Line references are to 1abfe74. The counts and timings come from a throwaway harness; everything else comes from reading the code.

Details

runScriptOnConnector (L342-L354) wraps the text in a GroovyCodeSource named "Script" + System.currentTimeMillis() + ".groovy". It then calls getGroovyScriptEngine().getGroovyClassLoader().parseClass(codeSource, false).

  • With false as the second argument, parseClass never populates the loader's source cache, so every call compiles. The time-based name only makes the class names unique; it is not what causes the recompilation.
  • Groovy 2.4.21 defines each generated class in a throwaway InnerLoader, but doParseClass then registers it in the engine loader's class cache (setClassCacheEntry). That entry keeps the class and its InnerLoader alive for as long as the engine loader lives.
  • The engine and its loader are created once per configuration (L793-L808). ScriptedConfiguration is a StatefulConfiguration, so the framework keeps one instance for the lifetime of the connector facade. Only release() drops the engine (L669).
  • parseClass holds the loader's sourceCache monitor while it compiles, so concurrent calls compile one at a time.

A caller that runs a connector script on a schedule therefore grows Metaspace for as long as the facade stays in use (the framework calls release() only when the facade is disposed or evicted as idle), and pays a full compilation on every call.

Reproducer

  1. Create a ScriptedConfiguration with an empty script root and init a ScriptedPoolableConnector with it.
  2. Call runScriptOnConnector(new ScriptContext("Groovy", "return a + 1", [a: 41]), null) 5,000 times.
  3. After every 1,000 calls, run System.gc() three times. Then read getGroovyScriptEngine().getGroovyClassLoader().getLoadedClasses().length and the used size of the Metaspace pool.

Results at 1abfe74 (JDK 26, macOS). The last column comes from a separate run with -XX:SoftRefLRUPolicyMSPerMB=0, which clears soft references on every GC, so it shows only what is strongly held:

Calls Classes in the engine's loader Metaspace Metaspace, soft refs cleared
0 0 7.9 MB 7.9 MB
1,000 1,000 21.0 MB 17.8 MB
2,000 2,000 28.6 MB 22.2 MB
3,000 3,000 36.0 MB 26.5 MB
4,000 4,000 43.4 MB 30.7 MB
5,000 5,000 50.8 MB 34.9 MB

The first block also loads Groovy's own runtime classes. After it, Metaspace grows by about 4.3 KB per call with soft references cleared and about 7.4 KB per call without; the difference is held only through soft references. The average time per call over a block of 1,000 calls ranged from 2.6 to 10 ms across our runs, varying with JIT and GC, and almost all of it is compilation. Running a class compiled once (InvokerHelper.createScript(cls, binding).run(), measured standalone, outside the connector) took 3.1 µs per call.

Proposed fix

  • Cache the compiled script classes per configuration in a bounded LRU map, keyed by a digest of the script text (e.g. SHA-256). A repeated script then costs a map lookup plus createScript.
  • Compile each new script through a short-lived child GroovyClassLoader of the engine's loader with the same CompilerConfiguration (the GroovyClassLoader(GroovyClassLoader) constructor inherits it), so that setClassCacheEntry lands in the child's cache rather than the engine's. When an entry is evicted, nothing else strongly references its class, and the class can be unloaded. Without the engine's configuration the script would lose the connector's default imports.
  • Clear the cache in release().

Behavioral differences: static fields and metaclass changes of the script class, and classes declared in the script, survive between calls with the same text, and the class name becomes stable. Today every call gets a fresh class. @Field variables are instance fields, and each call still gets its own Script instance and Binding.

Activity

  1. self-assigned this
    on Oct 6, 2026
  2. added
    bugSomething isn't working
    resource-leakMemory, class-loader, thread or handle leaks
    performancePerformance and scalability fixes
    on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingconnector:groovyGroovy connectorperformancePerformance and scalability fixesresource-leakMemory, class-loader, thread or handle leaks

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions