* 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>
137 lines
5.5 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|