Skip to content

Make DeclarativeRecipe.accumulator thread-safe with ThreadLocal#6810

Merged
sjungling merged 2 commits intomainfrom
fix/declarative-recipe-thread-safety
Feb 24, 2026
Merged

Make DeclarativeRecipe.accumulator thread-safe with ThreadLocal#6810
sjungling merged 2 commits intomainfrom
fix/declarative-recipe-thread-safety

Conversation

@kmccarp
Copy link
Copy Markdown
Contributor

@kmccarp kmccarp commented Feb 24, 2026

What's Changed

DeclarativeRecipe.accumulator is a mutable field that gets set during the scan phase via getInitialValue(). When the same DeclarativeRecipe instance is shared across concurrent recipe runs (e.g. via a recipe cache in the Moderne worker), one run's scan phase overwrites the accumulator set by another run. This causes the second run to use stale or null accumulator state during the edit phase, resulting in precondition scanning recipes producing incorrect results — typically 0 file changes for the losing run.

Changes

  • Changed accumulator field from Accumulator to ThreadLocal<Accumulator> so each run thread gets its own independent accumulator
  • Updated getInitialValue() to use accumulator.set(acc)
  • Updated orVisitors() to use accumulator.get() with a null guard
  • Overrode clone() to create a fresh ThreadLocal for cloned instances

How to Reproduce

  1. Configure a worker with a recipe cache that returns the same DeclarativeRecipe instance for the same recipe ID
  2. Run the same recipe (e.g. UpgradeSpringBoot_3_4) concurrently via two paths — one direct catalog run, one wrapped in a builder/YAML declarative recipe
  3. Both paths resolve to the same underlying recipe instances from the cache
  4. The catalog run produces correct results; the builder run produces 0 file changes

Test Plan

  • rewrite-core:test passes
  • Verified concurrent catalog + builder runs against local Moderne stack — builder run now produces results instead of 0 changes

DeclarativeRecipe.accumulator was a plain field that gets set during the
scan phase via getInitialValue(). When the same DeclarativeRecipe instance
is shared across concurrent recipe runs (e.g. via a recipe cache), one
run's scan phase overwrites the accumulator set by another run. This
causes the second run to use stale or null accumulator state during the
edit phase, resulting in precondition scanning recipes producing incorrect
results (typically 0 file changes).

This changes the accumulator field to a ThreadLocal so each run thread
gets its own independent accumulator. The clone() method is also
overridden to create a fresh ThreadLocal for cloned instances.
Copy link
Copy Markdown
Member

@zieka zieka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is ThreadLocal.remove() ever called?

@kmccarp
Copy link
Copy Markdown
Contributor Author

kmccarp commented Feb 24, 2026

is ThreadLocal.remove() ever called?

it is now

@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite Feb 24, 2026
@sjungling sjungling merged commit 447d382 into main Feb 24, 2026
1 check passed
@sjungling sjungling deleted the fix/declarative-recipe-thread-safety branch February 24, 2026 20:27
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite Feb 24, 2026
macsux pushed a commit that referenced this pull request Feb 27, 2026
* Make DeclarativeRecipe.accumulator thread-safe with ThreadLocal

DeclarativeRecipe.accumulator was a plain field that gets set during the
scan phase via getInitialValue(). When the same DeclarativeRecipe instance
is shared across concurrent recipe runs (e.g. via a recipe cache), one
run's scan phase overwrites the accumulator set by another run. This
causes the second run to use stale or null accumulator state during the
edit phase, resulting in precondition scanning recipes producing incorrect
results (typically 0 file changes).

This changes the accumulator field to a ThreadLocal so each run thread
gets its own independent accumulator. The clone() method is also
overridden to create a fresh ThreadLocal for cloned instances.

* Clean up ThreadLocal in onComplete() to prevent memory leaks
pstreef added a commit to pstreef/rewrite that referenced this pull request Mar 10, 2026
…veRecipe

PR openrewrite#6810 changed DeclarativeRecipe.accumulator from a plain field to
ThreadLocal to fix concurrent recipe runs overwriting each other's
accumulator. However, ThreadLocal values are lost when execution
resumes on a different thread after yielding.

The accumulator is already stored on the rootCursor by
ScanningRecipe.getAccumulator(), which is per-run and survives thread
switches. This change removes the redundant ThreadLocal storage and
instead resolves the accumulator from the rootCursor at visit time.

The PreconditionBellwether now defers precondition resolution to
visit time (when the rootCursor is available) rather than eagerly
resolving in a field initializer.
pstreef added a commit to pstreef/rewrite that referenced this pull request Mar 11, 2026
…veRecipe

PR openrewrite#6810 changed DeclarativeRecipe.accumulator from a plain field to
ThreadLocal to fix concurrent recipe runs overwriting each other's
accumulator. However, ThreadLocal values are lost when execution
resumes on a different thread after yielding.

The accumulator is already stored on the rootCursor by
ScanningRecipe.getAccumulator(), which is per-run and survives thread
switches. This change removes the redundant ThreadLocal storage and
instead resolves the accumulator from the rootCursor at visit time.

The PreconditionBellwether now defers precondition resolution to
visit time (when the rootCursor is available) rather than eagerly
resolving in a field initializer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

4 participants