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>
This commit is contained in:
Sipke Schoorstra 2026-09-14 21:03:54 +02:00 committed by GitHub
parent 7e22a12081
commit 2138f0997b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 451 additions and 4 deletions

View file

@ -0,0 +1,82 @@
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);

View file

@ -2,6 +2,8 @@ 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;
@ -12,6 +14,7 @@ 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
@ -32,6 +35,10 @@ public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptio
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[]
{
@ -47,7 +54,7 @@ public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptio
};
await ScheduleTriggersAsync(triggerStore, triggerScheduler, triggerFilter, pageSize, cancellationToken);
await ScheduleBookmarksAsync(bookmarkStore, bookmarkScheduler, bookmarkFilter, 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)
@ -71,9 +78,16 @@ public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptio
}
}
private static async Task ScheduleBookmarksAsync(IBookmarkStore bookmarkStore, IBookmarkScheduler bookmarkScheduler, BookmarkFilter bookmarkFilter, int pageSize, CancellationToken cancellationToken)
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)
{
@ -82,7 +96,18 @@ public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptio
if (page.Items.Count == 0)
break;
await bookmarkScheduler.ScheduleAsync(page.Items, cancellationToken);
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)
@ -90,5 +115,22 @@ public class CreateSchedulesStartupTask(IServiceProvider serviceProvider, IOptio
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);
}
}
}

View file

@ -0,0 +1,134 @@
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
};
}

View file

