elsa-core/src/modules/Elsa.Scheduling/Services/SchedulingBookmarkReconciler.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

83 lines
3.2 KiB
C#

using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Filters;
namespace Elsa.Scheduling.Services;
/// <summary>
/// Classifies Delay/Timer/Cron/StartAt stored bookmarks against the workflow-instance store so startup
/// schedule rebuild can skip and purge bookmarks whose instance is missing or finished.
/// </summary>
public class SchedulingBookmarkReconciler(IWorkflowInstanceStore workflowInstanceStore, IBookmarkManager? bookmarkManager)
{
/// <summary>
/// Splits bookmarks into those that may be scheduled and those whose instance is missing or terminal.
/// </summary>
public async Task<SchedulingBookmarkClassification> ClassifyAsync(IEnumerable<StoredBookmark> bookmarks, CancellationToken cancellationToken = default)
{
var bookmarkList = bookmarks as IReadOnlyList<StoredBookmark> ?? bookmarks.ToList();
if (bookmarkList.Count == 0)
return new SchedulingBookmarkClassification([], []);
var instanceIds = bookmarkList
.Select(x => x.WorkflowInstanceId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.Ordinal)
.ToList();
var liveInstanceIds = instanceIds.Count == 0
? new HashSet<string>(StringComparer.Ordinal)
: (await workflowInstanceStore.FindManyIdsAsync(new WorkflowInstanceFilter
{
Ids = instanceIds,
WorkflowStatus = WorkflowStatus.Running
}, cancellationToken)).ToHashSet(StringComparer.Ordinal);
var schedulable = new List<StoredBookmark>(bookmarkList.Count);
var orphans = new List<StoredBookmark>();
foreach (var bookmark in bookmarkList)
{
if (!string.IsNullOrWhiteSpace(bookmark.WorkflowInstanceId) && liveInstanceIds.Contains(bookmark.WorkflowInstanceId))
schedulable.Add(bookmark);
else
orphans.Add(bookmark);
}
return new SchedulingBookmarkClassification(schedulable, orphans);
}
/// <summary>
/// Revalidates candidates against the workflow-instance store, then deletes bookmarks that are still orphans.
/// </summary>
public async Task PurgeAsync(IEnumerable<StoredBookmark> candidateBookmarks, CancellationToken cancellationToken = default)
{
if (bookmarkManager == null)
return;
var remainingOrphans = (await ClassifyAsync(candidateBookmarks, cancellationToken)).Orphans;
var bookmarkIds = remainingOrphans
.Select(x => x.Id)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.Ordinal)
.ToList();
if (bookmarkIds.Count == 0)
return;
await bookmarkManager.DeleteManyAsync(new BookmarkFilter
{
BookmarkIds = bookmarkIds
}, cancellationToken);
}
}
/// <summary>
/// The schedulable vs orphan split for a batch of scheduling bookmarks.
/// </summary>
public record SchedulingBookmarkClassification(IReadOnlyList<StoredBookmark> Schedulable, IReadOnlyList<StoredBookmark> Orphans);