99import static org .mockito .Mockito .*;
1010
1111import java .lang .reflect .Field ;
12+ import java .util .concurrent .CompletableFuture ;
1213import java .util .concurrent .Executors ;
1314import java .util .concurrent .atomic .AtomicInteger ;
1415import org .junit .jupiter .api .BeforeEach ;
@@ -67,6 +68,26 @@ void setUp() {
6768 mockIdGenerator = mock (OperationIdGenerator .class );
6869 when (mockIdGenerator .nextOperationId ()).thenAnswer (inv -> "child-" + operationIdCounter .incrementAndGet ());
6970 when (executionManager .getOperationAndUpdateReplayState (anyString ())).thenReturn (null );
71+
72+ // Simulate the real backend: when a SUCCEED checkpoint is sent for the parallel op,
73+ // make getOperationAndUpdateReplayState return a SUCCEEDED operation so waitForOperationCompletion() can find
74+ // it.
75+ var succeededParallelOp = Operation .builder ()
76+ .id (OPERATION_ID )
77+ .name ("test-parallel" )
78+ .type (OperationType .CONTEXT )
79+ .subType (OperationSubType .PARALLEL .getValue ())
80+ .status (OperationStatus .SUCCEEDED )
81+ .build ();
82+ when (executionManager .sendOperationUpdate (argThat (u -> u != null
83+ && u .id () != null
84+ && u .id ().equals (OPERATION_ID )
85+ && u .action () == OperationAction .SUCCEED )))
86+ .thenAnswer (inv -> {
87+ when (executionManager .getOperationAndUpdateReplayState (OPERATION_ID ))
88+ .thenReturn (succeededParallelOp );
89+ return CompletableFuture .completedFuture (null );
90+ });
7091 }
7192
7293 private ParallelOperation <Void > createOperation (int maxConcurrency , int minSuccessful , int toleratedFailureCount ) {
@@ -153,7 +174,7 @@ void handleSuccess_sendsSucceedCheckpoint() throws Exception {
153174 op .addItem ("branch-1" , ctx -> "r1" , TypeToken .get (String .class ), SER_DES );
154175 op .addItem ("branch-2" , ctx -> "r2" , TypeToken .get (String .class ), SER_DES );
155176
156- runJoin ( op );
177+ op . get ( );
157178
158179 verify (executionManager ).sendOperationUpdate (argThat (update -> update .action () == OperationAction .SUCCEED ));
159180 }
@@ -179,7 +200,7 @@ void minSuccessful_joinCompletesWhenThresholdMet() throws Exception {
179200 op .addItem ("branch-1" , ctx -> "r1" , TypeToken .get (String .class ), SER_DES );
180201
181202 // Should not throw
182- assertDoesNotThrow (() -> runJoin ( op ) );
203+ op . get ( );
183204 assertEquals (1 , op .getSucceededCount ());
184205 }
185206
@@ -199,6 +220,100 @@ void contextHierarchy_branchesUseParallelContextAsParent() throws Exception {
199220 assertNotNull (childOp );
200221 }
201222
223+ // ===== Replay =====
224+
225+ @ Test
226+ void replay_doesNotSendStartCheckpoint () throws Exception {
227+ // Simulate the parallel operation already existing in the service (STARTED status)
228+ when (executionManager .getOperationAndUpdateReplayState (OPERATION_ID ))
229+ .thenReturn (Operation .builder ()
230+ .id (OPERATION_ID )
231+ .name ("test-parallel" )
232+ .type (OperationType .CONTEXT )
233+ .subType (OperationSubType .PARALLEL .getValue ())
234+ .status (OperationStatus .STARTED )
235+ .build ());
236+ // Both branches already succeeded
237+ when (executionManager .getOperationAndUpdateReplayState ("child-1" ))
238+ .thenReturn (Operation .builder ()
239+ .id ("child-1" )
240+ .name ("branch-1" )
241+ .type (OperationType .CONTEXT )
242+ .subType (OperationSubType .PARALLEL_BRANCH .getValue ())
243+ .status (OperationStatus .SUCCEEDED )
244+ .contextDetails (
245+ ContextDetails .builder ().result ("\" r1\" " ).build ())
246+ .build ());
247+ when (executionManager .getOperationAndUpdateReplayState ("child-2" ))
248+ .thenReturn (Operation .builder ()
249+ .id ("child-2" )
250+ .name ("branch-2" )
251+ .type (OperationType .CONTEXT )
252+ .subType (OperationSubType .PARALLEL_BRANCH .getValue ())
253+ .status (OperationStatus .SUCCEEDED )
254+ .contextDetails (
255+ ContextDetails .builder ().result ("\" r2\" " ).build ())
256+ .build ());
257+
258+ var op = createOperation (-1 , -1 , 0 );
259+ setOperationIdGenerator (op , mockIdGenerator );
260+ op .execute ();
261+ op .addItem ("branch-1" , ctx -> "r1" , TypeToken .get (String .class ), SER_DES );
262+ op .addItem ("branch-2" , ctx -> "r2" , TypeToken .get (String .class ), SER_DES );
263+
264+ op .get ();
265+
266+ verify (executionManager , never ())
267+ .sendOperationUpdate (argThat (update -> update .action () == OperationAction .START ));
268+ verify (executionManager , times (1 ))
269+ .sendOperationUpdate (argThat (update -> update .action () == OperationAction .SUCCEED ));
270+ }
271+
272+ @ Test
273+ void replay_doesNotSendSucceedCheckpointWhenParallelAlreadySucceeded () throws Exception {
274+ when (executionManager .getOperationAndUpdateReplayState (OPERATION_ID ))
275+ .thenReturn (Operation .builder ()
276+ .id (OPERATION_ID )
277+ .name ("test-parallel" )
278+ .type (OperationType .CONTEXT )
279+ .subType (OperationSubType .PARALLEL .getValue ())
280+ .status (OperationStatus .SUCCEEDED )
281+ .build ());
282+ when (executionManager .getOperationAndUpdateReplayState ("child-1" ))
283+ .thenReturn (Operation .builder ()
284+ .id ("child-1" )
285+ .name ("branch-1" )
286+ .type (OperationType .CONTEXT )
287+ .subType (OperationSubType .PARALLEL_BRANCH .getValue ())
288+ .status (OperationStatus .SUCCEEDED )
289+ .contextDetails (
290+ ContextDetails .builder ().result ("\" r1\" " ).build ())
291+ .build ());
292+ when (executionManager .getOperationAndUpdateReplayState ("child-2" ))
293+ .thenReturn (Operation .builder ()
294+ .id ("child-2" )
295+ .name ("branch-2" )
296+ .type (OperationType .CONTEXT )
297+ .subType (OperationSubType .PARALLEL_BRANCH .getValue ())
298+ .status (OperationStatus .SUCCEEDED )
299+ .contextDetails (
300+ ContextDetails .builder ().result ("\" r2\" " ).build ())
301+ .build ());
302+
303+ var op = createOperation (-1 , -1 , 0 );
304+ setOperationIdGenerator (op , mockIdGenerator );
305+ op .execute ();
306+ op .addItem ("branch-1" , ctx -> "r1" , TypeToken .get (String .class ), SER_DES );
307+ op .addItem ("branch-2" , ctx -> "r2" , TypeToken .get (String .class ), SER_DES );
308+
309+ op .get ();
310+
311+ verify (executionManager , never ())
312+ .sendOperationUpdate (argThat (update -> update .action () == OperationAction .START ));
313+ verify (executionManager , never ())
314+ .sendOperationUpdate (argThat (update -> update .action () == OperationAction .SUCCEED ));
315+ }
316+
202317 // ===== handleFailure still sends SUCCEED =====
203318
204319 @ Test
@@ -224,22 +339,10 @@ void handleFailure_sendsSucceedCheckpointEvenWhenFailureToleranceExceeded() thro
224339 TypeToken .get (String .class ),
225340 SER_DES );
226341
227- runJoin ( op );
342+ op . get ( );
228343
229344 verify (executionManager ).sendOperationUpdate (argThat (update -> update .action () == OperationAction .SUCCEED ));
230345 verify (executionManager , never ())
231346 .sendOperationUpdate (argThat (update -> update .action () == OperationAction .FAIL ));
232347 }
233-
234- // ===== Helpers =====
235-
236- private void runJoin (ParallelOperation <?> op ) throws InterruptedException {
237- var t = new Thread (op ::get );
238- t .start ();
239- t .join (2000 );
240- if (t .isAlive ()) {
241- t .interrupt ();
242- fail ("join() did not complete within 2 seconds" );
243- }
244- }
245348}
0 commit comments