@ -3,6 +3,8 @@ using Elsa.Common.Multitenancy;
using Elsa.Common.Models;
using Elsa.Scheduling.Options;
using Elsa.Scheduling.StartupTasks;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Filters;
@ -25,6 +27,8 @@ public class CreateSchedulesStartupTaskTests
private readonly IBookmarkStore _bookmarkStore = Substitute.For<IBookmarkStore>();
private readonly ITriggerScheduler _triggerScheduler = Substitute.For<ITriggerScheduler>();
private readonly IBookmarkScheduler _bookmarkScheduler = Substitute.For<IBookmarkScheduler>();
private readonly IWorkflowInstanceStore _workflowInstanceStore = Substitute.For<IWorkflowInstanceStore>();
private readonly IBookmarkManager _bookmarkManager = Substitute.For<IBookmarkManager>();
private readonly SchedulingOptions _options = new() { StartupSchedulePageSize = 1000 };
public CreateSchedulesStartupTaskTests()
@ -33,6 +37,8 @@ public class CreateSchedulesStartupTaskTests
.Returns(new Page<StoredTrigger>(_triggers, _triggers.Length));
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>(_bookmarks, _bookmarks.Length));
_workflowInstanceStore.FindManyIdsAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(call => RunningIds(call.Arg<WorkflowInstanceFilter>()));
}
[Fact]
@ -101,14 +107,197 @@ public class CreateSchedulesStartupTaskTests
await _bookmarkScheduler.Received(1).ScheduleAsync(Arg.Is<IEnumerable<StoredBookmark>>(x => x.SequenceEqual(secondBookmarkPage)), Arg.Any<CancellationToken>());
}
private ServiceProvider CreateServiceProvider(Action<IServiceCollection>? configureServices = null)
[Fact]
public async Task ExecuteAsync_SkipsAndPurgesOrphanSchedulingBookmarks()
{
var missingInstance = Bookmark("missing-bookmark", "missing-instance");
var finishedInstance = Bookmark("finished-bookmark", "finished-instance");
var cancelledInstance = Bookmark("cancelled-bookmark", "cancelled-instance");
var faultedInstance = Bookmark("faulted-bookmark", "faulted-instance");
var emptyInstanceId = Bookmark("empty-instance-bookmark", "");
var suspendedInstance = Bookmark("suspended-bookmark", "suspended-instance");
var bookmarks = new[]
{
missingInstance, finishedInstance, cancelledInstance, faultedInstance, emptyInstanceId, suspendedInstance
};
BookmarkFilter? purgedFilter = null;
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>(bookmarks, bookmarks.Length));
StubBookmarkReload(bookmarks);
_workflowInstanceStore.FindManyIdsAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(["suspended-instance"]);
_bookmarkManager.DeleteManyAsync(Arg.Do<BookmarkFilter>(x => purgedFilter = x), Arg.Any<CancellationToken>())
.Returns(5);
var task = new CreateSchedulesStartupTask(CreateServiceProvider(), OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkScheduler.Received(1).ScheduleAsync(
Arg.Is<IEnumerable<StoredBookmark>>(x => x.SequenceEqual(new[] { suspendedInstance })),
Arg.Any<CancellationToken>());
await _bookmarkManager.Received(1).DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
Assert.NotNull(purgedFilter);
Assert.Equal(
new[]
{
"cancelled-bookmark", "empty-instance-bookmark", "faulted-bookmark", "finished-bookmark", "missing-bookmark"
},
purgedFilter.BookmarkIds?.OrderBy(x => x, StringComparer.Ordinal).ToArray());
}
[Fact]
public async Task ExecuteAsync_SchedulesOrphanWhoseInstanceBecomesRunningBeforePurge()
{
var revived = Bookmark("revived-bookmark", "revived-instance");
var stillMissing = Bookmark("missing-bookmark", "missing-instance");
var bookmarks = new[] { revived, stillMissing };
BookmarkFilter? purgedFilter = null;
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>(bookmarks, bookmarks.Length));
StubBookmarkReload(bookmarks);
_workflowInstanceStore.FindManyIdsAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(_ => Array.Empty<string>(), _ => new[] { "revived-instance" });
_bookmarkManager.DeleteManyAsync(Arg.Do<BookmarkFilter>(x => purgedFilter = x), Arg.Any<CancellationToken>())
.Returns(1);
var task = new CreateSchedulesStartupTask(CreateServiceProvider(), OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkScheduler.Received(1).ScheduleAsync(
Arg.Is<IEnumerable<StoredBookmark>>(x => x.SequenceEqual(new[] { revived })),
Arg.Any<CancellationToken>());
await _bookmarkManager.Received(1).DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
Assert.NotNull(purgedFilter);
Assert.Equal(["missing-bookmark"], purgedFilter.BookmarkIds);
}
[Fact]
public async Task ExecuteAsync_ReconcilesOrphansInConfiguredPages()
{
var firstOrphan = Bookmark("orphan-1", "missing-1");
var secondOrphan = Bookmark("orphan-2", "missing-2");
var bookmarks = new[] { firstOrphan, secondOrphan };
var purgedFilters = new List<BookmarkFilter>();
var reloadedIdBatches = new List<ICollection<string>>();
_options.StartupSchedulePageSize = 1;
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Is<PageArgs>(x => x.Offset == 0 && x.Limit == 1), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>([firstOrphan], 2));
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Is<PageArgs>(x => x.Offset == 1 && x.Limit == 1), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>([secondOrphan], 2));
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
var ids = call.Arg<BookmarkFilter>().BookmarkIds ?? [];
reloadedIdBatches.Add(ids.ToList());
return bookmarks.Where(x => ids.Contains(x.Id));
});
_workflowInstanceStore.FindManyIdsAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<string>());
_bookmarkManager.DeleteManyAsync(Arg.Do<BookmarkFilter>(x => purgedFilters.Add(x)), Arg.Any<CancellationToken>())
.Returns(1);
var task = new CreateSchedulesStartupTask(CreateServiceProvider(), OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkScheduler.DidNotReceive().ScheduleAsync(Arg.Any<IEnumerable<StoredBookmark>>(), Arg.Any<CancellationToken>());
Assert.Equal(2, reloadedIdBatches.Count);
Assert.All(reloadedIdBatches, batch => Assert.Single(batch));
Assert.Equal(2, purgedFilters.Count);
Assert.All(purgedFilters, filter => Assert.Single(filter.BookmarkIds!));
Assert.Equal(["orphan-1", "orphan-2"], purgedFilters.SelectMany(x => x.BookmarkIds!).OrderBy(x => x, StringComparer.Ordinal));
}
[Fact]
public async Task ExecuteAsync_WithoutWorkflowInstanceStore_SchedulesAllBookmarks()
{
var missingInstance = Bookmark("missing-bookmark", "missing-instance");
var bookmarks = new[] { missingInstance, _bookmarks[0] };
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>(bookmarks, bookmarks.Length));
var task = new CreateSchedulesStartupTask(CreateServiceProvider(registerReconcileServices: false), OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkScheduler.Received(1).ScheduleAsync(Arg.Is<IEnumerable<StoredBookmark>>(x => x.SequenceEqual(bookmarks)), Arg.Any<CancellationToken>());
await _bookmarkManager.DidNotReceive().DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_WithoutBookmarkManager_SkipsOrphansButDoesNotPurge()
{
var missingInstance = Bookmark("missing-bookmark", "missing-instance");
var suspendedInstance = Bookmark("suspended-bookmark", "suspended-instance");
var bookmarks = new[] { missingInstance, suspendedInstance };
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(new Page<StoredBookmark>(bookmarks, bookmarks.Length));
StubBookmarkReload(bookmarks);
_workflowInstanceStore.FindManyIdsAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(["suspended-instance"]);
var task = new CreateSchedulesStartupTask(
CreateServiceProvider(services => services.AddSingleton(_workflowInstanceStore), registerReconcileServices: false),
OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkScheduler.Received(1).ScheduleAsync(
Arg.Is<IEnumerable<StoredBookmark>>(x => x.SequenceEqual(new[] { suspendedInstance })),
Arg.Any<CancellationToken>());
await _bookmarkManager.DidNotReceive().DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_DoesNotPurgeWhenEverySchedulingBookmarkIsLive()
{
var task = new CreateSchedulesStartupTask(CreateServiceProvider(), OptionsFactory.Create(_options));
await task.ExecuteAsync(CancellationToken.None);
await _bookmarkManager.DidNotReceive().DeleteManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>());
}
private ServiceProvider CreateServiceProvider(Action<IServiceCollection>? configureServices = null, bool registerReconcileServices = true)
{
var services = new ServiceCollection();
services.AddSingleton(_triggerStore);
services.AddSingleton(_bookmarkStore);
services.AddSingleton(_triggerScheduler);
services.AddSingleton(_bookmarkScheduler);
if (registerReconcileServices)
{
services.AddSingleton(_workflowInstanceStore);
services.AddSingleton(_bookmarkManager);
}
configureServices?.Invoke(services);
return services.BuildServiceProvider();
}
private void StubBookmarkReload(IEnumerable<StoredBookmark> bookmarks)
{
var bookmarkList = bookmarks.ToList();
_bookmarkStore.FindManyAsync(Arg.Any<BookmarkFilter>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
var ids = call.Arg<BookmarkFilter>().BookmarkIds;
if (ids == null)
return bookmarkList.AsEnumerable();
var idSet = ids.ToHashSet(StringComparer.Ordinal);
return bookmarkList.Where(x => idSet.Contains(x.Id));
});
}
private static IEnumerable<string> RunningIds(WorkflowInstanceFilter filter)
{
return (filter.Ids ?? []).Where(id => !string.IsNullOrWhiteSpace(id));
}
private static StoredBookmark Bookmark(string id, string workflowInstanceId) => new()
{
Id = id,
Hash = "hash",
Name = SchedulingStimulusNames.Delay,
WorkflowInstanceId = workflowInstanceId
};
}