Lazy single-recipe activation#7807
Merged
knutwannheden merged 4 commits intoMay 28, 2026
Merged
Conversation
Environment.activateRecipes(name) called loadRecipe(name, EXAMPLES) for the single-recipe path, which forces every YamlResourceLoader to load all recipe examples just to attach the requested recipe's examples. That work is on the hot path of CLI startup and the examples are not needed at activation time (callers fetch them on demand via Recipe.getExamples() if at all). Drop EXAMPLES from the single-recipe activation call. The multi-recipe path is unchanged.
timtebeek
approved these changes
May 28, 2026
EnumSet.copyOf throws IllegalArgumentException when given an empty collection. After the previous commit dropped EXAMPLES from the single-recipe activation path, loadRecipe started receiving an empty details array and every single-recipe YAML activation blew up. Fall back to EnumSet.noneOf when details is empty.
Environment.activateRecipes(name) is on the hot path of CLI startup.
For a single recipe — imperative or declarative — it should not have
to walk the entire classpath, parse every YAML file, or eagerly load
every recipe defined in dependency bundles.
Two changes:
1. Lazy dependency-side recipe loading in Environment.loadRecipe.
The eager loop that called listRecipes() on every dependency
resource loader (and initialized every dependency declarative
recipe up front) is replaced with a single Function<String, Recipe>
that searches dependency loaders by name on demand and caches
results. Dependencies are still consulted first to preserve the
"deps win on name conflicts" priority that the previous
putIfAbsent semantics established.
2. Split scan + progressive YAML drain in ClasspathScanningLoader.
ensureScanned() used to bundle the class hierarchy walk
(configureRecipesAndStyles) and the full YAML extraction into one
atomic step. Now they are independently triggerable:
- ensureClassesScanned(): walks class bytecode (~1 s on a large
bundle); needed by listRecipes / listStyles / listRecipeDescriptors.
- ensureYamlListed(): enumerates META-INF/rewrite/*.yml and
constructs one YamlResourceLoader per file. Cheap; no YAML
parsing yet.
- drainNextYamlLoader(): parses one YAML loader and merges its
contents into the shared maps. Called progressively from
loadRecipe until either the requested recipe turns up in the
map or every loader has been drained.
loadRecipe(name) now tries the imperative recipeLoader.load fast
path first, then short-circuits through the YAML loaders one at a
time. For Assertj (declarative, 888 sub-recipes) this avoids the
class scan entirely and bounds YAML parsing to "files needed to
resolve the activated recipeList".
listCategoryDescriptors and listRecipeExamples are wired to
ensureYamlScanned (YAML-only), not ensureScanned, so listing
examples no longer forces the class scan.
New tests in ClasspathScanningLoaderTest assert:
- Single imperative recipe activation triggers neither the class
scan nor the YAML enumeration.
- Single declarative recipe activation triggers only YAML listing,
not the class scan.
- Progressive scan stops at the first YAML file containing the
requested recipe.
Environment.listRecipeDescriptors calls ClasspathScanningLoader's listRecipeExamples before listRecipeDescriptors. With the new lazy phase split, listRecipeExamples triggered ensureYamlScanned which in turn extracted YAML recipe descriptors using recipes.values() — but the class scan hadn't run yet, so recipes only held declarative entries. Imperative recipes (e.g. org.openrewrite.text.Find) ended up missing from the returned descriptors, breaking marketplace descriptor enumeration in rewrite-maven. Split the descriptor extraction out of drainAllYamlLoaders into its own extractYamlRecipeDescriptors step, and only invoke it from listRecipeDescriptors after both phases of ensureScanned have run. drainAllYamlLoaders now only populates the shared recipes/styles/ categories/examples maps, leaving descriptor computation for callers that genuinely need it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Environment.activateRecipes(name)is on the hot path of CLI startup. Wall-clockprofiling of
mod run --recipe=org.openrewrite.java.testing.assertj.Assertj(declarative recipe with 888 sub-recipes) showed the prepare() window takes
~2.5 s on the first repo, broken down as:
className → superClassNamemap forisSubclassOf(name, Recipe)filtering (78% of which isInflater.inflateonclass file bodies).
For a single-recipe activation almost none of that work is needed: only the
requested recipe and the YAML files transitively referenced by its
recipeListhave to be touched.
Summary
EXAMPLESRecipeDetailfrom the single-recipe call site inEnvironment.activateRecipesso YAML example loading is skipped.Map<String, Recipe> nameToRecipebuild over everydependencyResourceLoader.listRecipes()inEnvironment.loadRecipewith alazy
Function<String, Recipe>that searches dependency loaders by name ondemand (deps still consulted first to preserve "deps win on name conflicts").
ClasspathScanningLoader.ensureScanned()into independently triggerableensureClassesScanned()andensureYamlScanned()phases.loadRecipe(name)now tries an imperative fast path (recipeLoader.load),then progressively drains YAML loaders one at a time until either the recipe
turns up in the shared map or every loader has been parsed.
listCategoryDescriptorsandlistRecipeExamplesare wired to the YAML phaseonly — no class scan needed for YAML-only data.
YamlResourceLoader.loadRecipeno longer throwsIllegalArgumentExceptionon empty
RecipeDetailvarargs (exposed by droppingEXAMPLES).Multi-recipe activation (
activateRecipes(Collection)with more than oneentry) still goes through
listRecipes()and is unchanged.Behavior changes
getExamples(); themulti-recipe path still attaches them. Affects marketplace
describe().resourceLoaders(previously sandboxed to deps).MAINTAINERSdetail on theactivation path;
listRecipeDescriptors()still surfaces maintainers via thefull-descriptor flow.
loadRecipereturns first-wins while a subsequent
listRecipes()overwrites withlast-wins (pre-PR was last-wins everywhere).
Test plan
./gradlew :rewrite-core:testpasses./gradlew :rewrite-java:testpassesClasspathScanningLoaderTest:singleImperativeRecipeActivationDoesNotScan— single imperativeactivation triggers neither class scan nor YAML enumeration.
singleDeclarativeRecipeActivationDoesNotScanClasses— single declarativeactivation triggers YAML listing but not class scan.
singleDeclarativeRecipeActivationDrainsOnlyUntilFirstMatch— progressivescan stops at first YAML file containing the recipe.