Skip to content

Commit c9582ef

Browse files
Copilotdavidfowl
andcommitted
Make ALL terminal state transitions idempotent (noop)
Co-authored-by: davidfowl <[email protected]>
1 parent 99a967f commit c9582ef

File tree

2 files changed

+47
-44
lines changed

2 files changed

+47
-44
lines changed

src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,10 @@ public async Task CompleteStepAsync(ReportingStep step, string completionText, C
9999
{
100100
lock (step)
101101
{
102-
// If the step is already in a terminal state
102+
// If the step is already in a terminal state, this is a noop (idempotent)
103103
if (step.CompletionState != CompletionState.InProgress)
104104
{
105-
// If trying to complete with the same state, this is a noop (idempotent)
106-
if (step.CompletionState == completionState)
107-
{
108-
return;
109-
}
110-
111-
// If trying to transition from a terminal state to a different terminal state, this is a coding bug
112-
throw new InvalidOperationException($"Cannot complete step '{step.Id}' with state '{completionState}'. Step is already in terminal state '{step.CompletionState}'.");
105+
return;
113106
}
114107

115108
step.CompletionState = completionState;
@@ -203,17 +196,10 @@ public async Task CompleteTaskAsync(ReportingTask task, CompletionState completi
203196
throw new InvalidOperationException($"Parent step with ID '{task.StepId}' does not exist.");
204197
}
205198

206-
// If the task is already in a terminal state
199+
// If the task is already in a terminal state, this is a noop (idempotent)
207200
if (task.CompletionState != CompletionState.InProgress)
208201
{
209-
// If trying to complete with the same state, this is a noop (idempotent)
210-
if (task.CompletionState == completionState)
211-
{
212-
return;
213-
}
214-
215-
// If trying to transition from a terminal state to a different terminal state, this is a coding bug
216-
throw new InvalidOperationException($"Cannot complete task '{task.Id}' with state '{completionState}'. Task is already in terminal state '{task.CompletionState}'.");
202+
return;
217203
}
218204

219205
lock (parentStep)

tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ public async Task CompleteTaskAsync_IdempotentWhenCompletedWithSameState()
401401
}
402402

403403
[Fact]
404-
public async Task CompleteTaskAsync_ThrowsWhenCompletedWithDifferentState()
404+
public async Task CompleteTaskAsync_IdempotentWhenCompletedWithDifferentState()
405405
{
406406
// Arrange
407407
var reporter = CreatePublishingReporter();
@@ -412,12 +412,17 @@ public async Task CompleteTaskAsync_ThrowsWhenCompletedWithDifferentState()
412412
// Complete the task first time successfully
413413
await task.CompleteAsync(null, CompletionState.Completed, cancellationToken: CancellationToken.None);
414414

415-
// Act & Assert - Try to complete with a different state (should throw)
416-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
417-
() => task.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None));
415+
// Clear activities
416+
while (reporter.ActivityItemUpdated.Reader.TryRead(out _)) { }
417+
418+
// Act - Try to complete with a different state (should be idempotent, no exception)
419+
await task.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None);
418420

421+
// Assert - No new activity should be emitted (noop)
422+
Assert.False(reporter.ActivityItemUpdated.Reader.TryRead(out _));
423+
419424
var taskInternal = Assert.IsType<ReportingTask>(task);
420-
Assert.Contains($"Cannot complete task '{taskInternal.Id}' with state 'CompletedWithError'. Task is already in terminal state 'Completed'.", exception.Message);
425+
Assert.Equal(CompletionState.Completed, taskInternal.CompletionState); // Original state is retained
421426
}
422427

423428
[Fact]
@@ -446,7 +451,7 @@ public async Task CompleteStepAsync_IdempotentWhenCompletedWithSameState()
446451
}
447452

448453
[Fact]
449-
public async Task CompleteStepAsync_ThrowsWhenCompletedWithDifferentState()
454+
public async Task CompleteStepAsync_IdempotentWhenCompletedWithDifferentState()
450455
{
451456
// Arrange
452457
var reporter = CreatePublishingReporter();
@@ -456,12 +461,18 @@ public async Task CompleteStepAsync_ThrowsWhenCompletedWithDifferentState()
456461
// Complete the step first time successfully
457462
await step.CompleteAsync("Complete", CompletionState.Completed, cancellationToken: CancellationToken.None);
458463

459-
// Act & Assert - Try to complete with a different state (should throw)
460-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
461-
() => step.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None));
464+
// Clear activities
465+
while (reporter.ActivityItemUpdated.Reader.TryRead(out _)) { }
462466

467+
// Act - Try to complete with a different state (should be idempotent, no exception)
468+
await step.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None);
469+
470+
// Assert - No new activity should be emitted (noop)
471+
Assert.False(reporter.ActivityItemUpdated.Reader.TryRead(out _));
472+
463473
var stepInternal = Assert.IsType<ReportingStep>(step);
464-
Assert.Contains($"Cannot complete step '{stepInternal.Id}' with state 'CompletedWithError'. Step is already in terminal state 'Completed'.", exception.Message);
474+
Assert.Equal(CompletionState.Completed, stepInternal.CompletionState); // Original state is retained
475+
Assert.Equal("Complete", stepInternal.CompletionText); // Original completion text is retained
465476
}
466477

