Skip to content

Cache delegate recipes per recipe instance to avoid repeated construction#184

Merged
knutwannheden merged 1 commit into
mainfrom
cosmic-pangolin
Jun 21, 2026
Merged

Cache delegate recipes per recipe instance to avoid repeated construction#184
knutwannheden merged 1 commit into
mainfrom
cosmic-pangolin

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Motivation

The Gradle/Maven dependency wrapper recipes — AddDependency, ChangeDependency, UpgradeDependencyVersion, and UpgradeTransitiveDependencyVersion — each delegate to a Gradle and a Maven ScanningRecipe. Until now they constructed a fresh delegate instance on every getInitialValue, getScanner, getVisitor, and validate call, and AddDependency.getScanner even constructed its delegates once per source file from inside the scanner's visit. Every ScanningRecipe constructor computes "org.openrewrite.recipe.acc." + UUID.randomUUID() for its accumulator-message key, and UUID.randomUUID() draws from SecureRandom. In profiles of recipe-heavy runs this SecureRandom draw dominated, with these wrapper recipes accounting for nearly all of the cost.

The fix is to build each delegate at most once and reuse it. The delegate recipe is derived from the wrapper's options, so it logically belongs to the wrapper recipe instance, not to the run-scoped accumulator. Each wrapper memoizes its two delegates lazily in a final transient AtomicReferencefinal and initialized so it is excluded from the Lombok-generated constructor, transient so it stays out of equals/hashCode, and AtomicReference for safe publication under concurrent getScanner/getVisitor calls. The Accumulator types are deliberately left unchanged so their public constructors keep working for downstream callers that build them directly (for example rewrite-spring, which constructs UpgradeDependencyVersion.Accumulator itself).

Caching on the instance rather than the accumulator also avoids a subtle correctness trap: rewrite-spring reuses a single accumulator across two different UpgradeDependencyVersion instances — an empty-option one for scanning and a real-option one for editing — so a delegate cached on the accumulator would apply the wrong options during the edit phase. Instance memoization gives each wrapper its own correctly-configured delegate.

Summary

  • Each wrapper memoizes its Gradle and Maven delegate ScanningRecipe lazily on the recipe instance via a final transient AtomicReference, reused across the scanning and editing phases.
  • Delegate construction per wrapper per run drops from "once per phase (and, for AddDependency, per source file)" to at most one Gradle and one Maven delegate per wrapper instance.
  • Hoisted AddDependency.getScanner's delegate-scanner creation out of the per-source-file visit.
  • Accumulator types are unchanged, preserving their public constructors.

Test plan

  • Re-ran AddDependencyTest, ChangeDependencyTest, and UpgradeDependencyVersionTest — green before and after.
  • Ran the full test suite — green.
  • Published to Maven Local and rebuilt rewrite-spring, which constructs UpgradeDependencyVersion.Accumulator directly — compiles successfully, confirming the public accumulator API is preserved.

…tion

The Gradle/Maven dependency wrapper recipes (`AddDependency`, `ChangeDependency`, `UpgradeDependencyVersion`, `UpgradeTransitiveDependencyVersion`) each delegate to a Gradle and a Maven `ScanningRecipe`. Previously a fresh delegate instance was constructed on every `getInitialValue`/`getScanner`/`getVisitor`/`validate` call (and, in `AddDependency`, once per source file inside the scanner's `visit`).

Each `ScanningRecipe` construction runs `UUID.randomUUID()` for its accumulator-message key, which draws from `SecureRandom` and dominated profiles of recipe-heavy runs.

Memoize each delegate lazily on the wrapper recipe instance (a `final transient AtomicReference` that is excluded from the generated constructor and from `equals`/`hashCode`) so it is built at most once per wrapper instance and reused across the scanning and editing phases. The delegate is derived from the wrapper's options, so it belongs to the recipe instance rather than the accumulator: callers such as `rewrite-spring` deliberately reuse a single accumulator across two wrapper instances that have different options, so caching the delegate on the accumulator would reuse the wrong options.

The `Accumulator` types are left unchanged, preserving their public constructors for downstream callers that build them directly.
@knutwannheden knutwannheden changed the title Cache delegate recipes in the accumulator to avoid repeated construction Cache delegate recipes per recipe instance to avoid repeated construction Jun 18, 2026
@knutwannheden knutwannheden merged commit cea69f1 into main Jun 21, 2026
1 check passed
@knutwannheden knutwannheden deleted the cosmic-pangolin branch June 21, 2026 21:00
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant