Re: Script caching
Per Nyfelt <[email protected]> Thu, 18 Jun 2026 20:25:21 +0200
| Newsgroups | gmane.comp.lang.groovy.devel |
|---|---|
| Organization | Alipsa HB |
| Message-ID | <[email protected]> |
Hi all, Following up on my last message — I said I'd build a wrapper script to validate the assumptions behind the proposal before taking it further. I ended up building two prototypes and benchmarking them across Linux, WSL, and Git Bash. Results below, updated GEP (v0.3) attached. What was built - gcs: a transparent drop-in for groovy — fingerprints the script/classpath on every run, serves cached bytecode on a hit, falls back to groovyc + cache-store on a miss. Stays inside the normal launcher path. - gsc: an opt-in replacement command — compiles and packages on a cold/stale run, but on a warm run skips GroovyShell/the launcher entirely and execs the cached class directly via java -cp. Both are external wrappers, not patches to Groovy itself — the goal was to validate the cache-hit/fingerprinting economics cheaply before touching launcher code. Results - Both approaches show real warm-run speedups: gcs 30-55% on Linux, gsc 33-52% across all three platforms, with break-even after 2-4 runs in most cases. Fingerprinting overhead (122-154ms for gcs) does not negate the saving. - gsc's warm times are substantially faster than gcs's (roughly 640-950ms vs 1400-2570ms on Linux), because it can skip the launcher entirely on a hit. Staying inside GroovyShell/the launcher carries inherent overhead that an external wrapper avoids — that's a real performance-ceiling difference, not just a discoverability tradeoff. - We extended gsc to cover @Grab, @CompileStatic/@Canonical, and dynamic Groovy (methodMissing, Expando). All work correctly through the cache with no extra overhead versus plain scripts — these are ordinary groovyc concerns, not special cases for the cache itself. - For @Grab specifically: we resolve Grape dependencies once during the compiling run, store the resolved jar paths in the cache entry's metadata, verify the jars still exist on later runs, and pass -Dgroovy.grape.enable=false so the runtime doesn't re-resolve. Once warm, an @Grab script's run time is indistinguishable from a plain script's — the resolution cost is fully amortized to the one-time compile. - One concrete finding that bears directly on Paul's "key completeness" point: gsc's warm path loads the cached class through a plain application classloader, not the launcher's RootLoader-based system classloader. That breaks @GrabConfig(systemClassLoader=true), which depends on dynamically adding jars to that specific loader at runtime. This is exactly the kind of silent-divergence risk Paul flagged — it's now called out explicitly in the proposal as a requirement: an in-launcher implementation must preserve classloader hierarchy/identity on cache-hit loading, not just bytecode equivalence. - A regression check after adding the @Grab/AST/dynamic support showed no slowdown on the original plain-script workloads — the new code paths only activate for scripts that use those features. Full results, including the WSL/Git Bash breakdowns and break-even tables, are here: https://github.com/perNyfelt/groovy-script-cache/tree/main/bench/results Prototype source (gcs, gsc) is in the same repo under bin/. What changed in the proposal (v0.3) - Added the classloader-hierarchy-preservation requirement to the implementation approach, based on the gsc finding above. - Sharpened the External Wrapper alternative with the concrete performance-ceiling numbers, rather than dismissing it on discoverability grounds alone. - Documented a validated @Grab caching approach (resolve once, cache the resolved classpath, verify on reuse, disable Grape re-resolution) as a concrete path for the "Future Work" item on smarter @Grab fingerprinting. The Open Questions section is unchanged — in particular, where the cache-aware component should live (launcher, GroovyShell, GroovyClassLoader, or a separate component) isn't something an external wrapper can settle, since it's an actual-codebase decision. Feedback there would be especially useful before this moves toward a reference implementation. Best regards, Per
scripting-cache-gep.md
(text/markdown, 32.7 KB)
# GEP-TBD: Persistent Script Compilation Cache for the Groovy Command-Line Launcher ## Metadata | Field | Value | |--------------------------|--------------------------------------------------------------------------| | Number | GEP-TBD | | Title | Persistent Script Compilation Cache for the Groovy Command-Line Launcher | | Version | 0.3 | | Type | Feature | | Status | Draft | | Leader | Per Nyfelt | | Created | 2026-06-01 | | Last modification | 2026-06-18 | | Target Groovy version | TBD | | Discussion | TBD | | Reference implementation | TBD | ## Abstract This proposal introduces an optional persistent compilation cache for Groovy scripts executed through the `groovy` command-line launcher. When an eligible script is executed, the launcher may store the generated class files in a local cache. On later executions, if the script source and all relevant compilation inputs are unchanged, the launcher may load the cached class files instead of parsing, transforming, and compiling the script again. The goal is to improve startup time for short-lived Groovy scripts and command-line tools while preserving current semantics. The cache complements JVM startup improvements such as CDS and Project Leyden; it is not intended to replace them. ## Rationale Groovy is frequently used for scripting, automation, build tooling, data processing, and command-line utilities. In these use cases, process lifetime is often short, and startup overhead matters. For simple scripts, actual execution time may be negligible compared to: * JVM startup * loading the Groovy runtime * bootstrapping the metaclass registry * JIT warmup * parsing the script * applying AST transformations * bytecode generation * class loading Recent and ongoing JVM work, such as CDS and Project Leyden, can reduce JVM startup and class-loading overhead. A persistent script compilation cache does not remove JVM startup, Groovy runtime initialization, or JIT costs. Its direct benefit is narrower: it can avoid parsing, AST transformations, and bytecode generation for an unchanged script. A cache hit may also avoid loading parts of the compiler frontend. The realistic performance ceiling is therefore the portion of wall-clock time spent compiling the script. That portion is likely to be smallest for trivial scripts and larger for scripts with substantial source or AST transformation work. The actual benefit must be measured across representative scripts. Groovy already caches compiled script classes during the lifetime of a `GroovyClassLoader`, but this cache is lost when the process exits. A persistent launcher-level cache would allow repeated invocations of unchanged scripts to skip most compilation work. This would make Groovy more attractive for command-line scripting and developer tooling, especially when used alongside JVM startup improvements. ## Goals The goals of this proposal are: 1. Reduce repeated startup overhead for unchanged Groovy scripts. 2. Avoid changing Groovy language semantics. 3. Make the feature safe and conservative by default. 4. Provide explicit ways to disable, clear, and inspect the cache. 5. Ensure cache invalidation accounts for relevant compilation inputs. 6. Allow future integration with JVM startup technologies such as CDS or Leyden-style caches. 7. Preserve a fresh JVM for each invocation, without sharing mutable runtime state between script executions. ## Non-Goals This proposal does not aim to: 1. Replace `groovyc`. 2. Change Groovy compilation semantics. 3. Cache arbitrary runtime results. 4. Guarantee improved performance for all scripts. 5. Provide a distributed or shared build cache. 6. Cache scripts run inside long-lived embedded Groovy runtimes. 7. Solve dependency resolution caching for `@Grab`, although it must interact safely with it. 8. Remove JVM startup, Groovy runtime initialization, or JIT warmup costs. 9. Provide a resident JVM or daemon process. ## Proposed Behavior When running: ```shell groovy myscript.groovy ``` when the cache is enabled, the launcher may: 1. Determine whether the script is eligible for caching. 2. If eligible, compute a cache key from the script and compilation environment. 3. Look for previously generated class files matching that key. 4. If found and valid, load the cached classes. 5. If the script is ineligible or no valid cache entry was found, compile the script normally and, if eligible, store the generated classes in the cache. The user-visible behavior of the script must remain the same as if the script had been compiled from source during that invocation. ### Correctness-First Principle The cache is an optimization. Reusing stale or incompatible bytecode is a release-blocking defect. Whenever the launcher cannot establish that every relevant compilation input is unchanged, it must compile the script normally instead of reusing a cache entry. ### Initial Scope The first implementation should be deliberately narrow. It should cache only file-backed scripts executed through the `groovy` command-line launcher when the compilation environment can be fingerprinted reliably. The initial implementation should treat the following as ineligible: * scripts using `@Grab` * scripts using externally supplied or dynamically mutated compiler configuration, including configuration scripts, that cannot be fingerprinted reliably * scripts affected by launcher configuration, such as `GROOVY_CONF`, that cannot be fingerprinted reliably * scripts affected by launcher startup hooks or JVM options when those hooks or options cannot be fingerprinted reliably * scripts affected by AST transformations that cannot be fingerprinted reliably * scripts with compilation inputs or dependencies that cannot be identified reliably An ineligible script must be compiled normally. Ineligibility is a cache miss, not an error. ### Cache Location The default cache location should follow platform conventions. Suggested defaults: | Platform | Cache location | | --- | --- | | Linux/Unix | `$XDG_CACHE_HOME/groovy/script-cache` or `~/.cache/groovy/script-cache` | | macOS | `~/Library/Caches/Groovy/script-cache` | | Windows | `%LOCALAPPDATA%\Groovy\script-cache` | A system property or environment variable should allow overriding the location: ```shell groovy -Dgroovy.script.cache.dir=/path/to/cache myscript.groovy GROOVY_SCRIPT_CACHE_DIR=/path/to/cache groovy myscript.groovy ``` ### Enabling and Disabling The cache should initially be opt-in unless the Groovy project decides the invalidation model is sufficiently conservative for default use. The proposed command-line options are: ```shell groovy --script-cache myscript.groovy groovy --no-script-cache myscript.groovy groovy --clear-script-cache ``` `--clear-script-cache` clears all entries in the selected cache directory and exits. It does not require a script argument. The proposed system properties are: ```shell -Dgroovy.script.cache=true -Dgroovy.script.cache=false -Dgroovy.script.cache.dir=/path/to/cache -Dgroovy.script.cache.maxSizeBytes=<bytes> ``` When multiple mechanisms configure the same setting, the precedence should be: 1. command-line option 2. system property 3. environment variable, such as `GROOVY_SCRIPT_CACHE_DIR` for the cache location 4. platform default Cache enablement remains opt-in through `--script-cache` or `-Dgroovy.script.cache=true`. If the feature later proves safe and reliable, it could become enabled by default for normal file-based scripts. ### Cache Management The cache must not grow without bound. The implementation should enforce a configurable size limit, such as `-Dgroovy.script.cache.maxSizeBytes=<bytes>`, and evict entries automatically when that limit is exceeded. The exact default limit and eviction policy should be determined by the reference implementation. Entries from older Groovy versions or cache format versions must be eligible for cleanup. Automatic cleanup should be best-effort and must not prevent normal script execution. Users can remove all entries in the selected cache directory explicitly with `--clear-script-cache`. An optional cache-size summary or listing command is discussed under [Open Questions](#open-questions). ### Source Identity Before computing a cache key for a file-backed script, the launcher should resolve the script to its canonical path. Relative paths should therefore be resolved against the current working directory, and symlink aliases should identify the same source file. This matches the existing file-backed `GroovyCodeSource` behavior. ### Package Declarations File-backed scripts with package declarations should remain eligible for caching. The source hash already accounts for the package declaration. The cache entry must record the binary names of all generated classes so they can be defined with the same names and packages on a cache hit. ### Cache Key The cache key and validation metadata must include enough information to avoid reusing stale or incompatible bytecode. The fingerprint should be over-inclusive: uncertainty must cause a cache miss rather than reuse of an entry that might be stale. At minimum, the key should include: * canonical script path * script source hash * cache format version * Groovy version and a fingerprint of the effective Groovy runtime distribution * Java version or class file target version * effective classpath * compiler configuration * invokedynamic setting * `--enable-preview`, the `groovy.preview.features` system property set by the current Groovy launchers when preview mode is enabled, and any related compiler or JVM options that affect generated bytecode * script base class * active AST transformations * classpath-discovered extension modules * effective launcher configuration, including `GROOVY_HOME`, `groovy.home`, `GROOVY_CONF`, and configuration scripts where relevant * relevant system properties that affect compilation The effective classpath fingerprint must detect changes to extension-module descriptors and their implementation classes. Groovy discovers extension modules from both: * `META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule` * `META-INF/services/org.codehaus.groovy.runtime.ExtensionModule` Launcher startup hooks and JVM options, such as relevant `JAVA_OPTS`, may change compilation behavior indirectly. The fingerprint should represent effective compilation-affecting inputs after launcher processing rather than blindly hashing every launcher environment variable or JVM option. Unrelated runtime options should not create unnecessary cache misses. The Groovy version alone is not sufficient to distinguish custom or vendor-modified distributions. The distribution fingerprint must detect changes to Groovy runtime artifacts that can affect compilation, even when the reported Groovy version is unchanged. `GROOVY_HOME` and `groovy.home` should be represented through their effective compilation inputs rather than treated as path strings when the resulting distribution and configuration are equivalent. The implementation must also account for dependent Groovy sources discovered during compilation. A changed sibling script must invalidate any cached script that depends on it. `GroovyScriptEngine` already tracks source dependencies during compilation and should inform this design. The first implementation should not cache scripts using `@Grab`. A later implementation may reconsider this if resolved dependencies, compile-time classpath mutation, and discovered transformations can be represented safely. Fingerprinting must also be efficient enough to preserve the expected benefit. For example, a full content hash of every classpath entry may be safe but too expensive. The implementation should benchmark competing strategies and prefer a cache miss when a cheap, reliable fingerprint is unavailable. ### Cache Contents The cache should store: * generated `.class` files * binary names of all generated classes * metadata describing the compilation environment * cache format version * source hash * Groovy version * Groovy runtime distribution fingerprint * Java or class file target * classpath fingerprint * dependency metadata for discovered Groovy sources * timestamp of creation * last-access metadata and entry size where required by the eviction policy * optional diagnostic information The cache format should be treated as internal and may change between Groovy versions. Cached classes must retain equivalent `CodeSource` and protection-domain behavior when they are loaded in a new process. ### Cache Entry Layout Each cache entry should be a self-contained unit identified by a digest of its cache key. It should contain the metadata and every generated class file needed for that script. Writers should create temporary entries on the same filesystem as the cache and publish complete entries atomically. The implementation may shard entries by key prefix to avoid excessively large directories. The exact directory names, metadata encoding, and sharding scheme are internal cache-format details to be determined by the reference implementation. ### Invalidation A cached script must be invalidated when any relevant compilation input changes. Examples: * script source changed * Groovy version changed * Groovy runtime distribution changed without a version change * Java target changed * classpath changed * AST transform implementation changed * compiler configuration changed * `@Grab` dependencies changed, for a future implementation that enables `@Grab` caching * cache format changed * dependent Groovy source changed * extension-module descriptor or implementation changed * compilation-affecting launcher configuration or JVM option changed If validation fails or metadata is unreadable, the launcher should silently fall back to normal compilation unless diagnostics are enabled. As required by the [Correctness-First Principle](#correctness-first-principle), the implementation must default to a cache miss whenever it cannot establish that all relevant compilation inputs are unchanged. ### Diagnostics The launcher should provide optional diagnostics. The proposed diagnostic options are: ```shell groovy --script-cache-info myscript.groovy groovy --script-cache-verbose myscript.groovy ``` `--script-cache-info` should report whether the invocation was a hit, miss, or ineligible for caching, together with a concise reason. `--script-cache-verbose` should include the same result plus detailed validation diagnostics, such as the cache entry location and the compilation inputs that caused a miss or made the script ineligible. Example output: ```text Groovy script cache: miss Reason: source hash changed ``` or: ```text Groovy script cache: hit Cache entry: ~/.cache/groovy/script-cache/... ``` Diagnostics should be disabled by default to preserve normal script output. ## Security Considerations The cache stores executable bytecode. Therefore: 1. Cache entries should be private to the current user by default. 2. The cache directory should not be world-writable. 3. The launcher should avoid loading cache entries with unsafe permissions. 4. Cache entries should be isolated by operating-system user. If a configured cache directory may be shared by multiple users, the launcher must use separate user-specific namespaces or disable caching. 5. The cache should not weaken existing script security assumptions. 6. Cache writes and reads should defend against symlink-based replacement and similar filesystem races where supported by the platform. 7. Cached classes should retain equivalent `CodeSource` and protection-domain behavior when they are loaded in a new process. The canonical script path and source hash distinguish scripts from different projects. User isolation should come from cache-directory scoping and permissions rather than a user-controlled system property. On systems where permissions cannot be verified reliably, the launcher may disable caching or use a more conservative mode. ## Concurrency Multiple processes may execute the same script concurrently. The implementation should use atomic writes, temporary files, and safe renames to avoid corrupted cache entries. If another process is replacing a cache entry, the launcher may either wait briefly, use the previous complete entry if it remains valid, or compile normally. Readers should not observe partially written entries. ## Failure Handling The cache is an optimization. Cache failures must not prevent an otherwise valid script from running. If a cache read fails because an entry is missing, unreadable, invalid, or corrupt, the launcher should compile the script normally. If a cache write fails because the disk is full, permissions are insufficient, or another I/O error occurs, the launcher should run the normally compiled script without persisting the entry. Temporary files should be removed on a best-effort basis. Failures should be reported only when cache diagnostics are enabled unless they prevent normal script execution for an unrelated reason. ## Interaction with Existing Groovy Facilities ### `GroovyShell` The `groovy` command-line launcher executes scripts through `GroovyShell`. `GroovyShell` delegates compilation to `GroovyClassLoader`, so it is part of the natural execution path for the cache. The final ownership boundary should follow the reference implementation rather than be fixed prematurely. ### `GroovyClassLoader` `GroovyClassLoader` already maintains in-memory caches for loaded classes and compiled sources. Its source cache uses a key derived from script text and code source, allowing repeated compilation requests within one process to reuse an existing `Class` instance. `GroovyClassLoader` can also add synthetic timestamp fields to generated classes when source recompilation is enabled. Those fields support source staleness checks for classes loaded within an existing runtime. The proposed persistent cache complements this machinery by surviving across process invocations. It cannot simply serialize the existing source cache, because that cache stores loaded `Class` instances rather than portable bytecode artifacts. A persistent cache must capture all generated class files, store validation metadata, and define the classes safely in a new process. Existing hashing, class collection, and timestamp behavior should be reused or factored where practical. The implementation should avoid introducing a parallel staleness model that can disagree with existing recompilation behavior. ### `GroovyScriptEngine` `GroovyScriptEngine` is the most similar existing facility. It caches script classes in memory for long-lived hosts and tracks dependencies discovered during compilation. It walks those dependencies when deciding whether a script must be recompiled. The persistent launcher cache should study and reuse or factor this dependency-tracking behavior where practical, especially for scripts that depend on sibling Groovy sources. `GroovyScriptEngine` remains an in-memory, timestamp-based facility and does not itself solve persistence across short-lived command-line invocations. ### `groovyc` This proposal does not replace `groovyc`. Users who want explicit ahead-of-time compilation can continue using `groovyc`. The script cache is intended for the common case where users execute source scripts directly with the `groovy` command. ### `@Grab` Scripts using `@Grab` may be cached only if the resolved dependency set can be included in the cache key. The first implementation should disable persistent script caching for scripts using `@Grab`. A prototype external wrapper (`gsc`, see [Alternatives Considered](#external-wrapper-tool)) validated a concrete approach for a later implementation: resolve `@Grab` dependencies once during the compiling invocation, store the resolved classpath in the cache entry's metadata, verify on each subsequent invocation that every resolved jar still exists before treating the entry as fresh, and disable Grape's own runtime re-resolution on a cache hit so the cached classpath is applied directly instead of being re-derived. Benchmarked warm-run time for an `@Grab` script was indistinguishable from warm-run time for an equivalent script without `@Grab`, while the one-time resolution cost was confined to the compiling invocation. This does not eliminate the need to track resolved dependencies as part of the cache key and invalidate on dependency changes, but it shows the general direction is workable. ### AST Transformations AST transformations affect generated bytecode and must be part of the compilation fingerprint. This includes classpath-discovered global transforms and source-selected local transforms. If this cannot be done reliably, caching should be disabled for affected scripts. ## Possible Implementation Approach One possible implementation is: 1. Add a cache-aware execution path for eligible file-backed scripts launched by the `groovy` command. 2. Before compilation, determine whether the script is eligible for caching. 3. If the script is ineligible, compile and run it normally without caching. 4. For eligible scripts, compute a `ScriptCacheKey` and look for a matching cache entry. 5. If present, validate the metadata and load every generated class using an appropriate class loader while preserving equivalent `CodeSource` and protection-domain behavior. Cache-hit loading must also preserve the same classloader hierarchy and identity that a normal launcher invocation would establish, including the launcher's system classloader. A prototype external wrapper that loaded cached classes via a plain application classloader instead of the launcher's `RootLoader`-based system classloader broke `@GrabConfig(systemClassLoader=true)`, which depends on dynamically adding jars to that specific loader at runtime. An in-launcher implementation should confirm it does not introduce an equivalent divergence. 6. If the cache entry is absent or invalid, compile it as today. 7. For eligible cache misses, reuse or factor existing class collection and dependency-tracking machinery where practical. 8. Capture all generated bytecode and discovered dependency metadata. 9. Persist generated bytecode and metadata atomically. Stored metadata is validated before loading cached classes on future invocations. The implementation should be internal and not expose cache internals as stable public API in the first version. The reference implementation should determine whether the cache belongs in the launcher, `GroovyShell`, `GroovyClassLoader`, or a focused internal component used by them. ## Reference Implementation A reference implementation has not yet been provided. ## Testing Tests should cover: * cache miss on first execution * cache hit on second execution * invalidation when script source changes * invalidation when a dependent sibling Groovy source changes * invalidation when classpath changes * invalidation when an extension-module descriptor or implementation changes * invalidation when Groovy version, runtime distribution, or cache format changes * invalidation when a compilation-affecting launcher or JVM option changes * disabling the cache * clearing the entire selected cache directory * configuration precedence * bounded growth and eviction * cleanup of entries from older Groovy or cache format versions * atomic publication of complete cache entries * concurrent execution * scripts with imports * scripts with local classes * scripts with closures * scripts using AST transformations * scripts using different compiler configurations * scripts using `--enable-preview` * scripts with package declarations * equivalent behavior for relative, absolute, and symlinked script paths * equivalent fingerprinting for `GROOVY_HOME` locations with equivalent compilation inputs * ineligible scripts using `@Grab` * ineligible scripts with compilation inputs that cannot be fingerprinted * failure fallback to normal compilation * fallback when cache writes fail because of disk-full, permission, or I/O errors * cache directory permission checks where supported * user isolation when a configured cache location may be shared * filesystem race and symlink checks where supported * equivalent `CodeSource` and protection-domain behavior on cache hits For a representative corpus of eligible scripts, the test suite should compare a cache hit with a fresh compilation. It should compare every generated class, not only the main script class. The comparison should include: * differential behavior tests * normalized bytecode comparison that excludes known volatile metadata * invalidation tests for each supported fingerprint input Raw byte-for-byte equality is not always an appropriate oracle because Groovy may embed recompilation timestamps in generated classes. Known volatile metadata includes synthetic `__timeStamp` and `__timeStamp__...` fields added for source-recompilation checks. Any unexplained semantic or normalized-bytecode difference must be investigated as a potential violation of the [Correctness-First Principle](#correctness-first-principle). Performance tests should measure: * trivial script * script with imports * script with AST transforms * script with a larger source file * script with a dependency-heavy classpath * script using `@CompileStatic` * script using dynamic Groovy features Benchmarks should report cold execution, uncached execution, and cache-hit execution separately. They should also measure fingerprinting overhead, including the cost of classpath validation, so the cache is not enabled for cases where validation consumes the expected savings. ## Impact This proposal is intended to be backward compatible. If disabled, behavior is unchanged. If enabled, the observable behavior of a script should be equivalent to normal source compilation. If the cache cannot guarantee this, it should not be used. The initial implementation would add complexity to the command-line launcher but should not expose cache internals as stable public API. Unlike a resident JVM service, the cache preserves a fresh JVM for every invocation. It does persist executable artifacts on disk, so it is not stateless in the filesystem sense. ## Risks The main risks are: 1. Incorrect cache invalidation. 2. Security issues from loading cached bytecode. 3. Increased launcher complexity. 4. Hard-to-debug behavior if cached bytecode differs from source compilation. 5. Limited benefit for scripts where runtime dominates startup. 6. Fingerprinting overhead that consumes the expected compilation savings. These risks can be mitigated by making the feature initially opt-in, using the [Correctness-First Principle](#correctness-first-principle), providing diagnostics, and falling back to normal compilation whenever uncertainty exists. ## Alternatives Considered ### Use `groovyc` Users can already precompile scripts with `groovyc`. However, this changes the workflow and removes the convenience of directly running `.groovy` source files. ### Rely Only on JVM Startup Improvements JVM-level startup improvements help Groovy, but they do not remove Groovy-specific parsing, AST transformation, and bytecode generation costs. ### Keep Only In-Memory Caching Groovy already benefits from in-memory class caching in long-lived processes. This does not help repeated short-lived invocations of the `groovy` command. ### External Wrapper Tool An external script runner could implement persistent caching, but launcher-level support would be more discoverable, portable, and consistent. A prototype external wrapper (`gsc`) was built and benchmarked alongside a transparent in-launcher prototype (`gcs`) on the same machine. By skipping `GroovyShell` and the launcher entirely on a cache hit and invoking the cached class directly with `java -cp`, the wrapper reached warm times of 639-1810 ms versus 1404-2570 ms for the in-launcher approach. This is a real performance-ceiling difference, not just a discoverability tradeoff: staying inside the launcher's compilation and execution path carries inherent overhead (`GroovyShell` setup, the launcher's own startup machinery) that an external wrapper can avoid. The wrapper pays for this with a narrower, replacement-command surface and the classloader-identity caveat noted in [Possible Implementation Approach](#possible-implementation-approach) (for example, it cannot honor `@GrabConfig(systemClassLoader=true)`). Launcher integration remains the right choice for the transparent, `groovy myscript.groovy` use case this proposal targets, but the performance ceiling of an external wrapper should be weighed explicitly rather than dismissed on discoverability grounds alone. ### GroovyServ GroovyServ uses a resident warmed JVM plus a thin client. It can avoid JVM startup, runtime initialization, and repeated compilation costs, so its performance ceiling is substantially higher than that of a persistent bytecode cache. The tradeoff is a stateful daemon process: mutable runtime state may be shared across executions, environment and standard streams must be propagated, and lifecycle and listener security must be managed. A persistent launcher cache serves a different use case. Each invocation runs in a fresh JVM while still avoiding compilation work when a disk entry can be validated safely. The two approaches are complementary. ## Open Questions The following decisions should be resolved before the proposal advances from `Draft` to `Accepted`: * Should the proposed command-line options, diagnostic options, and system-property names be adopted as written? * What default cache-size limit and eviction policy should be used? * Should the first version provide a cache-size summary command, a cache-entry listing command, or both? A listing command may expose local script paths and should account for that privacy concern. * What is the most efficient classpath fingerprinting strategy that remains conservative enough for cache reuse? * What is the most efficient Groovy runtime distribution fingerprint that detects compilation-affecting changes without unnecessary cache misses? * Should the cache-aware component live in the launcher, `GroovyShell`, `GroovyClassLoader`, or a focused internal component used by them? ## Future Work Future extensions could include: * enabling the cache by default * cache statistics * integration with CDS or Leyden-style JVM caches * shared cache support for trusted environments * tooling integrations for workflows that invoke file-backed scripts * reusable public APIs for embedders * support for caching generated stubs where applicable * smarter dependency fingerprinting for `@Grab`, building on the resolved- classpath-caching approach validated by the `gsc` prototype (see [`@Grab`](#grab)) ## Conclusion A persistent script compilation cache would address a long-standing pain point for Groovy as a scripting language: repeated startup cost for short-lived scripts. By caching generated class files across invocations, the `groovy` launcher could avoid unnecessary repeated parsing, AST transformation, and bytecode generation when eligible scripts and their compilation inputs are unchanged. Implemented conservatively, this feature would preserve Groovy semantics while reducing compilation overhead for repeated command-line script execution. Its benefit should be evaluated alongside JVM startup improvements such as CDS and Project Leyden. ## Update History | Version | Date | Description | |---------|------------|---------------------------------------------------------------------------------------------------------------------------------------| | 0.3 | 2026-06-18 | Incorporated findings from the `gsc` external-wrapper prototype: a validated `@Grab` caching approach, a sharper comparison against the in-launcher design in Alternatives Considered, and a classloader-hierarchy-preservation requirement for cache-hit loading. | | 0.2 | 2026-06-02 | Refined conservative v1 scope, cache lifecycle, fingerprinting, security, diagnostics, implementation flow, and testing requirements. | | 0.1 | 2026-06-01 | Initial draft formatted as a Groovy Enhancement Proposal. |