467478
[Fact]
@@ -486,13 +497,9 @@ public async Task CompleteStepAsync_KeepsStepInDictionaryForAggregation()
486497
var taskInternal = Assert.IsType<ReportingTask>(task);
487498
Assert.Contains($"Cannot update task '{taskInternal.Id}' because its parent step", updateException.Message);
488499

489-
// For CompleteTaskAsync, since task is already completed, attempting to complete with same state should be idempotent
500+
// For CompleteTaskAsync, since task is already completed, attempting to complete with same or different state should be idempotent
490501
await task.CompleteAsync(null, cancellationToken: CancellationToken.None); // Should not throw
491-
492-
// Attempting to complete with a different state should throw
493-
var completeException = await Assert.ThrowsAsync<InvalidOperationException>(
494-
() => task.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None));
495-
Assert.Contains($"Cannot complete task '{taskInternal.Id}' with state 'CompletedWithError'. Task is already in terminal state 'Completed'.", completeException.Message);
502+
await task.CompleteAsync("Error", CompletionState.CompletedWithError, cancellationToken: CancellationToken.None); // Should also not throw (noop)
496503

497504
// Creating new tasks for the completed step should also fail because the step is complete
498505
var createException = await Assert.ThrowsAsync<InvalidOperationException>(
@@ -1071,7 +1078,7 @@ public async Task CompleteStepAsync_IdempotentWithError()
10711078
[InlineData(CompletionState.CompletedWithWarning, CompletionState.CompletedWithError)]
10721079
[InlineData(CompletionState.CompletedWithError, CompletionState.Completed)]
10731080
[InlineData(CompletionState.CompletedWithError, CompletionState.CompletedWithWarning)]
1074-
public async Task CompleteTaskAsync_ThrowsWhenTransitioningBetweenTerminalStates(CompletionState firstState, CompletionState secondState)
1081+
public async Task CompleteTaskAsync_IdempotentWhenTransitioningBetweenTerminalStates(CompletionState firstState, CompletionState secondState)
10751082
{
10761083
// Arrange
10771084
var reporter = CreatePublishingReporter();
@@ -1082,12 +1089,17 @@ public async Task CompleteTaskAsync_ThrowsWhenTransitioningBetweenTerminalStates
10821089
// Complete the task with first state
10831090
await task.CompleteAsync("First completion", firstState, CancellationToken.None);
10841091

1085-
// Act & Assert - Try to complete with different state (should throw)
1086-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
1087-
() => task.CompleteAsync("Second completion", secondState, CancellationToken.None));
1092+
// Clear activities
1093+
while (reporter.ActivityItemUpdated.Reader.TryRead(out _)) { }
10881094

1095+
// Act - Try to complete with different state (should be idempotent, no exception)
1096+
await task.CompleteAsync("Second completion", secondState, CancellationToken.None);
1097+
1098+
// Assert - No new activity should be emitted (noop)
1099+
Assert.False(reporter.ActivityItemUpdated.Reader.TryRead(out _));
1100+
10891101
var taskInternal = Assert.IsType<ReportingTask>(task);
1090-
Assert.Contains($"Cannot complete task '{taskInternal.Id}' with state '{secondState}'. Task is already in terminal state '{firstState}'.", exception.Message);
1102+
Assert.Equal(firstState, taskInternal.CompletionState); // Original state is retained
10911103
}
10921104

10931105
[Theory]
@@ -1097,7 +1109,7 @@ public async Task CompleteTaskAsync_ThrowsWhenTransitioningBetweenTerminalStates
10971109
[InlineData(CompletionState.CompletedWithWarning, CompletionState.CompletedWithError)]
10981110
[InlineData(CompletionState.CompletedWithError, CompletionState.Completed)]
10991111
[InlineData(CompletionState.CompletedWithError, CompletionState.CompletedWithWarning)]
1100-
public async Task CompleteStepAsync_ThrowsWhenTransitioningBetweenTerminalStates(CompletionState firstState, CompletionState secondState)
1112+
public async Task CompleteStepAsync_IdempotentWhenTransitioningBetweenTerminalStates(CompletionState firstState, CompletionState secondState)
11011113
{
11021114
// Arrange
11031115
var reporter = CreatePublishingReporter();
@@ -1107,12 +1119,17 @@ public async Task CompleteStepAsync_ThrowsWhenTransitioningBetweenTerminalStates
11071119
// Complete the step with first state
11081120
await step.CompleteAsync("First completion", firstState, CancellationToken.None);
11091121

1110-
// Act & Assert - Try to complete with different state (should throw)
1111-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
1112-
() => step.CompleteAsync("Second completion", secondState, CancellationToken.None));
1122+
// Clear activities
1123+
while (reporter.ActivityItemUpdated.Reader.TryRead(out _)) { }
1124+
1125+
// Act - Try to complete with different state (should be idempotent, no exception)
1126+
await step.CompleteAsync("Second completion", secondState, CancellationToken.None);
11131127

1128+
// Assert - No new activity should be emitted (noop)
1129+
Assert.False(reporter.ActivityItemUpdated.Reader.TryRead(out _));
1130+
11141131
var stepInternal = Assert.IsType<ReportingStep>(step);
1115-
Assert.Contains($"Cannot complete step '{stepInternal.Id}' with state '{secondState}'. Step is already in terminal state '{firstState}'.", exception.Message);
1132+
Assert.Equal(firstState, stepInternal.CompletionState); // Original state is retained
11161133
}
11171134

11181135
private PipelineActivityReporter CreatePublishingReporter()

0 commit comments

Comments
 (0)