44 "context"
55 "errors"
66 "fmt"
7+ "sync/atomic"
78 "testing"
9+ "time"
810
911 "github.com/CrisisTextLine/modular"
1012)
@@ -19,6 +21,24 @@ func (s *foreachFailStep) Execute(_ context.Context, _ *PipelineContext) (*StepR
1921 return nil , errors .New ("step failed" )
2022}
2123
24+ // foreachSlowStep is a PipelineStep that sleeps for a fixed duration then succeeds.
25+ // It is used in timing-based concurrency tests to verify that items actually run
26+ // in parallel rather than sequentially.
27+ type foreachSlowStep struct {
28+ stepName string
29+ delay time.Duration
30+ }
31+
32+ func (s * foreachSlowStep ) Name () string { return s .stepName }
33+ func (s * foreachSlowStep ) Execute (ctx context.Context , _ * PipelineContext ) (* StepResult , error ) {
34+ select {
35+ case <- time .After (s .delay ):
36+ return & StepResult {Output : map [string ]any {"done" : true }}, nil
37+ case <- ctx .Done ():
38+ return nil , ctx .Err ()
39+ }
40+ }
41+
2242// buildTestForEachStep creates a ForEachStep with a fresh StepRegistry for testing.
2343// It registers a simple "step.set" factory so sub-steps can be built.
2444func buildTestForEachStep (t * testing.T , name string , config map [string ]any ) (PipelineStep , error ) {
@@ -545,45 +565,58 @@ func TestForEachStep_AppPassedToSubStep(t *testing.T) {
545565}
546566
547567func TestForEachStep_ConcurrentExecution (t * testing.T ) {
548- // 5 items each taking 100ms, concurrency=5 — should complete in ~100ms not 500ms
568+ // 5 items each taking 50ms, concurrency=5 — should complete in ~50ms not 250ms.
569+ // We use a custom slow step that sleeps to ensure actual concurrency is tested.
570+ const itemDelay = 50 * time .Millisecond
571+ const numItems = 5
572+
549573 registry := NewStepRegistry ()
550- registry .Register ("step.set" , NewSetStepFactory ())
574+ registry .Register ("step.slow" , func (name string , cfg map [string ]any , app modular.Application ) (PipelineStep , error ) {
575+ return & foreachSlowStep {stepName : name , delay : itemDelay }, nil
576+ })
551577
552578 factory := NewForEachStepFactory (func () * StepRegistry { return registry })
553579 step , err := factory ("par-foreach" , map [string ]any {
554580 "collection" : "items" ,
555581 "item_var" : "item" ,
556- "concurrency" : 5 ,
582+ "concurrency" : numItems , // full concurrency — all items run in parallel
557583 "step" : map [string ]any {
558584 "name" : "process" ,
559- "type" : "step.set" ,
560- "values" : map [string ]any {
561- "processed" : "true" ,
562- },
585+ "type" : "step.slow" ,
563586 },
564587 }, nil )
565588 if err != nil {
566589 t .Fatal (err )
567590 }
568591
569- items := make ([]any , 5 )
592+ items := make ([]any , numItems )
570593 for i := range items {
571594 items [i ] = map [string ]any {"id" : i }
572595 }
573596 pc := NewPipelineContext (map [string ]any {"items" : items }, nil )
574597
598+ start := time .Now ()
575599 result , err := step .Execute (context .Background (), pc )
600+ elapsed := time .Since (start )
576601 if err != nil {
577602 t .Fatal (err )
578603 }
579604
580605 results := result .Output ["results" ].([]any )
581- if len (results ) != 5 {
582- t .Fatalf ("expected 5 results, got %d" , len (results ))
606+ if len (results ) != numItems {
607+ t .Fatalf ("expected %d results, got %d" , numItems , len (results ))
583608 }
584609 count := result .Output ["count" ]
585- if count != 5 {
586- t .Fatalf ("expected count=5, got %v" , count )
610+ if count != numItems {
611+ t .Fatalf ("expected count=%d, got %v" , numItems , count )
612+ }
613+
614+ // With full concurrency the wall-clock time should be roughly one item's delay.
615+ // Allow 3× headroom for slow CI environments; reject if it took as long as sequential.
616+ maxExpected := itemDelay * 3
617+ if elapsed > time .Duration (numItems )* itemDelay {
618+ t .Fatalf ("concurrent execution took %v; expected <%v (sequential would be %v)" ,
619+ elapsed , maxExpected , time .Duration (numItems )* itemDelay )
587620 }
588621}
589622
@@ -706,8 +739,8 @@ func TestForEachStep_ConcurrencyZeroIsSequential(t *testing.T) {
706739 "item_var" : "item" ,
707740 "concurrency" : 0 ,
708741 "step" : map [string ]any {
709- "name" : "s" ,
710- "type" : "step.set" ,
742+ "name" : "s" ,
743+ "type" : "step.set" ,
711744 "values" : map [string ]any {"ok" : "true" },
712745 },
713746 }, nil )
@@ -755,3 +788,70 @@ func TestForEachStep_ForeachMapNotSetWhenConflict(t *testing.T) {
755788 t .Fatalf ("execute error: %v" , execErr )
756789 }
757790}
791+
792+ func TestForEachStep_ConcurrentFailFastStopsEarly (t * testing.T ) {
793+ // With fail_fast and slow remaining items, context cancellation should prevent
794+ // the producer from launching unnecessary goroutines after the first error.
795+ const itemDelay = 100 * time .Millisecond
796+ registry := NewStepRegistry ()
797+ // first item fails immediately; remaining items sleep
798+ callCount := int32 (0 )
799+ registry .Register ("step.slow_or_fail" , func (name string , cfg map [string ]any , app modular.Application ) (PipelineStep , error ) {
800+ return & foreachSlowOrFailStep {stepName : name , delay : itemDelay , callCount : & callCount }, nil
801+ })
802+
803+ factory := NewForEachStepFactory (func () * StepRegistry { return registry })
804+ step , err := factory ("ff-early-stop" , map [string ]any {
805+ "collection" : "items" ,
806+ "item_var" : "item" ,
807+ "concurrency" : 1 , // 1 worker so cancellation stops the queue
808+ "error_strategy" : "fail_fast" ,
809+ "step" : map [string ]any {
810+ "name" : "work" ,
811+ "type" : "step.slow_or_fail" ,
812+ },
813+ }, nil )
814+ if err != nil {
815+ t .Fatal (err )
816+ }
817+
818+ // 10 items; with concurrency=1 the producer should stop after the first failure.
819+ items := make ([]any , 10 )
820+ for i := range items {
821+ items [i ] = map [string ]any {"id" : i }
822+ }
823+ pc := NewPipelineContext (map [string ]any {"items" : items }, nil )
824+
825+ start := time .Now ()
826+ _ , err = step .Execute (context .Background (), pc )
827+ elapsed := time .Since (start )
828+ if err == nil {
829+ t .Fatal ("expected error with fail_fast" )
830+ }
831+ // If context cancellation works, we should not execute all 10 slow items.
832+ // Sequential execution of all 10 would take ~1s; early stop should be much faster.
833+ if elapsed >= time .Duration (len (items ))* itemDelay {
834+ t .Fatalf ("fail_fast did not stop early: took %v (expected less than %v)" , elapsed , time .Duration (len (items ))* itemDelay )
835+ }
836+ }
837+
838+ // foreachSlowOrFailStep fails on the first call and sleeps on subsequent calls.
839+ type foreachSlowOrFailStep struct {
840+ stepName string
841+ delay time.Duration
842+ callCount * int32
843+ }
844+
845+ func (s * foreachSlowOrFailStep ) Name () string { return s .stepName }
846+ func (s * foreachSlowOrFailStep ) Execute (ctx context.Context , _ * PipelineContext ) (* StepResult , error ) {
847+ n := atomic .AddInt32 (s .callCount , 1 )
848+ if n == 1 {
849+ return nil , fmt .Errorf ("first item fails immediately" )
850+ }
851+ select {
852+ case <- time .After (s .delay ):
853+ return & StepResult {Output : map [string ]any {"done" : true }}, nil
854+ case <- ctx .Done ():
855+ return nil , ctx .Err ()
856+ }
857+ }
0 commit comments