elsa-core/src/modules/Elsa.Scheduling/StartupTasks/CreateSchedulesStartupTask.cs
Sipke Schoorstra 2138f0997b
fix(scheduling): purge orphan Delay/Timer/Cron/StartAt bookmarks on startup (#8161)
* fix(scheduling): purge orphan Delay/Timer/Cron/StartAt bookmarks on startup

Reconcile stored scheduling bookmarks against the workflow-instance store
during CreateSchedulesStartupTask so missing and finished instances are
skipped and deleted instead of being re-scheduled on every rebuild.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): add missing WorkflowStatus using in bookmark reconciler

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): revalidate orphan bookmarks immediately before purge

Collect only bookmark IDs during paged rebuild, reload those rows, and
re-run classification so a candidate whose instance became Running is
not deleted. Still purge missing/blank/terminal bookmarks only.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): schedule revived bookmarks and bound reconcile batches

Revalidation now schedules bookmarks whose instance became Running,
chunks reload/classify/delete by StartupSchedulePageSize, and skips
reconcile when IWorkflowInstanceStore or IBookmarkManager is absent.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 21:03:54 +02:00

137 lines
5.5 KiB
C#

using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Common.Models;
using Elsa.Scheduling.Options;
using Elsa.Scheduling.Services;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Elsa.Scheduling.StartupTasks;
/// <summary>
/// Enqueues schedule creation when using the default scheduler, which doesn't have its own persistence layer like Quartz or Hangfire.
/// Scheduling bookmarks whose workflow instance is missing or finished are skipped and purged so startup does not rehydrate dead work.
/// </summary>
[TaskDependency(typeof(PopulateRegistriesStartupTask))]
public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptions<SchedulingOptions> options) : IStartupTask
{
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var workQueue = serviceProvider.GetService<ITenantBackgroundWorkQueue>();
if (workQueue != null)
await workQueue.EnqueueAsync(CreateSchedulesAsync, cancellationToken);
else
await CreateSchedulesAsync(serviceProvider, cancellationToken);
}
private async Task CreateSchedulesAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var triggerStore = serviceProvider.GetRequiredService<ITriggerStore>();
var bookmarkStore = serviceProvider.GetRequiredService<IBookmarkStore>();
var triggerScheduler = serviceProvider.GetRequiredService<ITriggerScheduler>();
var bookmarkScheduler = serviceProvider.GetRequiredService<IBookmarkScheduler>();
var workflowInstanceStore = serviceProvider.GetService<IWorkflowInstanceStore>();
var bookmarkReconciler = workflowInstanceStore == null
? null
: new SchedulingBookmarkReconciler(workflowInstanceStore, serviceProvider.GetService<IBookmarkManager>());
var pageSize = Math.Max(1, options.Value.StartupSchedulePageSize);
var stimulusNames = new[]
{
SchedulingStimulusNames.Cron, SchedulingStimulusNames.Timer, SchedulingStimulusNames.StartAt, SchedulingStimulusNames.Delay,
};
var triggerFilter = new TriggerFilter
{
Names = stimulusNames
};
var bookmarkFilter = new BookmarkFilter
{
Names = stimulusNames
};
await ScheduleTriggersAsync(triggerStore, triggerScheduler, triggerFilter, pageSize, cancellationToken);
await ScheduleBookmarksAsync(bookmarkStore, bookmarkScheduler, bookmarkReconciler, bookmarkFilter, pageSize, cancellationToken);
}
private static async Task ScheduleTriggersAsync(ITriggerStore triggerStore, ITriggerScheduler triggerScheduler, TriggerFilter triggerFilter, int pageSize, CancellationToken cancellationToken)
{
var pageArgs = PageArgs.FromRange(0, pageSize);
while (true)
{
var page = await triggerStore.FindManyAsync(triggerFilter, pageArgs, cancellationToken);
if (page.Items.Count == 0)
break;
await triggerScheduler.ScheduleAsync(page.Items, cancellationToken);
var nextOffset = pageArgs.Offset.GetValueOrDefault() + page.Items.Count;
if (nextOffset >= page.TotalCount)
break;
pageArgs = pageArgs.Next();
}
}
private static async Task ScheduleBookmarksAsync(
IBookmarkStore bookmarkStore,
IBookmarkScheduler bookmarkScheduler,
SchedulingBookmarkReconciler? bookmarkReconciler,
BookmarkFilter bookmarkFilter,
int pageSize,
CancellationToken cancellationToken)
{
var pageArgs = PageArgs.FromRange(0, pageSize);
var orphanBookmarkIds = new HashSet<string>(StringComparer.Ordinal);
while (true)
{
var page = await bookmarkStore.FindManyAsync(bookmarkFilter, pageArgs, cancellationToken);
if (page.Items.Count == 0)
break;
if (bookmarkReconciler == null)
{
await bookmarkScheduler.ScheduleAsync(page.Items, cancellationToken);
}
else
{
var classification = await bookmarkReconciler.ClassifyAsync(page.Items, cancellationToken);
orphanBookmarkIds.UnionWith(classification.Orphans.Select(x => x.Id).Where(id => !string.IsNullOrWhiteSpace(id)));
if (classification.Schedulable.Count > 0)
await bookmarkScheduler.ScheduleAsync(classification.Schedulable, cancellationToken);
}
var nextOffset = pageArgs.Offset.GetValueOrDefault() + page.Items.Count;
if (nextOffset >= page.TotalCount)
break;
pageArgs = pageArgs.Next();
}
if (bookmarkReconciler == null || orphanBookmarkIds.Count == 0)
return;
foreach (var orphanIdBatch in orphanBookmarkIds.Chunk(pageSize))
{
var candidates = await bookmarkStore.FindManyAsync(new BookmarkFilter
{
BookmarkIds = orphanIdBatch.ToList()
}, cancellationToken);
var classification = await bookmarkReconciler.ClassifyAsync(candidates, cancellationToken);
if (classification.Schedulable.Count > 0)
await bookmarkScheduler.ScheduleAsync(classification.Schedulable, cancellationToken);
await bookmarkReconciler.PurgeAsync(classification.Orphans, cancellationToken);
}
}
}