2424import io .serverlessworkflow .impl .WorkflowInstance ;
2525import io .serverlessworkflow .impl .WorkflowModel ;
2626import io .serverlessworkflow .impl .WorkflowStatus ;
27+ import io .serverlessworkflow .impl .lifecycle .EventType ;
2728import io .serverlessworkflow .impl .lifecycle .TaskCancelledEvent ;
29+ import io .serverlessworkflow .impl .lifecycle .TaskCompletedEvent ;
30+ import io .serverlessworkflow .impl .lifecycle .TaskFailedEvent ;
2831import io .serverlessworkflow .impl .lifecycle .WorkflowCancelledEvent ;
32+ import io .serverlessworkflow .impl .lifecycle .WorkflowCompletedEvent ;
33+ import io .serverlessworkflow .impl .lifecycle .WorkflowEvent ;
2934import io .serverlessworkflow .impl .lifecycle .WorkflowExecutionCompletableListener ;
3035import io .serverlessworkflow .impl .lifecycle .WorkflowExecutionListener ;
36+ import io .serverlessworkflow .impl .lifecycle .WorkflowFailedEvent ;
3137import io .serverlessworkflow .impl .lifecycle .WorkflowStartedEvent ;
3238import io .serverlessworkflow .impl .lifecycle .WorkflowStatusEvent ;
3339import java .io .IOException ;
3440import java .time .Duration ;
3541import java .util .Map ;
3642import java .util .Optional ;
3743import java .util .Set ;
44+ import java .util .concurrent .CancellationException ;
3845import java .util .concurrent .CompletableFuture ;
3946import java .util .concurrent .ConcurrentHashMap ;
4047import java .util .concurrent .ExecutionException ;
@@ -180,6 +187,43 @@ private static Stream<Arguments> pipelineEndsWhileCancellationIsPublishedParamet
180187 Arguments .of ("workflows-samples/wait-set.yaml" , Map .of (), null ));
181188 }
182189
190+ @ ParameterizedTest (name = "{0}" )
191+ @ MethodSource ("terminalTransitionWhileCancellingParameters" )
192+ void cancellationWhileLastTaskEventIsPendingWins (String workflowPath , EventType heldTaskEvent )
193+ throws IOException {
194+ WorkflowDefinition definition =
195+ appl .workflowDefinition (WorkflowReader .readWorkflowFromClasspath (workflowPath ));
196+ WorkflowInstance instance = definition .instance (Map .of ());
197+ CompletableFuture <Void > gate = cancelGate .hold (instance .id (), heldTaskEvent );
198+ CompletableFuture <WorkflowModel > future = instance .start ();
199+ assertThat (future ).isNotDone ();
200+
201+ assertThat (instance .cancel ()).isTrue ();
202+ await ().atMost (Duration .ofSeconds (5 )).until (() -> listener .cancelledSeen (instance .id ()));
203+ // lets the last task end and the workflow reach its terminal transition
204+ gate .complete (null );
205+
206+ assertThat (future )
207+ .failsWithin (Duration .ofSeconds (5 ))
208+ .withThrowableOfType (ExecutionException .class )
209+ .withCauseInstanceOf (CancellationException .class );
210+ assertThat (instance .status ()).isEqualTo (WorkflowStatus .CANCELLED );
211+ assertThat (listener .workflowCompletedSeen (instance .id ()))
212+ .as ("onWorkflowCompleted published for a cancelled instance" )
213+ .isFalse ();
214+ assertThat (listener .workflowFailedSeen (instance .id ()))
215+ .as ("onWorkflowFailed published for a cancelled instance" )
216+ .isFalse ();
217+ }
218+
219+ private static Stream <Arguments > terminalTransitionWhileCancellingParameters () {
220+ return Stream .of (
221+ // completes normally: COMPLETED transition
222+ Arguments .of ("workflows-samples/simple-expression.yaml" , EventType .TASK_COMPLETED ),
223+ // raises an error: FAULTED transition
224+ Arguments .of ("workflows-samples/raise-inline.yaml" , EventType .TASK_FAULTED ));
225+ }
226+
183227 private static void assertMetadataSeenOnCancel (WorkflowInstance instance ) {
184228 await ().atMost (Duration .ofSeconds (5 )).until (() -> listener .cancelledSeen (instance .id ()));
185229 assertThat (listener .metadataOnCancel (instance .id ()))
@@ -222,21 +266,48 @@ static class CancelGate implements WorkflowExecutionCompletableListener {
222266
223267 /** Keeps the CANCELLED status change of the given instance pending until completed. */
224268 CompletableFuture <Void > hold (String instanceId ) {
225- return gates .computeIfAbsent (instanceId , k -> new CompletableFuture <>());
269+ return hold (instanceId , EventType .WORKFLOW_STATUS_CHANGED );
270+ }
271+
272+ /**
273+ * Keeps the given lifecycle event of the given instance pending until completed. For {@link
274+ * EventType#WORKFLOW_STATUS_CHANGED} only the change to CANCELLED is held.
275+ */
276+ CompletableFuture <Void > hold (String instanceId , EventType type ) {
277+ return gates .computeIfAbsent (key (instanceId , type ), k -> new CompletableFuture <>());
226278 }
227279
228280 void reset () {
229281 gates .values ().forEach (g -> g .complete (null ));
230282 gates .clear ();
231283 }
232284
285+ private static String key (String instanceId , EventType type ) {
286+ return instanceId + '/' + type ;
287+ }
288+
289+ private CompletableFuture <?> gate (WorkflowEvent ev ) {
290+ return gates .getOrDefault (
291+ key (ev .workflowContext ().instanceData ().id (), ev .type ()),
292+ CompletableFuture .completedFuture (null ));
293+ }
294+
233295 @ Override
234296 public CompletableFuture <?> onWorkflowStatusChanged (WorkflowStatusEvent ev ) {
235297 return ev .status () == WorkflowStatus .CANCELLED
236- ? gates .getOrDefault (
237- ev .workflowContext ().instanceData ().id (), CompletableFuture .completedFuture (null ))
298+ ? gate (ev )
238299 : CompletableFuture .completedFuture (null );
239300 }
301+
302+ @ Override
303+ public CompletableFuture <?> onTaskCompleted (TaskCompletedEvent ev ) {
304+ return gate (ev );
305+ }
306+
307+ @ Override
308+ public CompletableFuture <?> onTaskFailed (TaskFailedEvent ev ) {
309+ return gate (ev );
310+ }
240311 }
241312
242313 static class TrackedMetadata implements AutoCloseable {
@@ -253,11 +324,23 @@ static class MetadataListener implements WorkflowExecutionListener {
253324 private final Map <String , Boolean > metadataOnCancel = new ConcurrentHashMap <>();
254325 private final Map <String , Boolean > closedBeforeCancel = new ConcurrentHashMap <>();
255326 private final Set <String > taskCancelled = ConcurrentHashMap .newKeySet ();
327+ private final Set <String > workflowCompleted = ConcurrentHashMap .newKeySet ();
328+ private final Set <String > workflowFailed = ConcurrentHashMap .newKeySet ();
256329
257330 void reset () {
258331 metadataOnCancel .clear ();
259332 closedBeforeCancel .clear ();
260333 taskCancelled .clear ();
334+ workflowCompleted .clear ();
335+ workflowFailed .clear ();
336+ }
337+
338+ boolean workflowCompletedSeen (String id ) {
339+ return workflowCompleted .contains (id );
340+ }
341+
342+ boolean workflowFailedSeen (String id ) {
343+ return workflowFailed .contains (id );
261344 }
262345
263346 boolean taskCancelledSeen (String id ) {
@@ -287,6 +370,16 @@ public void onTaskCancelled(TaskCancelledEvent ev) {
287370 taskCancelled .add (ev .workflowContext ().instanceData ().id ());
288371 }
289372
373+ @ Override
374+ public void onWorkflowCompleted (WorkflowCompletedEvent ev ) {
375+ workflowCompleted .add (ev .workflowContext ().instanceData ().id ());
376+ }
377+
378+ @ Override
379+ public void onWorkflowFailed (WorkflowFailedEvent ev ) {
380+ workflowFailed .add (ev .workflowContext ().instanceData ().id ());
381+ }
382+
290383 @ Override
291384 public void onWorkflowCancelled (WorkflowCancelledEvent ev ) {
292385 Optional <TrackedMetadata > metadata =
0 commit comments