Skip to content

Commit 6b7837d

Browse files
authored
Fix ScanningRecipe accumulator loss in preconditioned DeclarativeRecipes (#6913)
BellwetherDecoratedScanningRecipe gets a new UUID-based accumulator key each time it is instantiated. Since DeclarativeRecipe.getRecipeList() creates new wrapper instances on every call, a fresh RecipeStack (which triggers new getRecipeList() calls) causes accumulators stored under old keys to become unreachable. Override getAccumulator() to delegate to the wrapped recipe's stable UUID.
1 parent ff2c490 commit 6b7837d

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

rewrite-core/src/main/java/org/openrewrite/config/DeclarativeRecipe.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ static class BellwetherDecoratedScanningRecipe<T> extends ScanningRecipe<T> impl
365365
DeclarativeRecipe.PreconditionBellwether bellwether;
366366
ScanningRecipe<T> delegate;
367367

368+
@Override
369+
public T getAccumulator(Cursor cursor, ExecutionContext ctx) {
370+
return delegate.getAccumulator(cursor, ctx);
371+
}
372+
368373
@Override
369374
public T getInitialValue(ExecutionContext ctx) {
370375
return delegate.getInitialValue(ctx);

rewrite-core/src/test/java/org/openrewrite/config/DeclarativeRecipeTest.java

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@
2020
import org.jspecify.annotations.Nullable;
2121
import org.junit.jupiter.api.Test;
2222
import org.openrewrite.*;
23+
import org.openrewrite.internal.InMemoryLargeSourceSet;
2324
import org.openrewrite.marker.SearchResult;
25+
import org.openrewrite.scheduling.RecipeRunCycle;
26+
import org.openrewrite.scheduling.WatchableExecutionContext;
27+
import org.openrewrite.table.RecipeRunStats;
28+
import org.openrewrite.table.SearchResults;
29+
import org.openrewrite.table.SourcesFileErrors;
30+
import org.openrewrite.table.SourcesFileResults;
2431
import org.openrewrite.test.RewriteTest;
2532
import org.openrewrite.text.ChangeText;
2633
import org.openrewrite.text.Find;
@@ -29,7 +36,8 @@
2936

3037
import java.net.URI;
3138
import java.nio.file.Path;
32-
import java.util.List;
39+
import java.nio.file.Paths;
40+
import java.util.*;
3341
import java.util.concurrent.atomic.AtomicInteger;
3442

3543
import static java.util.Collections.emptyList;
@@ -740,4 +748,95 @@ void deeperCyclicReferencesDetectedAsCycle() {
740748
.hasMessageContaining("RecipeB")
741749
.hasMessageContaining("RecipeC");
742750
}
751+
752+
// Uses separate RecipeRunCycles for scan and edit to verify that the accumulator
753+
// survives when getRecipeList() is called again from a fresh RecipeStack.
754+
// This can't be tested via rewriteRun() which uses a single RecipeRunCycle per cycle.
755+
@Test
756+
void scanningRecipeAccumulatorSurvivesAcrossRecipeRunCycles() {
757+
DeclarativeRecipe recipe = new DeclarativeRecipe(
758+
"test.WithPrecondition", "With precondition", "Test with precondition.",
759+
emptySet(), null, URI.create("test"), true, emptyList()
760+
);
761+
recipe.addPrecondition(new Singleton());
762+
recipe.addUninitialized(new CountingRecipe());
763+
recipe.initialize(List.of());
764+
765+
List<SourceFile> sources = List.of(
766+
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file1.txt")).text("hello").build(),
767+
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file2.txt")).text("world").build()
768+
);
769+
770+
Cursor rootCursor = new Cursor(null, Cursor.ROOT_VALUE);
771+
WatchableExecutionContext ctx = new WatchableExecutionContext(new InMemoryExecutionContext());
772+
Recipe noop = Recipe.noop();
773+
774+
// Cycle 1: scan (simulates first yield batch on the platform)
775+
RecipeRunCycle<LargeSourceSet> scanCycle = new RecipeRunCycle<>(
776+
recipe, 1, rootCursor, ctx,
777+
new RecipeRunStats(noop), new SearchResults(noop),
778+
new SourcesFileResults(noop), new SourcesFileErrors(noop),
779+
LargeSourceSet::edit
780+
);
781+
ctx.putCycle(scanCycle);
782+
scanCycle.scanSources(new InMemoryLargeSourceSet(sources));
783+
784+
// Cycle 2: edit (simulates new RecipeRunCycle after worker yield — new RecipeStack, fresh getRecipeList() calls)
785+
RecipeRunCycle<LargeSourceSet> editCycle = new RecipeRunCycle<>(
786+
recipe, 1, rootCursor, ctx,
787+
new RecipeRunStats(noop), new SearchResults(noop),
788+
new SourcesFileResults(noop), new SourcesFileErrors(noop),
789+
LargeSourceSet::edit
790+
);
791+
ctx.putCycle(editCycle);
792+
LargeSourceSet edited = editCycle.editSources(new InMemoryLargeSourceSet(sources));
793+
794+
List<SourceFile> results = edited.getChangeset().getAllResults().stream()
795+
.map(Result::getAfter)
796+
.filter(Objects::nonNull)
797+
.toList();
798+
799+
assertThat(results).anyMatch(sf -> ((PlainText) sf).getText().contains("[scanned:"));
800+
}
801+
802+
static class CountingRecipe extends ScanningRecipe<List<String>> {
803+
@Override
804+
public String getDisplayName() {
805+
return "Counting recipe";
806+
}
807+
808+
@Override
809+
public String getDescription() {
810+
return "Counts files during scan, appends count during edit.";
811+
}
812+
813+
@Override
814+
public List<String> getInitialValue(ExecutionContext ctx) {
815+
return new ArrayList<>();
816+
}
817+
818+
@Override
819+
public TreeVisitor<?, ExecutionContext> getScanner(List<String> acc) {
820+
return new PlainTextVisitor<>() {
821+
@Override
822+
public PlainText visitText(PlainText text, ExecutionContext ctx) {
823+
acc.add(text.getText());
824+
return text;
825+
}
826+
};
827+
}
828+
829+
@Override
830+
public TreeVisitor<?, ExecutionContext> getVisitor(List<String> acc) {
831+
return new PlainTextVisitor<>() {
832+
@Override
833+
public PlainText visitText(PlainText text, ExecutionContext ctx) {
834+
if (!acc.isEmpty()) {
835+
return text.withText(text.getText() + " [scanned:" + acc.size() + "]");
836+
}
837+
return text;
838+
}
839+
};
840+
}
841+
}
743842
}

0 commit comments

Comments
 (0)