* 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>
135 lines
5.5 KiB
C#
135 lines
5.5 KiB
C#
using Elsa.Common.Services;
|
|
using Elsa.Mediator.Contracts;
|
|
using Elsa.Scheduling.Services;
|
|
using Elsa.Workflows;
|
|
using Elsa.Workflows.Management.Entities;
|
|
using Elsa.Workflows.Management.Stores;
|
|
using Elsa.Workflows.Runtime;
|
|
using Elsa.Workflows.Runtime.Entities;
|
|
using Elsa.Workflows.Runtime.Filters;
|
|
using Elsa.Workflows.Runtime.Stores;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Scheduling.UnitTests.Services;
|
|
|
|
public class SchedulingBookmarkReconcilerTests
|
|
{
|
|
[Fact]
|
|
public async Task ClassifyAsync_SkipsMissingBlankAndFinishedInstances()
|
|
{
|
|
var instanceStore = CreateInstanceStore(
|
|
Instance("running", WorkflowStatus.Running, WorkflowSubStatus.Executing),
|
|
Instance("suspended", WorkflowStatus.Running, WorkflowSubStatus.Suspended),
|
|
Instance("interrupted", WorkflowStatus.Running, WorkflowSubStatus.Interrupted),
|
|
Instance("pending", WorkflowStatus.Running, WorkflowSubStatus.Pending),
|
|
Instance("finished", WorkflowStatus.Finished, WorkflowSubStatus.Finished),
|
|
Instance("cancelled", WorkflowStatus.Finished, WorkflowSubStatus.Cancelled),
|
|
Instance("faulted", WorkflowStatus.Finished, WorkflowSubStatus.Faulted));
|
|
var reconciler = new SchedulingBookmarkReconciler(instanceStore, Substitute.For<IBookmarkManager>());
|
|
|
|
var result = await reconciler.ClassifyAsync(
|
|
[
|
|
Bookmark("b-missing", "missing"),
|
|
Bookmark("b-blank", ""),
|
|
Bookmark("b-finished", "finished"),
|
|
Bookmark("b-cancelled", "cancelled"),
|
|
Bookmark("b-faulted", "faulted"),
|
|
Bookmark("b-running", "running"),
|
|
Bookmark("b-suspended", "suspended"),
|
|
Bookmark("b-interrupted", "interrupted"),
|
|
Bookmark("b-pending", "pending")
|
|
]);
|
|
|
|
Assert.Equal(["b-running", "b-suspended", "b-interrupted", "b-pending"], result.Schedulable.Select(x => x.Id));
|
|
Assert.Equal(["b-missing", "b-blank", "b-finished", "b-cancelled", "b-faulted"], result.Orphans.Select(x => x.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClassifyAsync_KeepsPastDueSuspendedDelayBookmarks()
|
|
{
|
|
var instanceStore = CreateInstanceStore(Instance("suspended", WorkflowStatus.Running, WorkflowSubStatus.Suspended));
|
|
var reconciler = new SchedulingBookmarkReconciler(instanceStore, Substitute.For<IBookmarkManager>());
|
|
|
|
var result = await reconciler.ClassifyAsync([Bookmark("past-due-delay", "suspended")]);
|
|
|
|
Assert.Equal(["past-due-delay"], result.Schedulable.Select(x => x.Id));
|
|
Assert.Empty(result.Orphans);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PurgeAsync_DeletesOrphanBookmarksFromTheStore()
|
|
{
|
|
var bookmarkStore = new MemoryBookmarkStore(new MemoryStore<StoredBookmark>());
|
|
await bookmarkStore.SaveManyAsync(
|
|
[
|
|
Bookmark("orphan-1", "missing"),
|
|
Bookmark("orphan-2", "finished"),
|
|
Bookmark("live", "suspended")
|
|
], CancellationToken.None);
|
|
var bookmarkManager = new DefaultBookmarkManager(bookmarkStore, Substitute.For<INotificationSender>(), NullLogger<DefaultBookmarkManager>.Instance);
|
|
var reconciler = new SchedulingBookmarkReconciler(CreateInstanceStore(), bookmarkManager);
|
|
|
|
await reconciler.PurgeAsync([Bookmark("orphan-1", "missing"), Bookmark("orphan-2", "finished")]);
|
|
|
|
var remaining = (await bookmarkStore.FindManyAsync(new BookmarkFilter())).Select(x => x.Id).ToList();
|
|
Assert.Equal(["live"], remaining);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PurgeAsync_DoesNotDeleteWhenThereAreNoOrphans()
|
|
{
|
|
var bookmarkManager = Substitute.For<IBookmarkManager>();
|
|
var reconciler = new SchedulingBookmarkReconciler(CreateInstanceStore(), bookmarkManager);
|
|
|
|
await reconciler.PurgeAsync([]);
|
|
|
|
await bookmarkManager.DidNotReceive().DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PurgeAsync_DoesNotDeleteWhenInstanceBecameRunning()
|
|
{
|
|
var bookmarkManager = Substitute.For<IBookmarkManager>();
|
|
var reconciler = new SchedulingBookmarkReconciler(
|
|
CreateInstanceStore(Instance("revived", WorkflowStatus.Running, WorkflowSubStatus.Suspended)),
|
|
bookmarkManager);
|
|
|
|
await reconciler.PurgeAsync([Bookmark("revived-bookmark", "revived")]);
|
|
|
|
await bookmarkManager.DidNotReceive().DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PurgeAsync_DoesNotDeleteWhenBookmarkManagerIsMissing()
|
|
{
|
|
var reconciler = new SchedulingBookmarkReconciler(CreateInstanceStore(), bookmarkManager: null);
|
|
|
|
await reconciler.PurgeAsync([Bookmark("orphan-1", "missing")]);
|
|
}
|
|
|
|
private static MemoryWorkflowInstanceStore CreateInstanceStore(params WorkflowInstance[] instances)
|
|
{
|
|
var store = new MemoryStore<WorkflowInstance>();
|
|
store.AddMany(instances, x => x.Id);
|
|
return new MemoryWorkflowInstanceStore(store);
|
|
}
|
|
|
|
private static WorkflowInstance Instance(string id, WorkflowStatus status, WorkflowSubStatus subStatus) => new()
|
|
{
|
|
Id = id,
|
|
DefinitionId = "definition",
|
|
DefinitionVersionId = "version",
|
|
Status = status,
|
|
SubStatus = subStatus
|
|
};
|
|
|
|
private static StoredBookmark Bookmark(string id, string workflowInstanceId) => new()
|
|
{
|
|
Id = id,
|
|
Hash = "hash",
|
|
Name = SchedulingStimulusNames.Delay,
|
|
WorkflowInstanceId = workflowInstanceId
|
|
};
|
|
}
|