doc/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
Date: November 20, 2025 Issue: #7077 Branch: bug/7077 Type: Bug Fix + Integration Tests
When deleting triggers, the TriggerIndexer.DeleteTriggersAsync method would fail completely if any workflow failed to load due to a missing materializer or other exceptions. This resulted in the error:
System.Exception: Provider not found
at Elsa.Workflows.Management.Services.WorkflowDefinitionService.MaterializeWorkflowAsync
The method would stop processing remaining workflows, leaving triggers in an inconsistent state.
src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs)Added a try-catch block in the DeleteTriggersAsync method (lines 67-79):
foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds)
{
try
{
var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(
workflowDefinitionVersionId, cancellationToken);
if (workflowGraph == null)
continue;
await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. " +
"Skipping trigger deletion for this workflow.",
workflowDefinitionVersionId);
}
}
Benefits:
test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/)Created comprehensive integration tests in a new TriggerIndexing scenario folder with Tests.cs:
[Fact(DisplayName = "DeleteTriggersAsync should continue deleting triggers even when workflow fails to load")]
public async Task DeleteTriggersAsync_WorkflowFailsToLoad_ContinuesWithOtherTriggers()
[Fact(DisplayName = "DeleteTriggersAsync processes all workflows even when one fails")]
public async Task DeleteTriggersAsync_OneWorkflowFails_ProcessesAllWorkflows()
Custom Materializers:
FailingMaterializer: Throws "Provider not found" exception to simulate the reported issueWorkingMaterializer: Successfully materializes workflows from a registry for valid test casesHelper Methods (DRY principle):
CreateWorkflowDefinition(): Factory method for workflow definitionsCreateTrigger(): Factory method for triggersSaveWorkflowsAndTriggersAsync(): Common save and verification logicModern C# Features:
file scoped classes for helper materializers (limits visibility)WorkingMaterializerTriggerIndexerTests.cs to Tests.cs (project convention)Both tests pass successfully:
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 304 ms
✓ DeleteTriggersAsync should continue deleting triggers even when workflow fails to load [153 ms]
✓ DeleteTriggersAsync processes all workflows even when one fails [49 ms]
src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs)When a scheduled workflow instance is deleted before its scheduled resume time, the ResumeWorkflowTask would throw an unhandled exception:
Elsa.Workflows.Runtime.Exceptions.WorkflowInstanceNotFoundException: Workflow instance not found.
at Elsa.Workflows.Runtime.LocalWorkflowClient.GetWorkflowInstanceAsync
This would cause the scheduled task to fail, even though the workflow instance no longer exists and should simply be skipped.
Added exception handling to gracefully handle missing workflow instances:
public async ValueTask ExecuteAsync(TaskExecutionContext context)
{
var cancellationToken = context.CancellationToken;
var workflowRuntime = context.ServiceProvider.GetRequiredService<IWorkflowRuntime>();
var logger = context.ServiceProvider.GetRequiredService<ILogger<ResumeWorkflowTask>>();
try
{
var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken);
var request = new RunWorkflowInstanceRequest
{
Input = _request.Input,
Properties = _request.Properties,
ActivityHandle = _request.ActivityHandle,
BookmarkId = _request.BookmarkId
};
await workflowClient.RunInstanceAsync(request, cancellationToken);
}
catch (WorkflowInstanceNotFoundException ex)
{
logger.LogWarning(
"Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.",
ex.InstanceId);
}
}
Benefits:
src/modules/Elsa.Scheduling/Services/LocalScheduler.cs)The LocalScheduler was experiencing race conditions during concurrent access, particularly during application startup when multiple workflows are being scheduled simultaneously:
System.IndexOutOfRangeException: Index was outside the bounds of the array.
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.set_Item(TKey key, TValue value)
at Elsa.Scheduling.Services.LocalScheduler.RegisterScheduledTask
This exception occurs when multiple threads modify the internal Dictionary instances concurrently without synchronization.
Two dictionaries were being accessed concurrently without any thread safety:
_scheduledTasks: Maps task names to scheduled tasks_scheduledTaskKeys: Maps scheduled tasks to their keysMethods like ScheduleAsync, ClearScheduleAsync, and internal helper methods all modified these dictionaries without locks.
Added thread synchronization using a lock object:
public class LocalScheduler : IScheduler
{
private readonly IServiceProvider _serviceProvider;
private readonly IDictionary<string, IScheduledTask> _scheduledTasks = new Dictionary<string, IScheduledTask>();
private readonly IDictionary<IScheduledTask, ICollection<string>> _scheduledTaskKeys = new Dictionary<IScheduledTask, ICollection<string>>();
private readonly object _lock = new(); // Added lock object
public ValueTask ScheduleAsync(string name, ITask task, ISchedule schedule, IEnumerable<string>? keys = null, CancellationToken cancellationToken = default)
{
var scheduleContext = new ScheduleContext(_serviceProvider, task);
var scheduledTask = schedule.Schedule(scheduleContext);
lock (_lock) // Protected dictionary access
{
RegisterScheduledTask(name, scheduledTask, keys);
}
return ValueTask.CompletedTask;
}
public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default)
{
lock (_lock) // Protected dictionary access
{
RemoveScheduledTask(name);
}
return ValueTask.CompletedTask;
}
public ValueTask ClearScheduleAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default)
{
lock (_lock) // Protected dictionary access
{
RemoveScheduledTasks(keys);
}
return ValueTask.CompletedTask;
}
}
Benefits:
IndexOutOfRangeException during dictionary modificationsImplementation Notes:
RegisterScheduledTask, RemoveScheduledTask, RemoveScheduledTasks) are called within locks, so they don't need additional synchronizationWhy lock instead of SemaphoreSlim?
The implementation uses a simple lock rather than SemaphoreSlim for optimal performance:
| Aspect | lock | SemaphoreSlim |
|---|---|---|
| Overhead | Zero allocation | Heap allocations |
| Complexity | Automatic release | Manual try/finally |
| Use Case | Perfect for synchronous ops | Better for async ops |
| Performance | Compiler optimized | Additional overhead |
| Async Support | Cannot cross await | Can use WaitAsync |
Decision Rationale:
ValueTask.CompletedTask - Not truly async, just implementing interface signaturelock is ideal for fast synchronous operationsSemaphoreSlim would be appropriate if:
await statementsFor this use case, lock is the optimal choice for simplicity, performance, and correctness.
src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs - Added exception handling for workflow loading failurestest/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs - New integration testssrc/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs - Added exception handling for missing workflow instancessrc/modules/Elsa.Scheduling/Services/LocalScheduler.cs - Added thread synchronization for concurrent access