@@ -59,6 +59,32 @@ public virtual async Task ExecuteAsyncHang(string input)
5959 semaphoreSlim . Release ( ) ;
6060 await Task . Delay ( Timeout . InfiniteTimeSpan ) ;
6161 }
62+
63+ public virtual async Task ExecuteAsyncObserveCancellation ( string input , CancellationToken cancellationToken )
64+ {
65+ try
66+ {
67+ await Task . Delay ( Timeout . InfiniteTimeSpan , cancellationToken ) ;
68+ }
69+ catch ( OperationCanceledException )
70+ {
71+ semaphoreSlim . Release ( ) ;
72+ throw ;
73+ }
74+ }
75+
76+ public virtual async Task ExecuteAsyncObserveOptionalCancellation ( string input , CancellationToken cancellationToken = default )
77+ {
78+ try
79+ {
80+ await Task . Delay ( Timeout . InfiniteTimeSpan , cancellationToken ) ;
81+ }
82+ catch ( OperationCanceledException )
83+ {
84+ semaphoreSlim . Release ( ) ;
85+ throw ;
86+ }
87+ }
6288 }
6389
6490 private GustoConfig GetTestConfig ( ) => new GustoConfig
@@ -335,6 +361,133 @@ await AssertEventuallyAsync(
335361 }
336362 }
337363
364+ [ Fact ]
365+ public async Task ExecuteAsync_WhenJobHasCancellationToken_OverridesSerializedTokenWithExecutionToken ( )
366+ {
367+ // Arrange
368+ using var queuedTokenSource = new CancellationTokenSource ( ) ;
369+ queuedTokenSource . Cancel ( ) ;
370+
371+ var hangingJob = new TestJob
372+ {
373+ TrackingId = Guid . NewGuid ( ) ,
374+ JobType = typeof ( TestableJob ) . AssemblyQualifiedName ,
375+ MethodName = nameof ( TestableJob . ExecuteAsyncObserveCancellation ) ,
376+ ArgumentsJson = JsonConvert . SerializeObject (
377+ new object [ ] { "observe" , queuedTokenSource . Token } ,
378+ new JsonSerializerSettings { TypeNameHandling = TypeNameHandling . All } ) ,
379+ ExecuteAfter = DateTime . UtcNow ,
380+ IsComplete = false
381+ } ;
382+
383+ var storage = Substitute . For < IJobStorageProvider < TestJob > > ( ) ;
384+ storage . GetBatchAsync ( Arg . Any < JobSearchParams < TestJob > > ( ) , Arg . Any < CancellationToken > ( ) )
385+ . Returns ( new List < TestJob > { hangingJob } , new List < TestJob > ( ) ) ;
386+
387+ var services = new ServiceCollection ( ) ;
388+ services . AddScoped < IJobStorageProvider < TestJob > > ( _ => storage ) ;
389+ var serviceProvider = services . BuildServiceProvider ( ) ;
390+
391+ var logger = Substitute . For < ILogger < JobQueueWorker < TestJob > > > ( ) ;
392+ var config = Options . Create ( new GustoConfig
393+ {
394+ Concurrency = 1 ,
395+ PollInterval = TimeSpan . FromMilliseconds ( 10 ) ,
396+ BatchSize = 1 ,
397+ JobExecutionTimeout = TimeSpan . FromMilliseconds ( 150 )
398+ } ) ;
399+
400+ var worker = new JobQueueWorker < TestJob > ( serviceProvider , config , logger ) ;
401+
402+ // Act
403+ TestableJob . CreateSemaphore ( ) ;
404+ var startedAt = DateTime . UtcNow ;
405+ await worker . StartAsync ( CancellationToken . None ) ;
406+
407+ try
408+ {
409+ var canceledInHandler = await TestableJob . semaphoreSlim . WaitAsync ( TimeSpan . FromSeconds ( 2 ) ) ;
410+ Assert . True ( canceledInHandler ) ;
411+
412+ var elapsed = DateTime . UtcNow - startedAt ;
413+ Assert . True ( elapsed >= TimeSpan . FromMilliseconds ( 100 ) ) ;
414+
415+ await AssertEventuallyAsync (
416+ ( ) =>
417+ {
418+ storage . Received ( ) . OnHandlerExecutionFailureAsync (
419+ hangingJob ,
420+ Arg . Is < Exception > ( ex => ex is TimeoutException ) ,
421+ Arg . Any < CancellationToken > ( ) ) ;
422+ } ,
423+ TimeSpan . FromSeconds ( 2 ) ) ;
424+ }
425+ finally
426+ {
427+ await worker . StopAsync ( CancellationToken . None ) ;
428+ }
429+ }
430+
431+ [ Fact ]
432+ public async Task ExecuteAsync_WhenOptionalCancellationTokenArgumentMissing_InjectsExecutionToken ( )
433+ {
434+ // Arrange
435+ var hangingJob = new TestJob
436+ {
437+ TrackingId = Guid . NewGuid ( ) ,
438+ JobType = typeof ( TestableJob ) . AssemblyQualifiedName ,
439+ MethodName = nameof ( TestableJob . ExecuteAsyncObserveOptionalCancellation ) ,
440+ ArgumentsJson = JsonConvert . SerializeObject (
441+ new object [ ] { "observe" } ,
442+ new JsonSerializerSettings { TypeNameHandling = TypeNameHandling . All } ) ,
443+ ExecuteAfter = DateTime . UtcNow ,
444+ IsComplete = false
445+ } ;
446+
447+ var storage = Substitute . For < IJobStorageProvider < TestJob > > ( ) ;
448+ storage . GetBatchAsync ( Arg . Any < JobSearchParams < TestJob > > ( ) , Arg . Any < CancellationToken > ( ) )
449+ . Returns ( new List < TestJob > { hangingJob } , new List < TestJob > ( ) ) ;
450+
451+ var services = new ServiceCollection ( ) ;
452+ services . AddScoped < IJobStorageProvider < TestJob > > ( _ => storage ) ;
453+ var serviceProvider = services . BuildServiceProvider ( ) ;
454+
455+ var logger = Substitute . For < ILogger < JobQueueWorker < TestJob > > > ( ) ;
456+ var config = Options . Create ( new GustoConfig
457+ {
458+ Concurrency = 1 ,
459+ PollInterval = TimeSpan . FromMilliseconds ( 10 ) ,
460+ BatchSize = 1 ,
461+ JobExecutionTimeout = TimeSpan . FromMilliseconds ( 150 )
462+ } ) ;
463+
464+ var worker = new JobQueueWorker < TestJob > ( serviceProvider , config , logger ) ;
465+
466+ // Act
467+ TestableJob . CreateSemaphore ( ) ;
468+ await worker . StartAsync ( CancellationToken . None ) ;
469+
470+ try
471+ {
472+ var canceledInHandler = await TestableJob . semaphoreSlim . WaitAsync ( TimeSpan . FromSeconds ( 2 ) ) ;
473+ Assert . True ( canceledInHandler ) ;
474+
475+ await AssertEventuallyAsync (
476+ ( ) =>
477+ {
478+ storage . Received ( ) . OnHandlerExecutionFailureAsync (
479+ hangingJob ,
480+ Arg . Is < Exception > ( ex => ex is TimeoutException ) ,
481+ Arg . Any < CancellationToken > ( ) ) ;
482+ } ,
483+ TimeSpan . FromSeconds ( 2 ) ) ;
484+ }
485+ finally
486+ {
487+ await worker . StopAsync ( CancellationToken . None ) ;
488+ }
489+ }
490+
338491 [ Fact ]
339492 public async Task ExecuteAsync_WhenStorageThrowsException_LogsAndDelays ( )
340493 {
0 commit comments