@@ -57,6 +57,47 @@ public async Task ExecuteAsync_ThrownStepException_ReturnsFailedResultWithExcept
5757 result . Messages . ShouldContain ( "boom" ) ;
5858 }
5959
60+ [ Fact ]
61+ public async Task ExecuteAsync_FailedResult_DefaultStopsRemainingSteps ( )
62+ {
63+ var services = CreateServices ( ) ;
64+ services . AddPipelines ( )
65+ . WithPipeline < ContinueOnFailurePipeline > ( ) ;
66+
67+ var provider = services . BuildServiceProvider ( ) ;
68+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
69+ . Create < ContinueOnFailurePipeline , TestContext > ( ) ;
70+ var context = new TestContext ( ) ;
71+
72+ var result = await pipeline . ExecuteAsync ( context ) ;
73+
74+ result . IsFailure . ShouldBeTrue ( ) ;
75+ result . Messages . ShouldContain ( "failed-step" ) ;
76+ context . AfterFailureExecuted . ShouldBeFalse ( ) ;
77+ context . Pipeline . ExecutedStepCount . ShouldBe ( 1 ) ;
78+ }
79+
80+ [ Fact ]
81+ public async Task ExecuteAsync_FailedResult_WhenContinueOnFailure_ContinuesRemainingSteps ( )
82+ {
83+ var services = CreateServices ( ) ;
84+ services . AddPipelines ( )
85+ . WithPipeline < ContinueOnFailurePipeline > ( ) ;
86+
87+ var provider = services . BuildServiceProvider ( ) ;
88+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
89+ . Create < ContinueOnFailurePipeline , TestContext > ( ) ;
90+ var context = new TestContext ( ) ;
91+
92+ var result = await pipeline . ExecuteAsync ( context , builder => builder . ContinueOnFailure ( ) ) ;
93+
94+ result . IsFailure . ShouldBeTrue ( ) ;
95+ result . Messages . ShouldContain ( "failed-step" ) ;
96+ result . Messages . ShouldContain ( "after-failure" ) ;
97+ context . AfterFailureExecuted . ShouldBeTrue ( ) ;
98+ context . Pipeline . ExecutedStepCount . ShouldBe ( 2 ) ;
99+ }
100+
60101 [ Fact ]
61102 public async Task ExecuteAsync_Retry_ReexecutesStepUntilSuccess ( )
62103 {
@@ -78,6 +119,26 @@ public async Task ExecuteAsync_Retry_ReexecutesStepUntilSuccess()
78119 context . Pipeline . ExecutedStepCount . ShouldBe ( 2 ) ;
79120 }
80121
122+ [ Fact ]
123+ public async Task ExecuteAsync_RetryExhaustion_ReturnsFailedResultWithRetryError ( )
124+ {
125+ var services = CreateServices ( ) ;
126+ services . AddPipelines ( )
127+ . WithPipeline < RetryExhaustionPipeline > ( ) ;
128+
129+ var provider = services . BuildServiceProvider ( ) ;
130+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
131+ . Create < RetryExhaustionPipeline , TestContext > ( ) ;
132+ var context = new TestContext ( ) ;
133+
134+ var result = await pipeline . ExecuteAsync ( context , builder => builder . MaxRetryAttemptsPerStep ( 2 ) ) ;
135+
136+ result . IsFailure . ShouldBeTrue ( ) ;
137+ result . Errors . OfType < Error > ( ) . Any ( e => e . Message . Contains ( "exhausted retry attempts" ) ) . ShouldBeTrue ( ) ;
138+ context . RetryAttempts . ShouldBe ( 3 ) ;
139+ context . Pipeline . ExecutedStepCount . ShouldBe ( 3 ) ;
140+ }
141+
81142 [ Fact ]
82143 public async Task ExecuteAsync_Break_StopsRemainingSteps ( )
83144 {
@@ -97,6 +158,46 @@ public async Task ExecuteAsync_Break_StopsRemainingSteps()
97158 context . Pipeline . ExecutedStepCount . ShouldBe ( 2 ) ;
98159 }
99160
161+ [ Fact ]
162+ public async Task ExecuteAsync_AccumulateDiagnosticsOnFailureFalse_StripsFailureDiagnostics ( )
163+ {
164+ var services = CreateServices ( ) ;
165+ services . AddPipelines ( )
166+ . WithPipeline < ContinueOnFailurePipeline > ( ) ;
167+
168+ var provider = services . BuildServiceProvider ( ) ;
169+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
170+ . Create < ContinueOnFailurePipeline , TestContext > ( ) ;
171+
172+ var result = await pipeline . ExecuteAsync (
173+ new TestContext ( ) ,
174+ builder => builder . AccumulateDiagnosticsOnFailure ( false ) ) ;
175+
176+ result . IsFailure . ShouldBeTrue ( ) ;
177+ result . Messages . ShouldBeEmpty ( ) ;
178+ result . Errors . ShouldBeEmpty ( ) ;
179+ }
180+
181+ [ Fact ]
182+ public async Task ExecuteAsync_AccumulateDiagnosticsOnBreakFalse_StripsBreakDiagnostics ( )
183+ {
184+ var services = CreateServices ( ) ;
185+ services . AddPipelines ( )
186+ . WithPipeline < BreakDiagnosticsPipeline > ( ) ;
187+
188+ var provider = services . BuildServiceProvider ( ) ;
189+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
190+ . Create < BreakDiagnosticsPipeline , TestContext > ( ) ;
191+
192+ var result = await pipeline . ExecuteAsync (
193+ new TestContext ( ) ,
194+ builder => builder . AccumulateDiagnosticsOnBreak ( false ) ) ;
195+
196+ result . IsSuccess . ShouldBeTrue ( ) ;
197+ result . Messages . ShouldBeEmpty ( ) ;
198+ result . Errors . ShouldBeEmpty ( ) ;
199+ }
200+
100201 [ Fact ]
101202 public async Task ExecuteAsync_InlineSteps_WorkForSyncAsyncAndContextAwareDelegates ( )
102203 {
@@ -134,6 +235,40 @@ public async Task ExecuteAsync_InlineSteps_WorkForSyncAsyncAndContextAwareDelega
134235 context . Pipeline . ExecutedStepCount . ShouldBe ( 5 ) ;
135236 }
136237
238+ [ Fact ]
239+ public async Task ExecuteAsync_ProgressReporter_IsExposedToClassAndInlineSteps ( )
240+ {
241+ var services = CreateServices ( ) ;
242+ var progress = new RecordingProgress ( ) ;
243+
244+ services . AddPipelines ( )
245+ . WithPipeline < TestContext > ( "progress" , builder => builder
246+ . AddStep < ProgressStep > ( )
247+ . AddStep ( execution =>
248+ {
249+ execution . Options . Progress ? . Report ( new ProgressReport (
250+ execution . Name ,
251+ [ "inline-progress" ] ,
252+ 100 ,
253+ isCompleted : true ) ) ;
254+
255+ return execution . Continue ( ) ;
256+ } ) ) ;
257+
258+ var provider = services . BuildServiceProvider ( ) ;
259+ var pipeline = provider . GetRequiredService < IPipelineFactory > ( )
260+ . Create < TestContext > ( "progress" ) ;
261+
262+ var result = await pipeline . ExecuteAsync (
263+ new TestContext ( ) ,
264+ builder => builder . WithProgress ( progress ) ) ;
265+
266+ result . IsSuccess . ShouldBeTrue ( ) ;
267+ progress . Reports . Count . ShouldBe ( 2 ) ;
268+ progress . Reports [ 0 ] . Messages . ShouldContain ( "class-progress" ) ;
269+ progress . Reports [ 1 ] . Messages . ShouldContain ( "inline-progress" ) ;
270+ }
271+
137272 [ Fact ]
138273 public async Task ExecuteAsync_BaseContextHookAndBehavior_AreAppliedToDerivedContextPipeline ( )
139274 {
@@ -204,6 +339,51 @@ public async Task ExecuteAndForgetAsync_TracksExecutionAndInvokesCompletionCallb
204339 completion . Status . ShouldBe ( PipelineExecutionStatus . Completed ) ;
205340 }
206341
342+ [ Fact ]
343+ public async Task ExecuteAndForgetAsync_CompletionCallbackFailure_DoesNotOverwriteCompletedSnapshot ( )
344+ {
345+ var services = CreateServices ( ) ;
346+ services . AddPipelines ( )
347+ . WithPipeline < BackgroundPipeline > ( ) ;
348+
349+ var provider = services . BuildServiceProvider ( ) ;
350+ var factory = provider . GetRequiredService < IPipelineFactory > ( ) ;
351+ var tracker = provider . GetRequiredService < IPipelineExecutionTracker > ( ) ;
352+ var pipeline = factory . Create < BackgroundPipeline , TestContext > ( ) ;
353+ var callbackSource = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
354+ PipelineExecutionSnapshot snapshotObservedInCallback = null ;
355+
356+ var handle = await pipeline . ExecuteAndForgetAsync (
357+ new TestContext ( ) ,
358+ builder => builder . WhenCompleted ( async completion =>
359+ {
360+ snapshotObservedInCallback = await tracker . GetAsync ( completion . ExecutionId ) ;
361+ callbackSource . TrySetResult ( ) ;
362+ throw new InvalidOperationException ( "callback boom" ) ;
363+ } ) ) ;
364+
365+ await callbackSource . Task . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
366+
367+ PipelineExecutionSnapshot snapshot = null ;
368+ for ( var i = 0 ; i < 40 ; i ++ )
369+ {
370+ snapshot = await tracker . GetAsync ( handle . ExecutionId ) ;
371+ if ( snapshot ? . Status == PipelineExecutionStatus . Completed )
372+ {
373+ break ;
374+ }
375+
376+ await Task . Delay ( 25 ) ;
377+ }
378+
379+ snapshot . ShouldNotBeNull ( ) ;
380+ snapshot . Status . ShouldBe ( PipelineExecutionStatus . Completed ) ;
381+ snapshot . Result . IsSuccess . ShouldBeTrue ( ) ;
382+ snapshotObservedInCallback . ShouldNotBeNull ( ) ;
383+ snapshotObservedInCallback . Status . ShouldBe ( PipelineExecutionStatus . Completed ) ;
384+ snapshotObservedInCallback . Result . IsSuccess . ShouldBeTrue ( ) ;
385+ }
386+
207387 [ Fact ]
208388 public async Task ExecuteAsync_TracingBehavior_CreatesPipelineAndStepActivities ( )
209389 {
@@ -251,6 +431,8 @@ public sealed class TestContext : PipelineContextBase
251431
252432 public bool AfterBreakExecuted { get ; set ; }
253433
434+ public bool AfterFailureExecuted { get ; set ; }
435+
254436 public int ContextStepCount { get ; set ; }
255437 }
256438
@@ -289,6 +471,32 @@ protected override void Configure(IPipelineDefinitionBuilder<TestContext> builde
289471 }
290472 }
291473
474+ public sealed class ContinueOnFailurePipeline : PipelineDefinition < TestContext >
475+ {
476+ protected override void Configure ( IPipelineDefinitionBuilder < TestContext > builder )
477+ {
478+ builder . AddStep < FailedResultStep > ( )
479+ . AddStep < AfterFailureStep > ( ) ;
480+ }
481+ }
482+
483+ public sealed class RetryExhaustionPipeline : PipelineDefinition < TestContext >
484+ {
485+ protected override void Configure ( IPipelineDefinitionBuilder < TestContext > builder )
486+ {
487+ builder . AddStep < AlwaysRetryStep > ( ) ;
488+ }
489+ }
490+
491+ public sealed class BreakDiagnosticsPipeline : PipelineDefinition < TestContext >
492+ {
493+ protected override void Configure ( IPipelineDefinitionBuilder < TestContext > builder )
494+ {
495+ builder . AddStep < BreakWithDiagnosticsStep > ( )
496+ . AddStep < AfterBreakStep > ( ) ;
497+ }
498+ }
499+
292500 public sealed class BackgroundPipeline : PipelineDefinition < TestContext >
293501 {
294502 protected override void Configure ( IPipelineDefinitionBuilder < TestContext > builder )
@@ -322,6 +530,26 @@ protected override PipelineControl Execute(TestContext context, Result result, P
322530 }
323531 }
324532
533+ public sealed class FailedResultStep : PipelineStep < TestContext >
534+ {
535+ protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
536+ {
537+ return PipelineControl . Continue (
538+ Result . Failure ( )
539+ . WithMessage ( "failed-step" )
540+ . WithError ( new Error ( "failed-error" ) ) ) ;
541+ }
542+ }
543+
544+ public sealed class AfterFailureStep : PipelineStep < TestContext >
545+ {
546+ protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
547+ {
548+ context . AfterFailureExecuted = true ;
549+ return PipelineControl . Continue ( result . WithMessage ( "after-failure" ) ) ;
550+ }
551+ }
552+
325553 public sealed class RetryStep : PipelineStep < TestContext >
326554 {
327555 protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
@@ -333,6 +561,15 @@ protected override PipelineControl Execute(TestContext context, Result result, P
333561 }
334562 }
335563
564+ public sealed class AlwaysRetryStep : PipelineStep < TestContext >
565+ {
566+ protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
567+ {
568+ context . RetryAttempts ++ ;
569+ return PipelineControl . Retry ( result . WithMessage ( $ "retry-{ context . RetryAttempts } ") , "retry" ) ;
570+ }
571+ }
572+
336573 public sealed class BreakStep : PipelineStep < TestContext >
337574 {
338575 protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
@@ -341,6 +578,14 @@ protected override PipelineControl Execute(TestContext context, Result result, P
341578 }
342579 }
343580
581+ public sealed class BreakWithDiagnosticsStep : PipelineStep < TestContext >
582+ {
583+ protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
584+ {
585+ return PipelineControl . Break ( result . WithMessage ( "break-diagnostic" ) , "break" ) ;
586+ }
587+ }
588+
344589 public sealed class AfterBreakStep : PipelineStep < TestContext >
345590 {
346591 protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
@@ -350,6 +595,15 @@ protected override PipelineControl Execute(TestContext context, Result result, P
350595 }
351596 }
352597
598+ public sealed class ProgressStep : PipelineStep < TestContext >
599+ {
600+ protected override PipelineControl Execute ( TestContext context , Result result , PipelineExecutionOptions options )
601+ {
602+ options . Progress ? . Report ( new ProgressReport ( this . Name , [ "class-progress" ] , 50 ) ) ;
603+ return PipelineControl . Continue ( result ) ;
604+ }
605+ }
606+
353607 public sealed class BackgroundStep : AsyncPipelineStep < TestContext >
354608 {
355609 protected override async ValueTask < PipelineControl > ExecuteAsync ( TestContext context , Result result , PipelineExecutionOptions options , CancellationToken cancellationToken )
@@ -370,6 +624,16 @@ public sealed class ExecutionProbe
370624 public int StepBehaviorCount { get ; set ; }
371625 }
372626
627+ public sealed class RecordingProgress : IProgress < ProgressReport >
628+ {
629+ public List < ProgressReport > Reports { get ; } = [ ] ;
630+
631+ public void Report ( ProgressReport value )
632+ {
633+ this . Reports . Add ( value ) ;
634+ }
635+ }
636+
373637 public sealed class ProbeHook ( ExecutionProbe probe ) : PipelineHook < PipelineContextBase >
374638 {
375639 public override ValueTask OnPipelineStartingAsync ( PipelineContextBase context , CancellationToken cancellationToken )
0 commit comments