Skip to content

Lazy single-recipe activation#7807

Merged
knutwannheden merged 4 commits into
mainfrom
lazy-single-recipe-activation-in-environment
May 28, 2026
Merged

Lazy single-recipe activation#7807
knutwannheden merged 4 commits into
mainfrom
lazy-single-recipe-activation-in-environment

Conversation

@knutwannheden
Copy link
Copy Markdown
Contributor

@knutwannheden knutwannheden commented May 28, 2026

Motivation

Environment.activateRecipes(name) is on the hot path of CLI startup. Wall-clock
profiling 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:

  • ~1 s walking class bytecode to build a className → superClassName map for
    isSubclassOf(name, Recipe) filtering (78% of which is Inflater.inflate on
    class file bodies).
  • ~870 ms instantiating every Recipe subclass found by the scan.
  • ~520 ms parsing every YAML file in the bundle.

For a single-recipe activation almost none of that work is needed: only the
requested recipe and the YAML files transitively referenced by its recipeList
have to be touched.

Summary

  • Drop the EXAMPLES RecipeDetail from the single-recipe call site in
    Environment.activateRecipes so YAML example loading is skipped.
  • Replace the eager Map<String, Recipe> nameToRecipe build over every
    dependencyResourceLoader.listRecipes() in Environment.loadRecipe with a
    lazy Function<String, Recipe> that searches dependency loaders by name on
    demand (deps still consulted first to preserve "deps win on name conflicts").
  • Split ClasspathScanningLoader.ensureScanned() into independently triggerable
    ensureClassesScanned() and ensureYamlScanned() 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.
  • listCategoryDescriptors and listRecipeExamples are wired to the YAML phase
    only — no class scan needed for YAML-only data.
  • YamlResourceLoader.loadRecipe no longer throws IllegalArgumentException
    on empty RecipeDetail varargs (exposed by dropping EXAMPLES).

Multi-recipe activation (activateRecipes(Collection) with more than one
entry) still goes through listRecipes() and is unchanged.

Behavior changes

  • Single-recipe activation returns a recipe with empty getExamples(); the
    multi-recipe path still attaches them. Affects marketplace describe().
  • Dependency declarative recipes can now resolve sub-recipes from primary
    resourceLoaders (previously sandboxed to deps).
  • Dependency declaratives are loaded without MAINTAINERS detail on the
    activation path; listRecipeDescriptors() still surfaces maintainers via the
    full-descriptor flow.
  • For duplicate recipe names across YAML files: progressive loadRecipe
    returns first-wins while a subsequent listRecipes() overwrites with
    last-wins (pre-PR was last-wins everywhere).

Test plan

  • ./gradlew :rewrite-core:test passes
  • ./gradlew :rewrite-java:test passes
  • New tests in ClasspathScanningLoaderTest:
    • singleImperativeRecipeActivationDoesNotScan — single imperative
      activation triggers neither class scan nor YAML enumeration.
    • singleDeclarativeRecipeActivationDoesNotScanClasses — single declarative
      activation triggers YAML listing but not class scan.
    • singleDeclarativeRecipeActivationDrainsOnlyUntilFirstMatch — progressive
      scan stops at first YAML file containing the recipe.

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.
@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite May 28, 2026
timtebeek and others added 2 commits May 28, 2026 12:10
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.
@knutwannheden knutwannheden changed the title Don't eagerly load examples when activating a single recipe Lazy single-recipe activation May 28, 2026
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.
@knutwannheden knutwannheden merged commit 5a94a24 into main May 28, 2026
1 check passed
@knutwannheden knutwannheden deleted the lazy-single-recipe-activation-in-environment branch May 28, 2026 13:14
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite May 28, 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.

2 participants