@@ -16,6 +16,8 @@ namespace Microsoft.DurableTask.Grpc.Tests;
1616public class DurableTaskGrpcClientIntegrationTests : IntegrationTestBase
1717{
1818 const string OrchestrationName = "TestOrchestration" ;
19+ const int PollingTimeoutSeconds = 5 ;
20+ const int PollingIntervalMilliseconds = 100 ;
1921
2022 public DurableTaskGrpcClientIntegrationTests ( ITestOutputHelper output , GrpcSidecarFixture sidecarFixture )
2123 : base ( output , sidecarFixture )
@@ -291,26 +293,6 @@ await restartAction.Should().ThrowAsync<ArgumentException>()
291293 . WithMessage ( "*An orchestration with the instanceId non-existent-instance-id was not found*" ) ;
292294 }
293295
294- Task < HostTestLifetime > StartAsync ( )
295- {
296- static async Task < string > Orchestration ( TaskOrchestrationContext context , bool shouldThrow )
297- {
298- context . SetCustomStatus ( "waiting" ) ;
299- await context . WaitForExternalEvent < string > ( "event" ) ;
300- if ( shouldThrow )
301- {
302- throw new InvalidOperationException ( "Orchestration failed" ) ;
303- }
304-
305- return $ "{ shouldThrow } -> output";
306- }
307-
308- return this . StartWorkerAsync ( b =>
309- {
310- b . AddTasks ( tasks => tasks . AddOrchestratorFunc < bool , string > ( OrchestrationName , Orchestration ) ) ;
311- } ) ;
312- }
313-
314296 [ Fact ]
315297 public async Task ScheduleNewOrchestrationInstance_WithDedupeStatuses_ThrowsWhenInstanceExists ( )
316298 {
@@ -482,6 +464,223 @@ await createAction.Should().ThrowAsync<RpcException>()
482464 . Where ( e => e . StatusCode == StatusCode . AlreadyExists ) ;
483465 }
484466
467+ [ Fact ]
468+ public async Task SuspendAndResumeInstance_EndToEnd ( )
469+ {
470+ // Arrange
471+ await using HostTestLifetime server = await this . StartLongRunningAsync ( ) ;
472+
473+ string instanceId = await server . Client . ScheduleNewOrchestrationInstanceAsync (
474+ OrchestrationName , input : false ) ;
475+
476+ // Wait for the orchestration to start
477+ await server . Client . WaitForInstanceStartAsync ( instanceId , default ) ;
478+
479+ // Act - Suspend the orchestration
480+ await server . Client . SuspendInstanceAsync ( instanceId , "Test suspension" , default ) ;
481+
482+ // Poll for suspended status
483+ OrchestrationMetadata ? suspendedMetadata = await this . PollForStatusAsync (
484+ server . Client , instanceId , OrchestrationRuntimeStatus . Suspended , default ) ;
485+
486+ // Assert - Verify orchestration is suspended
487+ suspendedMetadata . Should ( ) . NotBeNull ( ) ;
488+ suspendedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Suspended ) ;
489+ suspendedMetadata . InstanceId . Should ( ) . Be ( instanceId ) ;
490+
491+ // Act - Resume the orchestration
492+ await server . Client . ResumeInstanceAsync ( instanceId , "Test resumption" , default ) ;
493+
494+ // Poll for running status
495+ OrchestrationMetadata ? resumedMetadata = await this . PollForStatusAsync (
496+ server . Client , instanceId , OrchestrationRuntimeStatus . Running , default ) ;
497+
498+ // Assert - Verify orchestration is running again
499+ resumedMetadata . Should ( ) . NotBeNull ( ) ;
500+ resumedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Running ) ;
501+
502+ // Complete the orchestration
503+ await server . Client . RaiseEventAsync ( instanceId , "event" , default ) ;
504+ await server . Client . WaitForInstanceCompletionAsync ( instanceId , default ) ;
505+
506+ // Verify the orchestration completed successfully
507+ OrchestrationMetadata ? completedMetadata = await server . Client . GetInstanceAsync ( instanceId , false ) ;
508+ completedMetadata . Should ( ) . NotBeNull ( ) ;
509+ completedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Completed ) ;
510+ }
511+
512+ [ Fact ]
513+ public async Task SuspendInstance_WithoutReason_Succeeds ( )
514+ {
515+ // Arrange
516+ await using HostTestLifetime server = await this . StartLongRunningAsync ( ) ;
517+
518+ string instanceId = await server . Client . ScheduleNewOrchestrationInstanceAsync (
519+ OrchestrationName , input : false ) ;
520+
521+ await server . Client . WaitForInstanceStartAsync ( instanceId , default ) ;
522+
523+ // Act - Suspend without a reason
524+ await server . Client . SuspendInstanceAsync ( instanceId , cancellation : default ) ;
525+
526+ // Poll for suspended status
527+ OrchestrationMetadata ? suspendedMetadata = await this . PollForStatusAsync (
528+ server . Client , instanceId , OrchestrationRuntimeStatus . Suspended , default ) ;
529+
530+ // Assert
531+ suspendedMetadata . Should ( ) . NotBeNull ( ) ;
532+ suspendedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Suspended ) ;
533+ }
534+
535+ [ Fact ]
536+ public async Task ResumeInstance_WithoutReason_Succeeds ( )
537+ {
538+ // Arrange
539+ await using HostTestLifetime server = await this . StartLongRunningAsync ( ) ;
540+
541+ string instanceId = await server . Client . ScheduleNewOrchestrationInstanceAsync (
542+ OrchestrationName , input : false ) ;
543+
544+ await server . Client . WaitForInstanceStartAsync ( instanceId , default ) ;
545+ await server . Client . SuspendInstanceAsync ( instanceId , "Test suspension" , default ) ;
546+
547+ // Wait for suspension
548+ await this . PollForStatusAsync ( server . Client , instanceId , OrchestrationRuntimeStatus . Suspended , default ) ;
549+
550+ // Act - Resume without a reason
551+ await server . Client . ResumeInstanceAsync ( instanceId , cancellation : default ) ;
552+
553+ // Poll for running status
554+ OrchestrationMetadata ? resumedMetadata = await this . PollForStatusAsync (
555+ server . Client , instanceId , OrchestrationRuntimeStatus . Running , default ) ;
556+
557+ // Assert
558+ resumedMetadata . Should ( ) . NotBeNull ( ) ;
559+ resumedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Running ) ;
560+ }
561+
562+ [ Fact ]
563+ public async Task SuspendInstance_AlreadyCompleted_NoError ( )
564+ {
565+ // Arrange
566+ await using HostTestLifetime server = await this . StartAsync ( ) ;
567+
568+ string instanceId = await server . Client . ScheduleNewOrchestrationInstanceAsync (
569+ OrchestrationName , input : false ) ;
570+
571+ await server . Client . WaitForInstanceStartAsync ( instanceId , default ) ;
572+ await server . Client . RaiseEventAsync ( instanceId , "event" , default ) ;
573+ await server . Client . WaitForInstanceCompletionAsync ( instanceId , default ) ;
574+
575+ // Verify it's completed
576+ OrchestrationMetadata ? completedMetadata = await server . Client . GetInstanceAsync ( instanceId , false ) ;
577+ completedMetadata . Should ( ) . NotBeNull ( ) ;
578+ completedMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Completed ) ;
579+
580+ // Act - Try to suspend a completed orchestration (should not throw)
581+ await server . Client . SuspendInstanceAsync ( instanceId , "Test suspension" , default ) ;
582+
583+ // Assert - Status should remain completed
584+ OrchestrationMetadata ? metadata = await server . Client . GetInstanceAsync ( instanceId , false ) ;
585+ metadata . Should ( ) . NotBeNull ( ) ;
586+ metadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Completed ) ;
587+ }
588+
589+ [ Fact ]
590+ public async Task ResumeInstance_NotSuspended_NoError ( )
591+ {
592+ // Arrange
593+ await using HostTestLifetime server = await this . StartLongRunningAsync ( ) ;
594+
595+ string instanceId = await server . Client . ScheduleNewOrchestrationInstanceAsync (
596+ OrchestrationName , input : false ) ;
597+
598+ await server . Client . WaitForInstanceStartAsync ( instanceId , default ) ;
599+
600+ // Verify it's running
601+ OrchestrationMetadata ? runningMetadata = await server . Client . GetInstanceAsync ( instanceId , false ) ;
602+ runningMetadata . Should ( ) . NotBeNull ( ) ;
603+ runningMetadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Running ) ;
604+
605+ // Act - Try to resume an already running orchestration (should not throw)
606+ await server . Client . ResumeInstanceAsync ( instanceId , "Test resumption" , default ) ;
607+
608+ // Assert - Status should remain running
609+ OrchestrationMetadata ? metadata = await server . Client . GetInstanceAsync ( instanceId , false ) ;
610+ metadata . Should ( ) . NotBeNull ( ) ;
611+ metadata ! . RuntimeStatus . Should ( ) . Be ( OrchestrationRuntimeStatus . Running ) ;
612+ }
613+
614+ Task < HostTestLifetime > StartAsync ( )
615+ {
616+ static async Task < string > Orchestration ( TaskOrchestrationContext context , bool shouldThrow )
617+ {
618+ context . SetCustomStatus ( "waiting" ) ;
619+ await context . WaitForExternalEvent < string > ( "event" ) ;
620+ if ( shouldThrow )
621+ {
622+ throw new InvalidOperationException ( "Orchestration failed" ) ;
623+ }
624+
625+ return $ "{ shouldThrow } -> output";
626+ }
627+
628+ return this . StartWorkerAsync ( b =>
629+ {
630+ b . AddTasks ( tasks => tasks . AddOrchestratorFunc < bool , string > ( OrchestrationName , Orchestration ) ) ;
631+ } ) ;
632+ }
633+
634+ Task < HostTestLifetime > StartLongRunningAsync ( )
635+ {
636+ static async Task < string > LongRunningOrchestration ( TaskOrchestrationContext context , bool shouldThrow )
637+ {
638+ context . SetCustomStatus ( "waiting" ) ;
639+ // Wait for external event or a timer (30 seconds) to allow suspend/resume operations
640+ Task < string > eventTask = context . WaitForExternalEvent < string > ( "event" ) ;
641+ Task timerTask = context . CreateTimer ( TimeSpan . FromSeconds ( 30 ) , CancellationToken . None ) ;
642+ Task completedTask = await Task . WhenAny ( eventTask , timerTask ) ;
643+
644+ if ( completedTask == timerTask )
645+ {
646+ throw new TimeoutException ( "Timed out waiting for external event 'event'." ) ;
647+ }
648+
649+ if ( shouldThrow )
650+ {
651+ throw new InvalidOperationException ( "Orchestration failed" ) ;
652+ }
653+
654+ return $ "{ shouldThrow } -> output";
655+ }
656+
657+ return this . StartWorkerAsync ( b =>
658+ {
659+ b . AddTasks ( tasks => tasks . AddOrchestratorFunc < bool , string > ( OrchestrationName , LongRunningOrchestration ) ) ;
660+ } ) ;
661+ }
662+
663+ async Task < OrchestrationMetadata ? > PollForStatusAsync (
664+ DurableTaskClient client ,
665+ string instanceId ,
666+ OrchestrationRuntimeStatus expectedStatus ,
667+ CancellationToken cancellation = default )
668+ {
669+ DateTime deadline = DateTime . UtcNow . AddSeconds ( PollingTimeoutSeconds ) ;
670+ while ( DateTime . UtcNow < deadline )
671+ {
672+ OrchestrationMetadata ? metadata = await client . GetInstanceAsync ( instanceId , false , cancellation ) ;
673+ if ( metadata ? . RuntimeStatus == expectedStatus )
674+ {
675+ return metadata ;
676+ }
677+
678+ await Task . Delay ( TimeSpan . FromMilliseconds ( PollingIntervalMilliseconds ) , cancellation ) ;
679+ }
680+
681+ return await client . GetInstanceAsync ( instanceId , false , cancellation ) ;
682+ }
683+
485684 class DateTimeToleranceComparer : IEqualityComparer < DateTimeOffset >
486685 {
487686 public bool Equals ( DateTimeOffset x , DateTimeOffset y ) => ( x - y ) . Duration ( ) < TimeSpan . FromMilliseconds ( 100 ) ;
0 commit comments