* Refactor workflow instance deletion to use `IWorkflowRuntime` for enhanced coordination and separation of concerns. * Remove `EnumerableTypeConverter` and update related usages for serialization. - Deleted the `EnumerableTypeConverter` class and its JSON serialization logic. - Removed associated type descriptor attribute in `DefaultFormattersFeature`. - Updated `ObjectFormatter` to handle collection serialization directly with JSON. * Remove `EnumerableTypeConverter` tests and consolidate serialization logic into `ObjectFormatter`. - Deleted `EnumerableTypeConverterTests` as the related functionality was removed. - Added comprehensive tests in `ObjectFormatterTests` to handle serialization of collections and arrays with JSON. * Add integration tests for `TriggerIndexer` to handle workflows with failing materialization - Introduced comprehensive test scenarios verifying `DeleteTriggersAsync` behavior when workflows fail to load or partially succeed. - Enhanced error handling in `TriggerIndexer` to skip failed workflows while ensuring remaining workflows are processed. * Add exception handling in `TriggerIndexer.DeleteTriggersAsync` and integration tests - Enhanced `DeleteTriggersAsync` with exception handling to skip failed workflows while processing others. - Logged warnings for failed workflows without halting execution. - Added comprehensive integration tests to verify behavior across success, failure, and mixed scenarios. - Refactored tests for improved clarity, maintainability, and consistency. * Add exception handling for `ResumeWorkflowTask` to skip deleted workflow instances - Enhanced `ResumeWorkflowTask.ExecuteAsync` to handle `WorkflowInstanceNotFoundException` gracefully when a scheduled workflow instance is missing. - Logged warnings for skipped executions to improve observability. - Ensured remaining workflows and scheduled tasks are processed seamlessly without disruption. * Add thread safety to `LocalScheduler` to prevent race conditions during concurrent scheduling - Introduced a `lock` object to synchronize access to internal dictionaries. - Resolved `IndexOutOfRangeException` caused by concurrent modifications during startup. - Ensured thread-safe operations in `ScheduleAsync`, `ClearScheduleAsync`, and related methods. - Improved reliability and stability of scheduling under concurrent workloads. * Improve exception handling, thread safety, and workflow instance deletion - Added exception handling in `TriggerIndexer.DeleteTriggersAsync` to skip failed workflows while continuing processing. - Enhanced `ResumeWorkflowTask` to handle missing workflow instances gracefully and log warnings. - Introduced thread synchronization in `LocalScheduler` with `lock` to prevent concurrent access issues. - Implemented and refactored tests to ensure behavior consistency and improve maintainability. - Added component tests for workflow deletion scenarios, covering running, completed, and non-existent workflows. * Add component tests for workflow instance deletion and refactor bulk delete logic - Added comprehensive component tests for workflow instance deletion scenarios (running, completed, bulk, and non-existent instances). - Refactored `BulkDelete` API to use `IWorkflowInstanceManager` for proper cleanup of related records (execution logs, activity executions, bookmarks). * Add integration tests and fakes for `TriggerIndexer` to verify behavior with failing and successful workflows - Introduced `FailingMaterializer` and `WorkingMaterializer` for simulating failing and successful workflow materializations. - Added `TriggerDeletionTestScenario`, `TriggerTestDataBuilder`, and related test data classes to define comprehensive test cases. - Updated `DeleteTriggersAsync` tests with scenarios for materialization failures and mixed success. - Improved test coverage and maintainability with reusable test data builders and utilities.
198 lines
7.4 KiB
C#
198 lines
7.4 KiB
C#
using Elsa.Common;
|
|
using Elsa.Mediator.Contracts;
|
|
using Elsa.Scheduling.ScheduledTasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Scheduling.UnitTests.ScheduledTasks;
|
|
|
|
/// <summary>
|
|
/// Tests for ScheduledRecurringTask to ensure recurring tasks handle edge cases correctly.
|
|
/// </summary>
|
|
public class ScheduledRecurringTaskTests : IDisposable
|
|
{
|
|
private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero);
|
|
private static readonly TimeSpan DefaultInterval = TimeSpan.FromMinutes(5);
|
|
|
|
private readonly ServiceProvider _serviceProvider;
|
|
private readonly ISystemClock _systemClock;
|
|
private readonly ILogger<ScheduledRecurringTask> _logger;
|
|
private readonly List<ScheduledRecurringTask> _tasksToDispose = new();
|
|
|
|
public ScheduledRecurringTaskTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
_systemClock = Substitute.For<ISystemClock>();
|
|
_logger = Substitute.For<ILogger<ScheduledRecurringTask>>();
|
|
|
|
services.AddSingleton(Substitute.For<ICommandSender>());
|
|
_serviceProvider = services.BuildServiceProvider();
|
|
}
|
|
|
|
private ScheduledRecurringTask CreateScheduledTask(
|
|
DateTimeOffset? startAt = null,
|
|
TimeSpan? interval = null,
|
|
ISystemClock? systemClock = null)
|
|
{
|
|
var task = Substitute.For<ITask>();
|
|
var scheduledTask = new ScheduledRecurringTask(
|
|
task,
|
|
startAt ?? DefaultNow.AddMinutes(5),
|
|
interval ?? DefaultInterval,
|
|
systemClock ?? _systemClock,
|
|
_serviceProvider.CreateScope().ServiceProvider.GetRequiredService<IServiceScopeFactory>(),
|
|
_logger
|
|
);
|
|
_tasksToDispose.Add(scheduledTask);
|
|
return scheduledTask;
|
|
}
|
|
|
|
private void SetupSystemClock(params DateTimeOffset[] times)
|
|
{
|
|
_systemClock.UtcNow.Returns(times[0], times.Skip(1).ToArray());
|
|
}
|
|
|
|
private void AssertNoErrorLogged()
|
|
{
|
|
_logger.DidNotReceive().Log(
|
|
LogLevel.Error,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
private void AssertWarningLogged(int expectedCount = 1)
|
|
{
|
|
_logger.Received(expectedCount).Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Schedule_WithVerySmallDelay_ShouldStillSetupTimer()
|
|
{
|
|
// Arrange - simulate a case where the delay is very small (1 tick = 100ns)
|
|
SetupSystemClock(DefaultNow);
|
|
var startAt = DefaultNow.AddTicks(1); // Only 1 tick in the future (100 nanoseconds)
|
|
|
|
// Act
|
|
CreateScheduledTask(startAt: startAt);
|
|
|
|
// Assert - Verify that no error was logged (timer should be set up successfully)
|
|
AssertNoErrorLogged();
|
|
}
|
|
|
|
[Fact]
|
|
public void Schedule_WithZeroDelay_ShouldRetryAndSetupTimer()
|
|
{
|
|
// Arrange - simulate a case where the first call returns exactly now
|
|
// but the second call returns a proper future time
|
|
SetupSystemClock(DefaultNow, DefaultNow);
|
|
var startAt = DefaultNow; // First: delay=0
|
|
|
|
// Act
|
|
var task = CreateScheduledTask(startAt: startAt);
|
|
|
|
// Dispose immediately to prevent timer from firing
|
|
((IDisposable)task).Dispose();
|
|
|
|
// Assert - System clock should be called at least twice (initial + retry)
|
|
// May be called more if timer fires before disposal in rare race conditions
|
|
_ = _systemClock.Received().UtcNow;
|
|
var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
|
|
Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Schedule_WithNegativeDelay_ShouldRetryAndSetupTimer()
|
|
{
|
|
// Arrange - simulate a case where the first call returns a time in the past
|
|
SetupSystemClock(DefaultNow, DefaultNow);
|
|
var startAt = DefaultNow.AddMinutes(-1); // Past time
|
|
|
|
// Act
|
|
var task = CreateScheduledTask(startAt: startAt);
|
|
|
|
// Dispose immediately to prevent timer from firing
|
|
((IDisposable)task).Dispose();
|
|
|
|
// Assert - System clock should be called at least twice (initial + retry)
|
|
// May be called more if timer fires before disposal in rare race conditions
|
|
_ = _systemClock.Received().UtcNow;
|
|
var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
|
|
Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Schedule_WithPersistentZeroDelay_ShouldLogWarningAndUseMinimumDelay()
|
|
{
|
|
// Arrange - simulate the bug scenario: both attempts return zero/negative delay
|
|
// This can happen if the system clock doesn't advance or if there's clock drift
|
|
SetupSystemClock(DefaultNow);
|
|
var startAt = DefaultNow; // Both calls return exactly now (delay = 0)
|
|
|
|
// Act - This should not crash and should set up a timer with minimum delay
|
|
var task = CreateScheduledTask(startAt: startAt);
|
|
|
|
// Dispose immediately to prevent timer from firing and recursing
|
|
((IDisposable)task).Dispose();
|
|
Thread.Sleep(5); // Brief wait to ensure disposal completes
|
|
|
|
// Assert - Should call UtcNow at least twice (initial + retry)
|
|
// May be called more if timer fires before disposal
|
|
_ = _systemClock.Received().UtcNow;
|
|
var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
|
|
Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
|
|
AssertWarningLogged();
|
|
}
|
|
|
|
[Fact]
|
|
public void Schedule_WithNegativeDelayAfterRetry_ShouldLogWarningAndUseMinimumDelay()
|
|
{
|
|
// Arrange - simulate a case where even after retry, delay is negative
|
|
// This could happen due to system clock adjustments
|
|
SetupSystemClock(DefaultNow, DefaultNow);
|
|
var startAt = DefaultNow.AddMilliseconds(-100); // Negative delay
|
|
|
|
// Act - Should handle negative delay gracefully
|
|
var task = CreateScheduledTask(startAt: startAt);
|
|
|
|
// Dispose immediately to prevent timer from firing
|
|
((IDisposable)task).Dispose();
|
|
|
|
// Assert - Should log a warning and still set up timer
|
|
AssertWarningLogged();
|
|
}
|
|
|
|
[Fact]
|
|
public void DisposeDuringTimerCallback_ShouldNotCrash()
|
|
{
|
|
// Arrange - set up a very short delay so timer fires quickly
|
|
SetupSystemClock(DefaultNow);
|
|
var startAt = DefaultNow; // Will use 1ms minimum delay
|
|
|
|
// Act - Create task and immediately dispose it (simulating race condition)
|
|
var task = CreateScheduledTask(startAt: startAt);
|
|
Thread.Sleep(5); // Give timer a chance to start firing
|
|
((IDisposable)task).Dispose();
|
|
Thread.Sleep(10); // Give any in-flight callbacks time to complete
|
|
|
|
// Assert - Should not crash (implicit - test passes if no exception thrown)
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Dispose tasks first to stop timers before disposing ServiceProvider
|
|
foreach (var task in _tasksToDispose)
|
|
{
|
|
((IDisposable)task).Dispose();
|
|
}
|
|
_serviceProvider.Dispose();
|
|
}
|
|
}
|