Skip to content

Commit 1df7d01

Browse files
committed
Also deduplicate singletons from DeclarativeRecipe descriptor
1 parent 39006a6 commit 1df7d01

2 files changed

Lines changed: 153 additions & 132 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,7 @@ protected RecipeDescriptor createRecipeDescriptor() {
696696
preconditionDescriptors.add(childRecipe.getDescriptor());
697697
}
698698
List<RecipeDescriptor> recipeDescriptors = new ArrayList<>();
699-
for (Recipe childRecipe : recipeList) {
699+
for (Recipe childRecipe : deduplicateSingletonsRecursively(recipeList, new HashSet<>())) {
700700
recipeDescriptors.add(childRecipe.getDescriptor());
701701
}
702702
return new RecipeDescriptor(getName(), getDisplayName(), getInstanceName(), getDescription() != null ? getDescription() : "",

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

Lines changed: 152 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -380,40 +380,41 @@ void getDataTableDescriptorsThreadSafe() throws Exception {
380380

381381
int threadCount = 10;
382382
int iterations = 100;
383-
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
384-
var startLatch = new CountDownLatch(1);
385-
var doneLatch = new CountDownLatch(threadCount);
386-
var errors = new ConcurrentLinkedQueue<Throwable>();
387-
388-
for (int i = 0; i < threadCount; i++) {
389-
final int threadIdx = i;
390-
executor.submit(() -> {
391-
try {
392-
startLatch.await();
393-
for (int j = 0; j < iterations; j++) {
394-
if (threadIdx % 2 == 0) {
395-
// Reader threads: iterate via getDataTableDescriptors and getDescriptor
396-
dr.getDataTableDescriptors();
397-
dr.getDescriptor();
398-
} else {
399-
// Writer threads: re-initialize to modify recipeList concurrently
400-
dr.addUninitialized(new Find("sam", null, null, null, null, null, null, null));
401-
dr.initialize(List.of());
383+
try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) {
384+
var startLatch = new CountDownLatch(1);
385+
var doneLatch = new CountDownLatch(threadCount);
386+
var errors = new ConcurrentLinkedQueue<Throwable>();
387+
388+
for (int i = 0; i < threadCount; i++) {
389+
final int threadIdx = i;
390+
executor.submit(() -> {
391+
try {
392+
startLatch.await();
393+
for (int j = 0; j < iterations; j++) {
394+
if (threadIdx % 2 == 0) {
395+
// Reader threads: iterate via getDataTableDescriptors and getDescriptor
396+
dr.getDataTableDescriptors();
397+
dr.getDescriptor();
398+
} else {
399+
// Writer threads: re-initialize to modify recipeList concurrently
400+
dr.addUninitialized(new Find("sam", null, null, null, null, null, null, null));
401+
dr.initialize(List.of());
402+
}
402403
}
404+
} catch (Throwable t) {
405+
errors.add(t);
406+
} finally {
407+
doneLatch.countDown();
403408
}
404-
} catch (Throwable t) {
405-
errors.add(t);
406-
} finally {
407-
doneLatch.countDown();
408-
}
409-
});
410-
}
409+
});
410+
}
411411

412-
startLatch.countDown();
413-
doneLatch.await();
414-
executor.shutdown();
412+
startLatch.countDown();
413+
doneLatch.await();
414+
executor.shutdown();
415415

416-
assertThat(errors).as("Concurrent access to getDataTableDescriptors/getDescriptor should not throw").isEmpty();
416+
assertThat(errors).as("Concurrent access to getDataTableDescriptors/getDescriptor should not throw").isEmpty();
417+
}
417418
}
418419

419420
@Test
@@ -427,32 +428,33 @@ void concurrentInitializeDoesNotDuplicateRecipes() throws Exception {
427428
int expectedSize = dr.getRecipeList().size();
428429

429430
int threadCount = 20;
430-
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
431-
var startLatch = new CountDownLatch(1);
432-
var doneLatch = new CountDownLatch(threadCount);
433-
var errors = new ConcurrentLinkedQueue<Throwable>();
434-
435-
for (int i = 0; i < threadCount; i++) {
436-
executor.submit(() -> {
437-
try {
438-
startLatch.await();
439-
for (int j = 0; j < 50; j++) {
440-
dr.initialize(List.of());
431+
try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) {
432+
var startLatch = new CountDownLatch(1);
433+
var doneLatch = new CountDownLatch(threadCount);
434+
var errors = new ConcurrentLinkedQueue<Throwable>();
435+
436+
for (int i = 0; i < threadCount; i++) {
437+
executor.submit(() -> {
438+
try {
439+
startLatch.await();
440+
for (int j = 0; j < 50; j++) {
441+
dr.initialize(List.of());
442+
}
443+
} catch (Throwable t) {
444+
errors.add(t);
445+
} finally {
446+
doneLatch.countDown();
441447
}
442-
} catch (Throwable t) {
443-
errors.add(t);
444-
} finally {
445-
doneLatch.countDown();
446-
}
447-
});
448-
}
448+
});
449+
}
449450

450-
startLatch.countDown();
451-
doneLatch.await();
452-
executor.shutdown();
451+
startLatch.countDown();
452+
doneLatch.await();
453+
executor.shutdown();
453454

454-
assertThat(errors).as("Concurrent initialize() should not throw").isEmpty();
455-
assertThat(dr.getRecipeList()).as("Concurrent initialize() should not duplicate recipes").hasSize(expectedSize);
455+
assertThat(errors).as("Concurrent initialize() should not throw").isEmpty();
456+
assertThat(dr.getRecipeList()).as("Concurrent initialize() should not duplicate recipes").hasSize(expectedSize);
457+
}
456458
}
457459

458460
@Test
@@ -541,49 +543,49 @@ public Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
541543
void selfReferencingRecipeDetectedAsCycle() {
542544
// Test that a recipe referencing itself is detected as a cycle
543545
var selfReferencing = new DeclarativeRecipe(
544-
"org.openrewrite.SelfReferencing",
545-
"Self Referencing Recipe",
546-
"A recipe that references itself",
547-
emptySet(),
548-
null,
549-
URI.create("test"),
550-
false,
551-
emptyList()
546+
"org.openrewrite.SelfReferencing",
547+
"Self Referencing Recipe",
548+
"A recipe that references itself",
549+
emptySet(),
550+
null,
551+
URI.create("test"),
552+
false,
553+
emptyList()
552554
);
553555

554556
// Add itself as a sub-recipe
555557
selfReferencing.addUninitialized("org.openrewrite.SelfReferencing");
556558

557559
// Initialize should throw RecipeIntrospectionException when cycle is detected
558560
assertThatThrownBy(() -> selfReferencing.initialize(List.of(selfReferencing)))
559-
.isInstanceOf(RecipeIntrospectionException.class)
560-
.hasMessageContaining("creates a cycle")
561-
.hasMessageContaining("org.openrewrite.SelfReferencing -> org.openrewrite.SelfReferencing");
561+
.isInstanceOf(RecipeIntrospectionException.class)
562+
.hasMessageContaining("creates a cycle")
563+
.hasMessageContaining("org.openrewrite.SelfReferencing -> org.openrewrite.SelfReferencing");
562564
}
563565

564566
@Test
565567
void mutuallyRecursiveRecipesDetectedAsCycle() {
566568
// Test that mutually recursive recipes are detected as a cycle
567569
var recipeA = new DeclarativeRecipe(
568-
"org.openrewrite.RecipeA",
569-
"Recipe A",
570-
"Recipe A that references Recipe B",
571-
emptySet(),
572-
null,
573-
URI.create("test"),
574-
false,
575-
emptyList()
570+
"org.openrewrite.RecipeA",
571+
"Recipe A",
572+
"Recipe A that references Recipe B",
573+
emptySet(),
574+
null,
575+
URI.create("test"),
576+
false,
577+
emptyList()
576578
);
577579

578580
var recipeB = new DeclarativeRecipe(
579-
"org.openrewrite.RecipeB",
580-
"Recipe B",
581-
"Recipe B that references Recipe A",
582-
emptySet(),
583-
null,
584-
URI.create("test"),
585-
false,
586-
emptyList()
581+
"org.openrewrite.RecipeB",
582+
"Recipe B",
583+
"Recipe B that references Recipe A",
584+
emptySet(),
585+
null,
586+
URI.create("test"),
587+
false,
588+
emptyList()
587589
);
588590

589591
// A references B
@@ -594,10 +596,10 @@ void mutuallyRecursiveRecipesDetectedAsCycle() {
594596

595597
// Initialize should throw RecipeIntrospectionException when cycle is detected
596598
assertThatThrownBy(() -> recipeA.initialize(List.of(recipeA, recipeB)))
597-
.isInstanceOf(RecipeIntrospectionException.class)
598-
.hasMessageContaining("creates a cycle")
599-
.hasMessageContaining("RecipeA")
600-
.hasMessageContaining("RecipeB");
599+
.isInstanceOf(RecipeIntrospectionException.class)
600+
.hasMessageContaining("creates a cycle")
601+
.hasMessageContaining("RecipeA")
602+
.hasMessageContaining("RecipeB");
601603
}
602604

603605
@Issue("https://github.com/openrewrite/rewrite/issues/6698")
@@ -786,36 +788,36 @@ void multipleNestedOrPreconditionsWithSameScanningRecipe() {
786788
void deeperCyclicReferencesDetectedAsCycle() {
787789
// Test that deeper cyclic references (A -> B -> C -> A) are detected as a cycle
788790
var recipeA = new DeclarativeRecipe(
789-
"org.openrewrite.RecipeA",
790-
"Recipe A",
791-
"Recipe A that references Recipe B",
792-
emptySet(),
793-
null,
794-
URI.create("test"),
795-
false,
796-
emptyList()
791+
"org.openrewrite.RecipeA",
792+
"Recipe A",
793+
"Recipe A that references Recipe B",
794+
emptySet(),
795+
null,
796+
URI.create("test"),
797+
false,
798+
emptyList()
797799
);
798800

799801
var recipeB = new DeclarativeRecipe(
800-
"org.openrewrite.RecipeB",
801-
"Recipe B",
802-
"Recipe B that references Recipe C",
803-
emptySet(),
804-
null,
805-
URI.create("test"),
806-
false,
807-
emptyList()
802+
"org.openrewrite.RecipeB",
803+
"Recipe B",
804+
"Recipe B that references Recipe C",
805+
emptySet(),
806+
null,
807+
URI.create("test"),
808+
false,
809+
emptyList()
808810
);
809811

810812
var recipeC = new DeclarativeRecipe(
811-
"org.openrewrite.RecipeC",
812-
"Recipe C",
813-
"Recipe C that references Recipe A",
814-
emptySet(),
815-
null,
816-
URI.create("test"),
817-
false,
818-
emptyList()
813+
"org.openrewrite.RecipeC",
814+
"Recipe C",
815+
"Recipe C that references Recipe A",
816+
emptySet(),
817+
null,
818+
URI.create("test"),
819+
false,
820+
emptyList()
819821
);
820822

821823
// A references B
@@ -829,12 +831,12 @@ void deeperCyclicReferencesDetectedAsCycle() {
829831

830832
// Initialize should throw RecipeIntrospectionException when cycle is detected
831833
assertThatThrownBy(() -> recipeA.initialize(List.of(recipeA, recipeB, recipeC)))
832-
.isInstanceOf(RecipeIntrospectionException.class)
833-
.hasMessageContaining("creates a cycle")
834-
// The cycle path should show A -> B -> C -> A
835-
.hasMessageContaining("RecipeA")
836-
.hasMessageContaining("RecipeB")
837-
.hasMessageContaining("RecipeC");
834+
.isInstanceOf(RecipeIntrospectionException.class)
835+
.hasMessageContaining("creates a cycle")
836+
// The cycle path should show A -> B -> C -> A
837+
.hasMessageContaining("RecipeA")
838+
.hasMessageContaining("RecipeB")
839+
.hasMessageContaining("RecipeC");
838840
}
839841

840842
// Uses separate RecipeRunCycles for scan and edit to verify that the accumulator
@@ -843,50 +845,69 @@ void deeperCyclicReferencesDetectedAsCycle() {
843845
@Test
844846
void scanningRecipeAccumulatorSurvivesAcrossRecipeRunCycles() {
845847
var recipe = new DeclarativeRecipe(
846-
"test.WithPrecondition", "With precondition", "Test with precondition.",
847-
emptySet(), null, URI.create("test"), true, emptyList()
848+
"test.WithPrecondition", "With precondition", "Test with precondition.",
849+
emptySet(), null, URI.create("test"), true, emptyList()
848850
);
849851
recipe.addPrecondition(new Singleton());
850852
recipe.addUninitialized(new CountingRecipe());
851853
recipe.initialize(List.of());
852854

853855
List<SourceFile> sources = List.of(
854-
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file1.txt")).text("hello").build(),
855-
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file2.txt")).text("world").build()
856+
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file1.txt")).text("hello").build(),
857+
PlainText.builder().id(Tree.randomId()).sourcePath(Paths.get("file2.txt")).text("world").build()
856858
);
857859

858860
var rootCursor = new Cursor(null, Cursor.ROOT_VALUE);
859861
var ctx = new WatchableExecutionContext(new InMemoryExecutionContext());
860862
Recipe noop = Recipe.noop();
861863

862864
// Cycle 1: scan (simulates first yield batch on the platform)
863-
var scanCycle = new RecipeRunCycle<LargeSourceSet>(
864-
recipe, 1, rootCursor, ctx,
865-
new RecipeRunStats(noop), new SearchResults(noop),
866-
new SourcesFileResults(noop), new SourcesFileErrors(noop),
867-
LargeSourceSet::edit
865+
var scanCycle = new RecipeRunCycle<>(
866+
recipe, 1, rootCursor, ctx,
867+
new RecipeRunStats(noop), new SearchResults(noop),
868+
new SourcesFileResults(noop), new SourcesFileErrors(noop),
869+
LargeSourceSet::edit
868870
);
869871
ctx.putCycle(scanCycle);
870872
scanCycle.scanSources(new InMemoryLargeSourceSet(sources));
871873

872874
// Cycle 2: edit (simulates new RecipeRunCycle after worker yield — new RecipeStack, fresh getRecipeList() calls)
873-
var editCycle = new RecipeRunCycle<LargeSourceSet>(
874-
recipe, 1, rootCursor, ctx,
875-
new RecipeRunStats(noop), new SearchResults(noop),
876-
new SourcesFileResults(noop), new SourcesFileErrors(noop),
877-
LargeSourceSet::edit
875+
var editCycle = new RecipeRunCycle<>(
876+
recipe, 1, rootCursor, ctx,
877+
new RecipeRunStats(noop), new SearchResults(noop),
878+
new SourcesFileResults(noop), new SourcesFileErrors(noop),
879+
LargeSourceSet::edit
878880
);
879881
ctx.putCycle(editCycle);
880882
LargeSourceSet edited = editCycle.editSources(new InMemoryLargeSourceSet(sources));
881883

882884
List<SourceFile> results = edited.getChangeset().getAllResults().stream()
883-
.map(Result::getAfter)
884-
.filter(Objects::nonNull)
885-
.toList();
885+
.map(Result::getAfter)
886+
.filter(Objects::nonNull)
887+
.toList();
886888

887889
assertThat(results).anyMatch(sf -> ((PlainText) sf).getText().contains("[scanned:"));
888890
}
889891

892+
@Test
893+
void recipeDescriptorFiltersSingletonDuplicates() {
894+
var leaf = new DeclarativeRecipe("leaf", "leaf", "", emptySet(),
895+
null, URI.create("test"), false, emptyList());
896+
leaf.addPrecondition(new Singleton());
897+
leaf.initialize(List.of());
898+
899+
var root = new DeclarativeRecipe("root", "root", "", emptySet(),
900+
null, URI.create("test"), false, emptyList());
901+
root.addUninitialized(leaf);
902+
root.addUninitialized(leaf);
903+
root.initialize(List.of());
904+
905+
assertThat(root.getDescriptor().getRecipeList())
906+
.hasSize(1)
907+
.first()
908+
.satisfies(r -> assertThat(r.getName()).isEqualTo("leaf"));
909+
}
910+
890911
static class CountingRecipe extends ScanningRecipe<List<String>> {
891912
@Override
892913
public String getDisplayName() {

0 commit comments

Comments
 (0)