@@ -2369,6 +2369,147 @@ describe("EventSent Handler", () => {
23692369 expect ( failureDetails ?. getErrortype ( ) ) . toEqual ( "NonDeterminismError" ) ;
23702370 expect ( failureDetails ?. getErrormessage ( ) ) . toMatch ( / s e n d E v e n t / ) ;
23712371 } ) ;
2372+
2373+ // Regression coverage for issue #292: the classic "activity vs. timeout" race
2374+ // where the winner cancels the loser timer (cancellable TimerTask).
2375+ it ( "should complete the timer-vs-activity race when the activity wins and the loser timer is canceled" , async ( ) => {
2376+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2377+
2378+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2379+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2380+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2381+
2382+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2383+
2384+ // Identity must be preserved: whenAny returns the exact activity task instance.
2385+ if ( winner === activityTask ) {
2386+ if ( ! timeoutTask . isCompleted ) {
2387+ timeoutTask . cancel ( ) ;
2388+ }
2389+ return activityTask . getResult ( ) ;
2390+ }
2391+ return "timed out" ;
2392+ } ;
2393+
2394+ const registry = new Registry ( ) ;
2395+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2396+ const activityName = registry . addActivity ( hello ) ;
2397+
2398+ // Turn 1: schedules the timer (action 1) and the activity (action 2), then yields on whenAny.
2399+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2400+ let executor = new OrchestrationExecutor ( registry , testLogger ) ;
2401+ let result = await executor . execute ( TEST_INSTANCE_ID , [ ] , [
2402+ newOrchestratorStartedEvent ( startTime ) ,
2403+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2404+ ] ) ;
2405+ expect ( result . actions . length ) . toEqual ( 2 ) ;
2406+ expect ( result . actions [ 0 ] . hasCreatetimer ( ) ) . toBeTruthy ( ) ;
2407+ expect ( result . actions [ 1 ] . hasScheduletask ( ) ) . toBeTruthy ( ) ;
2408+
2409+ // Turn 2: the activity completes first; the timer has NOT fired.
2410+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2411+ const oldEvents = [
2412+ newOrchestratorStartedEvent ( startTime ) ,
2413+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2414+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2415+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2416+ ] ;
2417+ const encodedOutput = JSON . stringify ( hello ( null , "Tokyo" ) ) ;
2418+ executor = new OrchestrationExecutor ( registry , testLogger ) ;
2419+ result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [ newTaskCompletedEvent ( 2 , encodedOutput ) ] ) ;
2420+
2421+ // A single CompleteOrchestration action, carrying the activity's result, and
2422+ // no leftover CreateTimer action (the canceled timer must not be rescheduled).
2423+ const completeAction = getAndValidateSingleCompleteOrchestrationAction ( result ) ;
2424+ expect ( completeAction ?. getOrchestrationstatus ( ) ) . toEqual ( pb . OrchestrationStatus . ORCHESTRATION_STATUS_COMPLETED ) ;
2425+ expect ( completeAction ?. getResult ( ) ?. getValue ( ) ) . toEqual ( encodedOutput ) ;
2426+ expect ( result . actions . some ( ( a ) => a . hasCreatetimer ( ) ) ) . toBe ( false ) ;
2427+ } ) ;
2428+
2429+ it ( "should complete normally when the timer wins the race (no regression to timer firing)" , async ( ) => {
2430+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2431+
2432+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2433+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2434+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2435+
2436+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2437+
2438+ if ( winner === timeoutTask ) {
2439+ return "timed out" ;
2440+ }
2441+ return activityTask . getResult ( ) ;
2442+ } ;
2443+
2444+ const registry = new Registry ( ) ;
2445+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2446+ const activityName = registry . addActivity ( hello ) ;
2447+
2448+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2449+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2450+ const oldEvents = [
2451+ newOrchestratorStartedEvent ( startTime ) ,
2452+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2453+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2454+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2455+ ] ;
2456+
2457+ // The timer fires first: the orchestration should still complete via the timeout branch.
2458+ const executor = new OrchestrationExecutor ( registry , testLogger ) ;
2459+ const result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [ newTimerFiredEvent ( 1 , timerFireAt ) ] ) ;
2460+
2461+ const completeAction = getAndValidateSingleCompleteOrchestrationAction ( result ) ;
2462+ expect ( completeAction ?. getOrchestrationstatus ( ) ) . toEqual ( pb . OrchestrationStatus . ORCHESTRATION_STATUS_COMPLETED ) ;
2463+ expect ( completeAction ?. getResult ( ) ?. getValue ( ) ) . toEqual ( JSON . stringify ( "timed out" ) ) ;
2464+ } ) ;
2465+
2466+ it ( "should ignore a fired event for a canceled timer (replay-safe) and keep running" , async ( ) => {
2467+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2468+
2469+ // The orchestration cancels the timer, then continues with more work. A late
2470+ // TimerFired event for the canceled timer must be ignored (no crash, no
2471+ // premature completion) rather than resuming the orchestrator.
2472+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2473+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2474+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2475+
2476+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2477+ if ( winner === activityTask && ! timeoutTask . isCompleted ) {
2478+ timeoutTask . cancel ( ) ;
2479+ }
2480+
2481+ // Continue after canceling the timer.
2482+ const second = yield ctx . callActivity ( hello , "Seattle" ) ;
2483+ return second ;
2484+ } ;
2485+
2486+ const registry = new Registry ( ) ;
2487+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2488+ const activityName = registry . addActivity ( hello ) ;
2489+
2490+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2491+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2492+ const oldEvents = [
2493+ newOrchestratorStartedEvent ( startTime ) ,
2494+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2495+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2496+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2497+ ] ;
2498+
2499+ // The first activity completes AND the (canceled) timer fires in the same batch.
2500+ const encodedOutput = JSON . stringify ( hello ( null , "Tokyo" ) ) ;
2501+ const executor = new OrchestrationExecutor ( registry , testLogger ) ;
2502+ const result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [
2503+ newTaskCompletedEvent ( 2 , encodedOutput ) ,
2504+ newTimerFiredEvent ( 1 , timerFireAt ) ,
2505+ ] ) ;
2506+
2507+ // The fired canceled timer is ignored; the orchestration schedules the second
2508+ // activity and is NOT complete.
2509+ expect ( result . actions . length ) . toEqual ( 1 ) ;
2510+ expect ( result . actions [ 0 ] . hasScheduletask ( ) ) . toBeTruthy ( ) ;
2511+ expect ( result . actions . some ( ( a ) => a . hasCompleteorchestration ( ) ) ) . toBe ( false ) ;
2512+ } ) ;
23722513} ) ;
23732514
23742515describe ( "Entity call on classic (Azure Storage) backend — EVENTSENT/EVENTRAISED" , ( ) => {
0 commit comments