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
# 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.                                                                             |

Reply via email to