@@ -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