Incremental work on Azure Service Bus

This commit is contained in:
Sipke Schoorstra 2022-02-22 12:51:59 +01:00
parent 88609d5a62
commit 0d06ae1823
66 changed files with 765 additions and 172 deletions

View file

@ -11,7 +11,7 @@ public static class Trigger
public static IResult Handle(string eventName, IHasher hasher)
{
var hash = hasher.Hash(eventName);
var stimulus = Stimuli.Standard(nameof(Event), hash);
var stimulus = Stimulus.Standard(nameof(Event), hash);
return new ProcessStimulusResult(stimulus);
}
}

View file

@ -15,8 +15,4 @@
<PackageReference Include="System.Text.Json" Version="6.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Converters" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,30 @@
namespace Elsa.Helpers;
/// <summary>
/// A simple utility that compares two collections and returns a set of added, removed and unchanged items.
/// </summary>
public class Diff<T>
{
internal Diff(ICollection<T> added, ICollection<T> removed, ICollection<T> unchanged)
{
Added = added;
Removed = removed;
Unchanged = unchanged;
}
public ICollection<T> Added { get; }
public ICollection<T> Removed { get; }
public ICollection<T> Unchanged { get; }
}
public static class Diff
{
public static Diff<T> For<T>(ICollection<T> firstSet, ICollection<T> secondSet)
{
var added = firstSet.Except(secondSet).ToList();
var removed = secondSet.Except(firstSet).ToList();
var unchanged = firstSet.Intersect(secondSet).ToList();
return new Diff<T>(added, removed, unchanged);
}
}

View file

@ -18,11 +18,24 @@ public class TriggerActivity : Activity, ITrigger
set => NodeType = value;
}
public virtual ValueTask<IEnumerable<object>> GetPayloadsAsync(TriggerIndexingContext context, CancellationToken cancellationToken = default)
ValueTask<IEnumerable<object>> ITrigger.GetPayloadsAsync(TriggerIndexingContext context, CancellationToken cancellationToken) => GetPayloadsAsync(context, cancellationToken);
/// <summary>
/// Override this method to return a list of trigger payloads.
/// </summary>
protected virtual ValueTask<IEnumerable<object>> GetPayloadsAsync(TriggerIndexingContext context, CancellationToken cancellationToken = default)
{
var hashes = GetHashInputs(context);
var hashes = GetPayloads(context);
return ValueTask.FromResult(hashes);
}
protected virtual IEnumerable<object> GetHashInputs(TriggerIndexingContext context) => Enumerable.Empty<object>();
/// <summary>
/// Override this method to return a list of trigger payloads.
/// </summary>
protected virtual IEnumerable<object> GetPayloads(TriggerIndexingContext context) => new[]{ GetPayload(context) };
/// <summary>
/// Override this method to return a trigger payloads.
/// </summary>
protected virtual object GetPayload(TriggerIndexingContext context) => new();
}

View file

@ -14,4 +14,6 @@ public class TriggerIndexingContext
ExpressionExecutionContext = expressionExecutionContext;
Trigger = trigger;
}
public T? Get<T>(Input<T> input) => ExpressionExecutionContext.Get<T>(input);
}

View file

@ -76,5 +76,5 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddStimulusHandler<T>(this IServiceCollection services) where T : class, IStimulusHandler => services.AddSingleton<IStimulusHandler, T>();
public static IServiceCollection AddInstructionInterpreter<T>(this IServiceCollection services) where T : class, IWorkflowInstructionInterpreter => services.AddSingleton<IWorkflowInstructionInterpreter, T>();
public static IServiceCollection ConfigureWorkflowRuntime(this IServiceCollection services, Action<WorkflowRuntimeOptions> configure) => services.Configure(configure);
public static IServiceCollection IndexWorkflowTriggers(this IServiceCollection services) => services.AddHostedService<IndexWorkflowTriggersHostedService>();
public static IServiceCollection IndexWorkflowTriggers(this IServiceCollection services) => services.AddHostedService<IndexWorkflowTriggers>();
}

View file

@ -0,0 +1,19 @@
using Elsa.Attributes;
using Elsa.Models;
using Elsa.Modules.AzureServiceBus.Models;
namespace Elsa.Modules.AzureServiceBus.Activities;
[Activity("Azure.ServiceBus.MessageReceived", "Executes when a message is received from the configured queue or topic and subscription", "Azure Service Bus")]
public class MessageReceived : TriggerActivity
{
public Input<string> QueueOrTopic { get; set; } = default!;
public Input<string>? Subscription { get; set; } = default!;
protected override object GetPayload(TriggerIndexingContext context)
{
var queueOrTopic = context.Get(QueueOrTopic)!;
var subscription = context.Get(Subscription!)!;
return new MessageReceivedTriggerPayload(queueOrTopic, subscription);
}
}

View file

@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace Elsa.Modules.AzureServiceBus.Activities;
[Activity("Azure.ServiceBus.Send", "Send a message to a queue or topic", "Azure Service Bus")]
public class Send : Activity
public class SendMessage : Activity
{
public Input<object> MessageBody { get; set; } = default!;
public Input<string> QueueOrTopic { get; set; } = default!;
@ -55,7 +55,7 @@ public class Send : Activity
private static bool ValidatePreconditions(ActivityExecutionContext context, string? queueOrTopic, object? messageBody)
{
var logger = context.GetRequiredService<ILogger<Send>>();
var logger = context.GetRequiredService<ILogger<SendMessage>>();
if (string.IsNullOrWhiteSpace(queueOrTopic))
{

View file

@ -0,0 +1,37 @@
using Elsa.Modules.AzureServiceBus.Models;
using Elsa.Modules.AzureServiceBus.Services;
namespace Elsa.Modules.AzureServiceBus.Contracts;
/// <summary>
/// Manages message workers.
/// </summary>
public interface IWorkerManager
{
IReadOnlyCollection<Worker> Workers { get; }
/// <summary>
/// Returns a value indicating whether a worker exists for the specified trigger.
/// </summary>
/// <param name="queueOrTopic"></param>
/// <param name="subscription"></param>
/// <param name="payload"></param>
/// <returns></returns>
Worker? FindWorkerFor(string queueOrTopic, string? subscription);
/// <summary>
/// Creates a worker for the specified queue or topic and subscription.
/// </summary>
Task StartWorkerAsync(string queueOrTopic, string? subscription, CancellationToken cancellationToken = default);
/// <summary>
/// Decrements the ref count for the worker for the specified queue or topic and subscription.
/// If the worker's ref count reaches 0, it is removed.
/// </summary>
Task StopWorkerAsync(string queueOrTopic, string? subscription, CancellationToken cancellationToken = default);
/// <summary>
/// Removes the specified worker.
/// </summary>
Task RemoveWorkerAsync(Worker worker);
}

View file

@ -18,6 +18,8 @@
<ProjectReference Include="..\..\core\Elsa.Core\Elsa.Core.csproj" />
<ProjectReference Include="..\..\core\Elsa.Formatting\Elsa.Formatting.csproj" />
<ProjectReference Include="..\..\core\Elsa.Mediator\Elsa.Mediator.csproj" />
<ProjectReference Include="..\..\persistence\Elsa.Persistence.Abstractions\Elsa.Persistence.Abstractions.csproj" />
<ProjectReference Include="..\..\runtime\Elsa.Runtime\Elsa.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,8 @@
using Azure.Messaging.ServiceBus;
using Azure.Messaging.ServiceBus.Administration;
using Elsa.Mediator.Extensions;
using Elsa.Modules.AzureServiceBus.Contracts;
using Elsa.Modules.AzureServiceBus.Handlers;
using Elsa.Modules.AzureServiceBus.HostedServices;
using Elsa.Modules.AzureServiceBus.Options;
using Elsa.Modules.AzureServiceBus.Providers;
@ -25,14 +27,22 @@ public static class ServiceCollectionExtensions
.AddSingleton(CreateServiceBusManagementClient)
.AddSingleton(CreateServiceBusClient)
.AddSingleton<ConfigurationQueueTopicAndSubscriptionProvider>()
.AddTransient<IServiceBusInitializer, ServiceBusInitializer>()
.AddSingleton<IWorkerManager, WorkerManager>()
.AddTransient<IServiceBusInitializer, ServiceBusInitializer>();
// Definition providers.
services
.AddSingleton<IQueueProvider>(sp => sp.GetRequiredService<ConfigurationQueueTopicAndSubscriptionProvider>())
.AddSingleton<ITopicProvider>(sp => sp.GetRequiredService<ConfigurationQueueTopicAndSubscriptionProvider>())
.AddSingleton<ISubscriptionProvider>(sp => sp.GetRequiredService<ConfigurationQueueTopicAndSubscriptionProvider>());
// Hosted services.
if (autoCreateQueuesTopicsAndSubscriptions)
services.AddHostedService<CreateQueuesTopicsAndSubscriptions>();
// Handlers.
services.AddHandlersFrom<UpdateWorkers>();
return services;
}

View file

@ -0,0 +1,68 @@
using Elsa.Contracts;
using Elsa.Mediator.Contracts;
using Elsa.Modules.AzureServiceBus.Activities;
using Elsa.Modules.AzureServiceBus.Contracts;
using Elsa.Modules.AzureServiceBus.Models;
using Elsa.Persistence.Extensions;
using Elsa.Runtime.Models;
using Elsa.Runtime.Notifications;
namespace Elsa.Modules.AzureServiceBus.Handlers;
/// <summary>
/// Creates workers for each trigger & bookmark in response to updated workflow trigger indexes and bookmarks.
/// </summary>
public class UpdateWorkers : INotificationHandler<WorkflowTriggersIndexed>, INotificationHandler<WorkflowBookmarksDeleted>, INotificationHandler<WorkflowBookmarksSaved>
{
private readonly IPayloadSerializer _serializer;
private readonly IWorkerManager _workerManager;
public UpdateWorkers(IPayloadSerializer serializer, IWorkerManager workerManager)
{
_serializer = serializer;
_workerManager = workerManager;
}
/// <summary>
/// Adds, updates and removes workers based on added and removed triggers.
/// </summary>
public async Task HandleAsync(WorkflowTriggersIndexed notification, CancellationToken cancellationToken)
{
var added = notification.IndexedWorkflow.AddedTriggers.Filter<MessageReceived>().Select(x => DeserializePayload(x.Payload!));
var removed = notification.IndexedWorkflow.RemovedTriggers.Filter<MessageReceived>().Select(x => DeserializePayload(x.Payload!));
await StopWorkersAsync(removed, cancellationToken);
await StartWorkersAsync(added, cancellationToken);
}
/// <summary>
/// Removes workers based on removed bookmarks.
/// </summary>
public async Task HandleAsync(WorkflowBookmarksDeleted notification, CancellationToken cancellationToken)
{
var payloads = notification.Bookmarks.Filter<MessageReceived>().Select(x => DeserializePayload(x.Payload!));
await StopWorkersAsync(payloads, cancellationToken);
}
/// <summary>
/// Starts workers based on added bookmarks.
/// </summary>
public async Task HandleAsync(WorkflowBookmarksSaved notification, CancellationToken cancellationToken)
{
var payloads = notification.Bookmarks.Filter<MessageReceived>().Select(x => DeserializePayload(x.Payload!));
await StartWorkersAsync(payloads, cancellationToken);
}
private MessageReceivedTriggerPayload DeserializePayload(string payload) => _serializer.Deserialize<MessageReceivedTriggerPayload>(payload);
private ICollection<MessageReceivedTriggerPayload> DeserializePayloads(IEnumerable<string> payloads) => payloads.Select(_serializer.Deserialize<MessageReceivedTriggerPayload>).ToList();
private async Task StartWorkersAsync(IEnumerable<MessageReceivedTriggerPayload> payloads, CancellationToken cancellationToken)
{
foreach (var payload in payloads) await _workerManager.StartWorkerAsync(payload.QueueOrTopic, payload.Subscription, cancellationToken);
}
private async Task StopWorkersAsync(IEnumerable<MessageReceivedTriggerPayload> payloads, CancellationToken cancellationToken)
{
foreach (var payload in payloads) await _workerManager.StopWorkerAsync(payload.QueueOrTopic, payload.Subscription, cancellationToken);
}
}

View file

@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Elsa.Modules.AzureServiceBus.Models;
public record MessageReceivedTriggerPayload
{
private readonly string _queueOrTopic = default!;
private readonly string? _subscription;
public MessageReceivedTriggerPayload(string queueOrTopic, string? subscription)
{
QueueOrTopic = queueOrTopic;
Subscription = subscription;
}
[JsonConstructor]
public MessageReceivedTriggerPayload()
{
}
public string QueueOrTopic
{
get => _queueOrTopic;
init => _queueOrTopic = value.ToLowerInvariant();
}
public string? Subscription
{
get => _subscription;
init => _subscription = value?.ToLowerInvariant();
}
public void Deconstruct(out string queueOrTopic, out string? subscription)
{
queueOrTopic = QueueOrTopic;
subscription = Subscription;
}
}

View file

@ -0,0 +1,80 @@
using Azure.Messaging.ServiceBus;
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Modules.AzureServiceBus.Activities;
using Elsa.Modules.AzureServiceBus.Models;
using Elsa.Runtime.Contracts;
using Elsa.Runtime.Models;
using Microsoft.Extensions.Logging;
namespace Elsa.Modules.AzureServiceBus.Services;
/// <summary>
/// Processes messages received via a queue specified through the <see cref="MessageReceivedTriggerPayload"/>.
/// When a message is received, the appropriate workflows are executed.
/// </summary>
public class Worker : IAsyncDisposable
{
private static readonly string BookmarkName = TypeNameHelper.GenerateTypeName<MessageReceived>();
private readonly ServiceBusProcessor _processor;
private readonly IHasher _hasher;
private readonly IWorkflowServer _workflowServer;
private readonly ILogger _logger;
private int _refCount = 1;
public Worker(string queueOrTopic, string? subscription, ServiceBusClient client, IHasher hasher, IWorkflowServer workflowServer, ILogger<Worker> logger)
{
QueueOrTopic = queueOrTopic;
Subscription = subscription;
_hasher = hasher;
_workflowServer = workflowServer;
_logger = logger;
var options = new ServiceBusProcessorOptions();
var processor = subscription == null ? client.CreateProcessor(queueOrTopic, options) : client.CreateProcessor(queueOrTopic, subscription, options);
processor.ProcessMessageAsync += OnMessageReceivedAsync;
processor.ProcessErrorAsync += OnErrorAsync;
_processor = processor;
}
public string QueueOrTopic { get; }
public string? Subscription { get; }
/// <summary>
/// Maintains the number of workflows are relying on this worker. When it goes to zero, the worker will be removed.
/// </summary>
public int RefCount
{
get => _refCount;
private set
{
if (value < 0)
throw new ArgumentException("RefCount cannot be less than zero");
_refCount = value;
}
}
public async Task StartAsync(CancellationToken cancellationToken = default) => await _processor.StartProcessingAsync(cancellationToken);
public void IncrementRefCount() => RefCount++;
public void DecrementRefCount() => RefCount--;
public async ValueTask DisposeAsync() => await _processor.DisposeAsync();
private async Task OnMessageReceivedAsync(ProcessMessageEventArgs args) => await InvokeWorkflowsAsync(args.Message, args.CancellationToken);
private Task OnErrorAsync(ProcessErrorEventArgs args)
{
_logger.LogError(args.Exception, "An error occurred while processing {EnrityPath}", args.EntityPath);
return Task.CompletedTask;
}
private async Task InvokeWorkflowsAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken)
{
var payload = new MessageReceivedTriggerPayload(QueueOrTopic, Subscription);
var hash = _hasher.Hash(payload);
var stimulus = Stimulus.Standard(BookmarkName, hash);
var executionResults = (await _workflowServer.ExecuteStimulusAsync(stimulus, cancellationToken)).ToList();
_logger.LogInformation("Triggered {WorkflowCount} workflows", executionResults.Count);
}
}

View file

@ -0,0 +1,57 @@
using Elsa.Modules.AzureServiceBus.Contracts;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Modules.AzureServiceBus.Services;
public class WorkerManager : IWorkerManager, IAsyncDisposable
{
private readonly IServiceProvider _serviceProvider;
private readonly ICollection<Worker> _workers = new List<Worker>();
public WorkerManager(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IReadOnlyCollection<Worker> Workers => _workers.ToList();
public Worker? FindWorkerFor(string queueOrTopic, string? subscription) => _workers.FirstOrDefault(x => x.QueueOrTopic == queueOrTopic && x.Subscription == subscription);
public async Task StartWorkerAsync(string queueOrTopic, string? subscription, CancellationToken cancellationToken = default)
{
var worker = FindWorkerFor(queueOrTopic, subscription);
if (worker != null)
{
worker.IncrementRefCount();
return;
}
worker = ActivatorUtilities.CreateInstance<Worker>(_serviceProvider, queueOrTopic, subscription!);
_workers.Add(worker);
await worker.StartAsync(cancellationToken);
}
public async Task StopWorkerAsync(string queueOrTopic, string? subscription, CancellationToken cancellationToken = default)
{
var worker = FindWorkerFor(queueOrTopic, subscription);
if (worker == null)
return;
worker.DecrementRefCount();
if (worker.RefCount == 0) await RemoveWorkerAsync(worker);
}
public async Task RemoveWorkerAsync(Worker worker)
{
_workers.Remove(worker);
await worker.DisposeAsync();
}
public async ValueTask DisposeAsync()
{
foreach (var worker in Workers) await worker.DisposeAsync();
}
}

View file

@ -4,6 +4,7 @@ using System.Net.Http;
using Elsa.Attributes;
using Elsa.Management.Models;
using Elsa.Models;
using Elsa.Modules.Http.Models;
namespace Elsa.Modules.Http;
@ -19,7 +20,7 @@ public class HttpTrigger : TriggerActivity
[Output] public Output<HttpRequestModel>? Result { get; set; }
protected override IEnumerable<object> GetHashInputs(TriggerIndexingContext context) => GetHashInputs(context.ExpressionExecutionContext);
protected override IEnumerable<object> GetPayloads(TriggerIndexingContext context) => GetHashInputs(context.ExpressionExecutionContext);
protected override void Execute(ActivityExecutionContext context) => context.SetBookmarks(GetHashInputs(context.ExpressionExecutionContext));
private IEnumerable<object> GetHashInputs(ExpressionExecutionContext context)
@ -27,6 +28,6 @@ public class HttpTrigger : TriggerActivity
// Generate a bookmark hash for path and selected methods.
var path = context.Get(Path);
var methods = context.Get(SupportedMethods);
return methods!.Select(x => (path!.ToLowerInvariant(), x.ToLowerInvariant())).Cast<object>().ToArray();
return methods!.Select(x => new HttpTriggerPayload(path!, x.ToLowerInvariant())).Cast<object>().ToArray();
}
}

View file

@ -3,6 +3,7 @@ using System.Text.Json;
using System.Threading.Tasks;
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Modules.Http.Models;
using Elsa.Runtime.Contracts;
using Elsa.Runtime.Models;
using Microsoft.AspNetCore.Http;
@ -25,11 +26,11 @@ public class HttpTriggerMiddleware
var path = GetPath(httpContext);
var method = httpContext.Request.Method!.ToLowerInvariant();
var abortToken = httpContext.RequestAborted;
var hash = _hasher.Hash((path.ToLowerInvariant(), method.ToLowerInvariant()));
var hash = _hasher.Hash(new HttpTriggerPayload(path, method));
var activityTypeName = TypeNameHelper.GenerateTypeName<HttpTrigger>();
var stimulus = Stimuli.Standard(activityTypeName, hash);
var stimulus = Stimulus.Standard(activityTypeName, hash);
var executionResults = (await workflowServer.ExecuteStimulusAsync(stimulus, abortToken)).ToList();
if (!executionResults.Any())
{
await _next(httpContext);
@ -42,12 +43,12 @@ public class HttpTriggerMiddleware
{
response.ContentType = "application/json";
response.StatusCode = StatusCodes.Status200OK;
var model = new
{
workflowInstanceIds = executionResults.Select(x => x.ExecuteWorkflowResult.WorkflowState.Id).ToArray()
};
var json = JsonSerializer.Serialize(model);
await response.WriteAsync(json, abortToken);
}

View file

@ -0,0 +1,25 @@
namespace Elsa.Modules.Http.Models;
public record HttpTriggerPayload
{
private readonly string _path = default!;
private readonly string _method = default!;
public HttpTriggerPayload(string path, string method)
{
Path = path;
Method = method;
}
public string Path
{
get => _path;
init => _path = value.ToLowerInvariant();
}
public string Method
{
get => _method;
init => _method = value.ToLowerInvariant();
}
}

View file

@ -11,4 +11,5 @@ namespace Elsa.Modules.Scheduling.Contracts;
public interface IWorkflowTriggerScheduler
{
Task ScheduleTriggersAsync(IEnumerable<WorkflowTrigger> triggers, CancellationToken cancellationToken = default);
Task UnscheduleTriggersAsync(IEnumerable<WorkflowTrigger> triggers, CancellationToken cancellationToken = default);
}

View file

@ -18,7 +18,7 @@ public static class ServiceCollectionExtensions
.AddSingleton<IWorkflowBookmarkScheduler, WorkflowBookmarkScheduler>()
.AddJobHandler<RunWorkflowJobHandler>()
.AddJobHandler<ResumeWorkflowJobHandler>()
.AddNotificationHandlersFrom<ScheduleWorkflowsHandler>()
.AddNotificationHandlersFrom<ScheduleWorkflows>()
.AddHostedService<ScheduleWorkflowsHostedService>();
return services;

View file

@ -2,23 +2,28 @@ using System.Threading;
using System.Threading.Tasks;
using Elsa.Mediator.Contracts;
using Elsa.Modules.Scheduling.Contracts;
using Elsa.Runtime.Models;
using Elsa.Runtime.Notifications;
namespace Elsa.Modules.Scheduling.Handlers;
// Updates scheduled jobs based on the updated workflow triggers.
public class ScheduleWorkflowsHandler : INotificationHandler<TriggerIndexingFinished>, INotificationHandler<WorkflowExecuted>
public class ScheduleWorkflows : INotificationHandler<WorkflowTriggersIndexed>, INotificationHandler<WorkflowExecuted>
{
private readonly IWorkflowTriggerScheduler _workflowTriggerScheduler;
private readonly IWorkflowBookmarkScheduler _workflowBookmarkScheduler;
public ScheduleWorkflowsHandler(IWorkflowTriggerScheduler workflowTriggerScheduler, IWorkflowBookmarkScheduler workflowBookmarkScheduler)
public ScheduleWorkflows(IWorkflowTriggerScheduler workflowTriggerScheduler, IWorkflowBookmarkScheduler workflowBookmarkScheduler)
{
_workflowTriggerScheduler = workflowTriggerScheduler;
_workflowBookmarkScheduler = workflowBookmarkScheduler;
}
public async Task HandleAsync(TriggerIndexingFinished notification, CancellationToken cancellationToken) => await _workflowTriggerScheduler.ScheduleTriggersAsync(notification.Triggers, cancellationToken);
public async Task HandleAsync(WorkflowTriggersIndexed notification, CancellationToken cancellationToken)
{
await _workflowTriggerScheduler.UnscheduleTriggersAsync(notification.IndexedWorkflow.RemovedTriggers, cancellationToken);
await _workflowTriggerScheduler.ScheduleTriggersAsync(notification.IndexedWorkflow.AddedTriggers, cancellationToken);
}
public async Task HandleAsync(WorkflowExecuted notification, CancellationToken cancellationToken)
{

View file

@ -39,7 +39,7 @@ public class ScheduleWorkflowsHostedService : BackgroundService
private async Task ScheduleTriggersAsync(CancellationToken cancellationToken)
{
var timerTriggers = (await _requestSender.RequestAsync(FindWorkflowTriggers.ForTrigger<Timer>(), cancellationToken)).ToImmutableList();
var timerTriggers = (await _requestSender.RequestAsync(FindWorkflowTriggersByName.ForTrigger<Timer>(), cancellationToken)).ToImmutableList();
await _workflowTriggerScheduler.ScheduleTriggersAsync(timerTriggers, cancellationToken);
}

View file

@ -17,20 +17,33 @@ namespace Elsa.Modules.Scheduling.Services;
public class WorkflowTriggerScheduler : IWorkflowTriggerScheduler
{
private const string RootGroupKey = "WorkflowDefinition";
private readonly IJobScheduler _jobScheduler;
public WorkflowTriggerScheduler(IJobScheduler jobScheduler)
{
_jobScheduler = jobScheduler;
}
public async Task ScheduleTriggersAsync(IEnumerable<WorkflowTrigger> triggers, CancellationToken cancellationToken = default)
{
var triggerList = triggers.ToList();
// Select all Timer triggers.
var timerTriggers = triggerList.Filter<Timer>().ToList();
var timerTriggers = triggers.Filter<Timer>().ToList();
// Schedule each trigger.
foreach (var trigger in timerTriggers)
{
// Schedule trigger.
var (dateTime, timeSpan) = JsonSerializer.Deserialize<TimerPayload>(trigger.Payload!)!;
var groupKeys = new[] { RootGroupKey, trigger.WorkflowDefinitionId };
await _jobScheduler.ScheduleAsync(new RunWorkflowJob(trigger.WorkflowDefinitionId), new RecurringSchedule(dateTime, timeSpan), groupKeys, cancellationToken);
}
}
public async Task UnscheduleTriggersAsync(IEnumerable<WorkflowTrigger> triggers, CancellationToken cancellationToken = default)
{
// Select all Timer triggers.
var timerTriggers = triggers.Filter<Timer>().ToList();
// Unschedule all triggers for the distinct set of affected workflows.
var workflowDefinitionIds = timerTriggers.Select(x => x.WorkflowDefinitionId).Distinct().ToList();
@ -40,13 +53,5 @@ public class WorkflowTriggerScheduler : IWorkflowTriggerScheduler
var groupKeys = new[] { RootGroupKey, workflowDefinitionId };
await _jobScheduler.ClearAsync(groupKeys, cancellationToken);
}
foreach (var trigger in timerTriggers)
{
// Schedule trigger.
var (dateTime, timeSpan) = JsonSerializer.Deserialize<TimerPayload>(trigger.Payload!)!;
var groupKeys = new[] { RootGroupKey, trigger.WorkflowDefinitionId };
await _jobScheduler.ScheduleAsync(new RunWorkflowJob(trigger.WorkflowDefinitionId), new RecurringSchedule(dateTime, timeSpan), groupKeys, cancellationToken);
}
}
}

View file

@ -4,4 +4,4 @@ using Elsa.Persistence.Entities;
namespace Elsa.Persistence.Commands;
public record ReplaceWorkflowTriggers(Workflow Workflow, ICollection<WorkflowTrigger> WorkflowTriggers) : ICommand;
public record ReplaceWorkflowTriggers(Workflow Workflow, ICollection<WorkflowTrigger> Removed, ICollection<WorkflowTrigger> Added) : ICommand;

View file

@ -15,4 +15,19 @@ public class WorkflowBookmark : Entity
public string? CallbackMethodName { get; set; }
public Bookmark ToBookmark() => new(Id, Name, Hash, Payload, ActivityId, ActivityInstanceId, CallbackMethodName);
public static WorkflowBookmark FromBookmark(Bookmark bookmark, WorkflowInstance workflowInstance) =>
new()
{
Id = bookmark.Id,
WorkflowDefinitionId = workflowInstance.DefinitionId,
WorkflowInstanceId = workflowInstance.Id,
CorrelationId = workflowInstance.CorrelationId,
Hash = bookmark.Hash,
Payload = bookmark.Payload,
Name = bookmark.Name,
ActivityId = bookmark.ActivityId,
ActivityInstanceId = bookmark.ActivityInstanceId,
CallbackMethodName = bookmark.CallbackMethodName
};
}

View file

@ -1,5 +1,8 @@
namespace Elsa.Persistence.Entities;
/// <summary>
/// Represents a trigger associated with a workflow definition.
/// </summary>
public class WorkflowTrigger : Entity
{
public string WorkflowDefinitionId { get; set; } = default!;

View file

@ -0,0 +1,14 @@
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Persistence.Entities;
namespace Elsa.Persistence.Extensions;
public static class WorkflowBookmarkExtensions
{
public static IEnumerable<WorkflowBookmark> Filter<T>(this IEnumerable<WorkflowBookmark> bookmarks) where T : ITrigger
{
var bookmarkName = TypeNameHelper.GenerateTypeName<T>();
return bookmarks.Where(x => x.Name == bookmarkName);
}
}

View file

@ -1,20 +0,0 @@
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
namespace Elsa.Persistence.Requests;
public record FindWorkflowTriggers : IRequest<IEnumerable<WorkflowTrigger>>
{
public FindWorkflowTriggers(string name, string? hash = default)
{
Name = name;
Hash = hash;
}
public string Name { get; }
public string? Hash { get; }
public static FindWorkflowTriggers ForTrigger<T>(string? hash = default) where T : ITrigger => new(TypeNameHelper.GenerateTypeName<T>(), hash);
}

View file

@ -0,0 +1,11 @@
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
namespace Elsa.Persistence.Requests;
public record FindWorkflowTriggersByName(string Name, string? Hash = default) : IRequest<ICollection<WorkflowTrigger>>
{
public static FindWorkflowTriggersByName ForTrigger<T>(string? hash = default) where T : ITrigger => new(TypeNameHelper.GenerateTypeName<T>(), hash);
}

View file

@ -0,0 +1,6 @@
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
namespace Elsa.Persistence.Requests;
public record FindWorkflowTriggersByWorkflowDefinition(string WorkflowDefinitionId) : IRequest<ICollection<WorkflowTrigger>>;

View file

@ -11,6 +11,7 @@ public interface IStore<TEntity> where TEntity : Entity
Task<TEntity?> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> FindManyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
Task<int> DeleteManyAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
IEnumerable<TEntity> Load(ElsaDbContext dbContext, IEnumerable<TEntity> entities);
Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, CancellationToken cancellationToken = default);

View file

@ -12,9 +12,8 @@ public class ReplaceWorkflowTriggersHandler : ICommandHandler<ReplaceWorkflowTri
public async Task<Unit> HandleAsync(ReplaceWorkflowTriggers command, CancellationToken cancellationToken)
{
var definitionId = command.Workflow.Identity.DefinitionId;
await _store.DeleteWhereAsync(x => x.WorkflowDefinitionId == definitionId, cancellationToken);
await _store.SaveManyAsync(command.WorkflowTriggers, cancellationToken);
await _store.DeleteManyAsync(command.Removed, cancellationToken);
await _store.SaveManyAsync(command.Added, cancellationToken);
return Unit.Instance;
}

View file

@ -5,14 +5,14 @@ using Elsa.Persistence.Requests;
namespace Elsa.Persistence.EntityFrameworkCore.Handlers.Requests;
public class FindWorkflowTriggersHandler : IRequestHandler<FindWorkflowTriggers, IEnumerable<WorkflowTrigger>>
public class FindWorkflowTriggersByNameHandler : IRequestHandler<FindWorkflowTriggersByName, ICollection<WorkflowTrigger>>
{
private readonly IStore<WorkflowTrigger> _store;
public FindWorkflowTriggersHandler(IStore<WorkflowTrigger> store) => _store = store;
public FindWorkflowTriggersByNameHandler(IStore<WorkflowTrigger> store) => _store = store;
public async Task<IEnumerable<WorkflowTrigger>> HandleAsync(FindWorkflowTriggers request, CancellationToken cancellationToken)
public async Task<ICollection<WorkflowTrigger>> HandleAsync(FindWorkflowTriggersByName request, CancellationToken cancellationToken)
{
return await _store.QueryAsync(query =>
var triggers = await _store.QueryAsync(query =>
{
query = query.Where(x => x.Name == request.Name);
@ -21,5 +21,7 @@ public class FindWorkflowTriggersHandler : IRequestHandler<FindWorkflowTriggers,
return query;
}, cancellationToken);
return triggers.ToList();
}
}

View file

@ -0,0 +1,18 @@
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
using Elsa.Persistence.EntityFrameworkCore.Contracts;
using Elsa.Persistence.Requests;
namespace Elsa.Persistence.EntityFrameworkCore.Handlers.Requests;
public class FindWorkflowTriggersByWorkflowDefinitionHandler : IRequestHandler<FindWorkflowTriggersByWorkflowDefinition, ICollection<WorkflowTrigger>>
{
private readonly IStore<WorkflowTrigger> _store;
public FindWorkflowTriggersByWorkflowDefinitionHandler(IStore<WorkflowTrigger> store) => _store = store;
public async Task<ICollection<WorkflowTrigger>> HandleAsync(FindWorkflowTriggersByWorkflowDefinition request, CancellationToken cancellationToken)
{
var triggers = await _store.QueryAsync(query => query.Where(x => x.WorkflowDefinitionId == request.WorkflowDefinitionId), cancellationToken);
return triggers.ToList();
}
}

View file

@ -60,13 +60,21 @@ public class EFCoreStore<TEntity> : IStore<TEntity> where TEntity : Entity
return await dbContext.SaveChangesAsync(cancellationToken) == 1;
}
public async Task<int> DeleteManyAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var list = entities.ToList();
await dbContext.BulkDeleteAsync(list, cancellationToken: cancellationToken);
return list.Count;
}
public async Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var set = dbContext.Set<TEntity>();
return await set.Where(predicate).BatchDeleteWithWorkAroundAsync(dbContext, cancellationToken);
}
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
@ -74,12 +82,12 @@ public class EFCoreStore<TEntity> : IStore<TEntity> where TEntity : Entity
var queryable = query(set.AsQueryable());
queryable = query(queryable);
return Load(dbContext, queryable).ToList();
}
public IEnumerable<TEntity> Load(ElsaDbContext dbContext, IEnumerable<TEntity> entities) => OnLoading(dbContext, entities.ToList());
private void OnSaving(ElsaDbContext dbContext, TEntity entity)
{
var handler = _serviceProvider.GetService<IEntitySerializer<TEntity>>();

View file

@ -1,4 +1,3 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Elsa.Mediator.Contracts;
@ -19,9 +18,8 @@ public class ReplaceWorkflowTriggersHandler : ICommandHandler<ReplaceWorkflowTri
public Task<Unit> HandleAsync(ReplaceWorkflowTriggers command, CancellationToken cancellationToken)
{
var ids = command.WorkflowTriggers.Select(x => x.Id).ToList();
_store.DeleteWhere(x => ids.Contains(x.Id));
_store.SaveMany(command.WorkflowTriggers);
_store.DeleteMany(command.Removed);
_store.SaveMany(command.Added);
return Task.FromResult(Unit.Instance);
}

View file

@ -9,12 +9,12 @@ using Elsa.Persistence.Requests;
namespace Elsa.Persistence.InMemory.Handlers.Requests;
public class FindWorkflowTriggersHandler : IRequestHandler<FindWorkflowTriggers, IEnumerable<WorkflowTrigger>>
public class FindWorkflowTriggersByNameHandler : IRequestHandler<FindWorkflowTriggersByName, ICollection<WorkflowTrigger>>
{
private readonly InMemoryStore<WorkflowTrigger> _store;
public FindWorkflowTriggersHandler(InMemoryStore<WorkflowTrigger> store) => _store = store;
public FindWorkflowTriggersByNameHandler(InMemoryStore<WorkflowTrigger> store) => _store = store;
public Task<IEnumerable<WorkflowTrigger>> HandleAsync(FindWorkflowTriggers request, CancellationToken cancellationToken)
public Task<ICollection<WorkflowTrigger>> HandleAsync(FindWorkflowTriggersByName request, CancellationToken cancellationToken)
{
var triggers = _store.Query(query =>
{
@ -26,6 +26,6 @@ public class FindWorkflowTriggersHandler : IRequestHandler<FindWorkflowTriggers,
return query;
});
return Task.FromResult(triggers);
return Task.FromResult<ICollection<WorkflowTrigger>>(triggers.ToList());
}
}

View file

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
using Elsa.Persistence.InMemory.Services;
using Elsa.Persistence.Requests;
namespace Elsa.Persistence.InMemory.Handlers.Requests;
public class FindWorkflowTriggersByWorkflowDefinitionHandler : IRequestHandler<FindWorkflowTriggersByWorkflowDefinition, ICollection<WorkflowTrigger>>
{
private readonly InMemoryStore<WorkflowTrigger> _store;
public FindWorkflowTriggersByWorkflowDefinitionHandler(InMemoryStore<WorkflowTrigger> store) => _store = store;
public Task<ICollection<WorkflowTrigger>> HandleAsync(FindWorkflowTriggersByWorkflowDefinition request, CancellationToken cancellationToken)
{
var triggers = _store.Query(query => query.Where(x => x.WorkflowDefinitionId == request.WorkflowDefinitionId));
return Task.FromResult<ICollection<WorkflowTrigger>>(triggers.ToList());
}
}

View file

@ -12,7 +12,7 @@ public class InMemoryStore<TEntity> where TEntity : Entity
public void SaveMany(IEnumerable<TEntity> entities)
{
foreach (var entity in entities)
foreach (var entity in entities)
Save(entity);
}
@ -48,6 +48,20 @@ public class InMemoryStore<TEntity> where TEntity : Entity
return count;
}
public int DeleteMany(IEnumerable<TEntity> entities)
{
var count = 0;
var list = entities.ToList();
foreach (var entity in list)
{
count++;
Entities.Remove(entity.Id);
}
return count;
}
public IEnumerable<TEntity> Query(Func<IQueryable<TEntity>, IQueryable<TEntity>> query)
{
var queryable = Entities.Values.AsQueryable();

View file

@ -0,0 +1,12 @@
using Elsa.Persistence.Entities;
namespace Elsa.Runtime.Contracts;
/// <summary>
/// Provides methods to add and remove bookmarks.
/// </summary>
public interface IBookmarkManager
{
Task DeleteBookmarksAsync(IEnumerable<WorkflowBookmark> bookmarks, CancellationToken cancellationToken = default);
Task SaveBookmarksAsync(IEnumerable<WorkflowBookmark> bookmarks, CancellationToken cancellationToken = default);
}

View file

@ -1,5 +1,7 @@
using Elsa.Models;
using Elsa.Persistence.Entities;
using Elsa.Runtime.Models;
using Elsa.Runtime.Notifications;
namespace Elsa.Runtime.Contracts;
@ -8,10 +10,10 @@ public interface ITriggerIndexer
/// <summary>
/// Indexes triggers from all workflows.
/// </summary>
Task IndexTriggersAsync(CancellationToken cancellationToken = default);
Task<ICollection<IndexedWorkflow>> IndexTriggersAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Indexes triggers of the specified workflow.
/// </summary>
Task<IEnumerable<WorkflowTrigger>> IndexTriggersAsync(Workflow workflow, CancellationToken cancellationToken = default);
Task<IndexedWorkflow> IndexTriggersAsync(Workflow workflow, CancellationToken cancellationToken = default);
}

View file

@ -16,7 +16,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="System.Linq.Async" Version="5.1.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>
</Project>

View file

@ -24,6 +24,7 @@ public static class ServiceCollectionExtensions
.AddSingleton<IStimulusInterpreter, StimulusInterpreter>()
.AddSingleton<IWorkflowInstructionExecutor, WorkflowInstructionExecutor>()
.AddSingleton<ITriggerIndexer, TriggerIndexer>()
.AddSingleton<IBookmarkManager, BookmarkManager>()
// Stimulus handlers.
.AddStimulusHandler<TriggerWorkflowsStimulusHandler>()
@ -44,8 +45,8 @@ public static class ServiceCollectionExtensions
.AddNotificationHandlersFrom(typeof(ServiceCollectionExtensions))
// Hosted Services.
.AddHostedService<RegisterDescriptorsHostedService>()
.AddHostedService<RegisterExpressionSyntaxDescriptorsHostedService>();
.AddHostedService<RegisterDescriptors>()
.AddHostedService<RegisterExpressionSyntaxDescriptors>();
;
}
@ -60,5 +61,5 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddStimulusHandler<T>(this IServiceCollection services) where T : class, IStimulusHandler => services.AddSingleton<IStimulusHandler, T>();
public static IServiceCollection AddInstructionInterpreter<T>(this IServiceCollection services) where T : class, IWorkflowInstructionInterpreter => services.AddSingleton<IWorkflowInstructionInterpreter, T>();
public static IServiceCollection ConfigureWorkflowRuntime(this IServiceCollection services, Action<WorkflowRuntimeOptions> configure) => services.Configure(configure);
public static IServiceCollection IndexWorkflowTriggers(this IServiceCollection services) => services.AddHostedService<IndexWorkflowTriggersHostedService>();
public static IServiceCollection IndexWorkflowTriggers(this IServiceCollection services) => services.AddHostedService<IndexWorkflowTriggers>();
}

View file

@ -0,0 +1,12 @@
using Elsa.Contracts;
using Elsa.Pipelines.WorkflowExecution;
using Elsa.Runtime.Middleware;
namespace Elsa.Runtime.Extensions;
public static class WorkflowExecutionBuilderExtensions
{
public static IWorkflowExecutionBuilder UsePersistence(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PersistWorkflowInstanceMiddleware>();
public static IWorkflowExecutionBuilder UseWorkflowExecutionEvents(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PublishWorkflowExecutionEventsMiddleware>();
public static IWorkflowExecutionBuilder UseWorkflowExecutionLogPersistence(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PersistWorkflowExecutionLogMiddleware>();
}

View file

@ -7,11 +7,11 @@ namespace Elsa.Runtime.HostedServices;
/// <summary>
/// Asynchronously indexes all workflows in the background.
/// </summary>
public class IndexWorkflowTriggersHostedService : BackgroundService
public class IndexWorkflowTriggers : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
public IndexWorkflowTriggersHostedService(IServiceScopeFactory serviceScopeFactory)
public IndexWorkflowTriggers(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}

View file

@ -3,12 +3,12 @@ using Microsoft.Extensions.Hosting;
namespace Elsa.Runtime.HostedServices;
public class RegisterDescriptorsHostedService : IHostedService
public class RegisterDescriptors : IHostedService
{
private readonly IActivityRegistryPopulator _activityRegistryPopulator;
private readonly ITriggerRegistryPopulator _triggerRegistryPopulator;
public RegisterDescriptorsHostedService(IActivityRegistryPopulator activityRegistryPopulator, ITriggerRegistryPopulator triggerRegistryPopulator)
public RegisterDescriptors(IActivityRegistryPopulator activityRegistryPopulator, ITriggerRegistryPopulator triggerRegistryPopulator)
{
_activityRegistryPopulator = activityRegistryPopulator;
_triggerRegistryPopulator = triggerRegistryPopulator;

View file

@ -3,10 +3,10 @@ using Microsoft.Extensions.Hosting;
namespace Elsa.Runtime.HostedServices;
public class RegisterExpressionSyntaxDescriptorsHostedService : IHostedService
public class RegisterExpressionSyntaxDescriptors : IHostedService
{
private readonly IExpressionSyntaxRegistryPopulator _expressionSyntaxRegistryPopulator;
public RegisterExpressionSyntaxDescriptorsHostedService(IExpressionSyntaxRegistryPopulator expressionSyntaxRegistryPopulator) => _expressionSyntaxRegistryPopulator = expressionSyntaxRegistryPopulator;
public RegisterExpressionSyntaxDescriptors(IExpressionSyntaxRegistryPopulator expressionSyntaxRegistryPopulator) => _expressionSyntaxRegistryPopulator = expressionSyntaxRegistryPopulator;
public async Task StartAsync(CancellationToken cancellationToken) => await _expressionSyntaxRegistryPopulator.PopulateRegistryAsync(cancellationToken);
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View file

@ -36,7 +36,7 @@ public class ResumeWorkflowInstructionInterpreter : WorkflowInstructionInterpret
{
_logger
.LogWarning(
"Workflow bookmark {WorkflowBookmarkId} for workflow definition {WorkflowDefinitionId} points to workflow instance ID {WorkflowInstanceId}, but no such workflow instance was found", workflowBookmark.Id, workflowBookmark.WorkflowDefinitionId, workflowBookmark.WorkflowInstanceId);
"Workflow bookmark {WorkflowBookmarkId} for workflow definition {WorkflowDefinitionId} references workflow instance ID {WorkflowInstanceId}, but no such workflow instance was found", workflowBookmark.Id, workflowBookmark.WorkflowDefinitionId, workflowBookmark.WorkflowInstanceId);
return null;
}
@ -45,7 +45,7 @@ public class ResumeWorkflowInstructionInterpreter : WorkflowInstructionInterpret
if (workflow == null)
{
_logger.LogWarning("Workflow bookmark {WorkflowBookmarkId} points to workflow definition ID {WorkflowDefinitionId}, but no such workflow definition was found", workflowBookmark.Id, workflowBookmark.WorkflowDefinitionId);
_logger.LogWarning("Workflow bookmark {WorkflowBookmarkId} references workflow definition ID {WorkflowDefinitionId}, but no such workflow definition was found", workflowBookmark.Id, workflowBookmark.WorkflowDefinitionId);
return null;
}

View file

@ -6,12 +6,7 @@ using Elsa.Persistence.Entities;
using Elsa.Pipelines.WorkflowExecution;
using Elsa.Pipelines.WorkflowExecution.Components;
namespace Elsa.Persistence.Middleware.WorkflowExecution;
public static class PersistWorkflowExecutionLogMiddlewareExtensions
{
public static IWorkflowExecutionBuilder UseWorkflowExecutionLogPersistence(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PersistWorkflowExecutionLogMiddleware>();
}
namespace Elsa.Runtime.Middleware;
/// <summary>
/// Takes care of persisting a workflow instance after workflow execution.

View file

@ -1,4 +1,5 @@
using Elsa.Contracts;
using Elsa.Helpers;
using Elsa.Mediator.Contracts;
using Elsa.Models;
using Elsa.Persistence.Commands;
@ -6,13 +7,9 @@ using Elsa.Persistence.Entities;
using Elsa.Persistence.Requests;
using Elsa.Pipelines.WorkflowExecution;
using Elsa.Pipelines.WorkflowExecution.Components;
using Elsa.Runtime.Contracts;
namespace Elsa.Persistence.Middleware.WorkflowExecution;
public static class PersistWorkflowInstanceMiddlewareExtensions
{
public static IWorkflowExecutionBuilder UsePersistence(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PersistWorkflowInstanceMiddleware>();
}
namespace Elsa.Runtime.Middleware;
/// <summary>
/// Takes care of persisting a workflow instance after workflow execution.
@ -22,7 +19,7 @@ public class PersistWorkflowInstanceMiddleware : WorkflowExecutionMiddleware
private readonly IRequestSender _requestSender;
private readonly ICommandSender _commandSender;
private readonly IWorkflowStateSerializer _workflowStateSerializer;
private readonly IPayloadSerializer _payloadSerializer;
private readonly IBookmarkManager _bookmarkManager;
private readonly IIdentityGenerator _identityGenerator;
private readonly ISystemClock _clock;
@ -30,15 +27,15 @@ public class PersistWorkflowInstanceMiddleware : WorkflowExecutionMiddleware
WorkflowMiddlewareDelegate next,
IRequestSender requestSender,
ICommandSender commandSender,
IBookmarkManager bookmarkManager,
IWorkflowStateSerializer workflowStateSerializer,
IPayloadSerializer payloadSerializer,
IIdentityGenerator identityGenerator,
ISystemClock clock) : base(next)
{
_requestSender = requestSender;
_commandSender = commandSender;
_bookmarkManager = bookmarkManager;
_workflowStateSerializer = workflowStateSerializer;
_payloadSerializer = payloadSerializer;
_identityGenerator = identityGenerator;
_clock = clock;
}
@ -66,19 +63,14 @@ public class PersistWorkflowInstanceMiddleware : WorkflowExecutionMiddleware
// Get a copy of current bookmarks.
var existingBookmarks = await _requestSender.RequestAsync(new FindWorkflowBookmarks(workflowInstance.Id), cancellationToken);
var bookmarksSnapshot = existingBookmarks.Select(x => x.ToBookmark()).ToList();
var bookmarks = bookmarksSnapshot.ToList();
// Exclude the current bookmark that initiated the creation of the workflow context, if any.
var invokedBookmark = context.Bookmark;
var removedBookmarkIds = new List<string>();
if (invokedBookmark != null)
{
bookmarksSnapshot.RemoveAll(x => x.Id == invokedBookmark.Id);
removedBookmarkIds.Add(invokedBookmark.Id);
}
if (invokedBookmark != null) bookmarks.RemoveAll(x => x.Id == invokedBookmark.Id);
// Apply bookmarks to workflow context.
context.RegisterBookmarks(bookmarksSnapshot);
context.RegisterBookmarks(bookmarks);
// Persist workflow instance.
await _commandSender.ExecuteAsync(new SaveWorkflowInstance(workflowInstance), cancellationToken);
@ -93,25 +85,15 @@ public class PersistWorkflowInstanceMiddleware : WorkflowExecutionMiddleware
// Persist updated workflow instance.
await _commandSender.ExecuteAsync(new SaveWorkflowInstance(workflowInstance), cancellationToken);
// Remove bookmarks that were in the snapshot but no longer present in context.
removedBookmarkIds.AddRange(bookmarksSnapshot.Except(context.Bookmarks).Select(x => x.Id));
await _commandSender.ExecuteAsync(new DeleteWorkflowBookmarks(removedBookmarkIds), cancellationToken);
// Get a diff between bookmarks that were in the snapshot but no longer present in context.
var diff = Diff.For(bookmarksSnapshot, context.Bookmarks.ToList());
// Persist bookmarks, if any.
var workflowBookmarks = context.Bookmarks.Select(x => new WorkflowBookmark
{
Id = x.Id,
WorkflowDefinitionId = definitionId,
WorkflowInstanceId = workflowInstance.Id,
CorrelationId = workflowInstance.CorrelationId,
Hash = x.Hash,
Payload = x.Payload,
Name = x.Name,
ActivityId = x.ActivityId,
ActivityInstanceId = x.ActivityInstanceId,
CallbackMethodName = x.CallbackMethodName
}).ToList();
// Delete removed bookmarks.
var removedBookmarks = diff.Removed.Select(x => WorkflowBookmark.FromBookmark(x, workflowInstance)).ToList();
await _bookmarkManager.DeleteBookmarksAsync(removedBookmarks, cancellationToken);
await _commandSender.ExecuteAsync(new SaveWorkflowBookmarks(workflowBookmarks), cancellationToken);
// Persist created bookmarks.
var createdBookmarks = diff.Added.Select(x => WorkflowBookmark.FromBookmark(x, workflowInstance)).ToList();
await _bookmarkManager.SaveBookmarksAsync(createdBookmarks, cancellationToken);
}
}

View file

@ -7,11 +7,6 @@ using Elsa.Runtime.Notifications;
namespace Elsa.Runtime.Middleware;
public static class PersistWorkflowExecutionLogMiddlewareExtensions
{
public static IWorkflowExecutionBuilder UseWorkflowExecutionEvents(this IWorkflowExecutionBuilder builder) => builder.UseMiddleware<PublishWorkflowExecutionEventsMiddleware>();
}
/// <summary>
/// Processes collected bookmarks.
/// </summary>

View file

@ -0,0 +1,6 @@
using Elsa.Models;
using Elsa.Persistence.Entities;
namespace Elsa.Runtime.Models;
public record IndexedWorkflow(Workflow Workflow, ICollection<WorkflowTrigger> AddedTriggers, ICollection<WorkflowTrigger> RemovedTriggers);

View file

@ -2,7 +2,7 @@ using Elsa.Runtime.Stimuli;
namespace Elsa.Runtime.Models;
public static class Stimuli
public static class Stimulus
{
public static StandardStimulus Standard(string activityTypeName, string? hash = default, IDictionary<string, object?>? data = default) => new(activityTypeName, hash, data);
}

View file

@ -0,0 +1,10 @@
using Elsa.Mediator.Contracts;
using Elsa.Runtime.Notifications;
namespace Elsa.Runtime.Models;
/// <summary>
/// Published when the all workflows' triggers have been indexed.
/// </summary>
/// <param name="IndexedWorkflows">Contains the workflows that were indexed and their resulting triggers.</param>
public record WorkflowIndexingCompleted(ICollection<IndexedWorkflow> IndexedWorkflows) : INotification;

View file

@ -0,0 +1,10 @@
using Elsa.Mediator.Contracts;
using Elsa.Runtime.Notifications;
namespace Elsa.Runtime.Models;
/// <summary>
/// Published when the specified workflow's triggers have been indexed.
/// </summary>
/// <param name="IndexedWorkflow">Contains the workflow that was indexed and the resulting triggers.</param>
public record WorkflowTriggersIndexed(IndexedWorkflow IndexedWorkflow) : INotification;

View file

@ -3,4 +3,4 @@ using Elsa.Persistence.Entities;
namespace Elsa.Runtime.Notifications;
public record TriggerIndexingFinished(ICollection<WorkflowTrigger> Triggers) : INotification;
public record WorkflowBookmarksDeleted(List<WorkflowBookmark> Bookmarks) : INotification;

View file

@ -0,0 +1,6 @@
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Entities;
namespace Elsa.Runtime.Notifications;
public record WorkflowBookmarksSaved(List<WorkflowBookmark> Bookmarks) : INotification;

View file

@ -0,0 +1,34 @@
using Elsa.Mediator.Contracts;
using Elsa.Persistence.Commands;
using Elsa.Persistence.Entities;
using Elsa.Runtime.Contracts;
using Elsa.Runtime.Notifications;
namespace Elsa.Runtime.Services;
public class BookmarkManager : IBookmarkManager
{
private readonly ICommandSender _commandSender;
private readonly IEventPublisher _eventPublisher;
public BookmarkManager(ICommandSender commandSender, IEventPublisher eventPublisher)
{
_commandSender = commandSender;
_eventPublisher = eventPublisher;
}
public async Task DeleteBookmarksAsync(IEnumerable<WorkflowBookmark> bookmarks, CancellationToken cancellationToken = default)
{
var list = bookmarks.ToList();
var ids = list.Select(x => x.Id).ToList();
await _commandSender.ExecuteAsync(new DeleteWorkflowBookmarks(ids), cancellationToken);
await _eventPublisher.PublishAsync(new WorkflowBookmarksDeleted(list), cancellationToken);
}
public async Task SaveBookmarksAsync(IEnumerable<WorkflowBookmark> bookmarks, CancellationToken cancellationToken = default)
{
var list = bookmarks.ToList();
await _commandSender.ExecuteAsync(new SaveWorkflowBookmarks(list), cancellationToken);
await _eventPublisher.PublishAsync(new WorkflowBookmarksSaved(list), cancellationToken);
}
}

View file

@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
@ -8,7 +9,9 @@ using Elsa.Mediator.Contracts;
using Elsa.Models;
using Elsa.Persistence.Commands;
using Elsa.Persistence.Entities;
using Elsa.Persistence.Requests;
using Elsa.Runtime.Contracts;
using Elsa.Runtime.Models;
using Elsa.Runtime.Notifications;
using Microsoft.Extensions.Logging;
@ -25,6 +28,7 @@ public class TriggerIndexer : ITriggerIndexer
private readonly IWorkflowRegistry _workflowRegistry;
private readonly IExpressionEvaluator _expressionEvaluator;
private readonly IIdentityGenerator _identityGenerator;
private readonly IRequestSender _requestSender;
private readonly ICommandSender _commandSender;
private readonly IEventPublisher _eventPublisher;
private readonly IServiceProvider _serviceProvider;
@ -35,6 +39,7 @@ public class TriggerIndexer : ITriggerIndexer
IWorkflowRegistry workflowRegistry,
IExpressionEvaluator expressionEvaluator,
IIdentityGenerator identityGenerator,
IRequestSender requestSender,
ICommandSender commandSender,
IEventPublisher eventPublisher,
IServiceProvider serviceProvider,
@ -44,6 +49,7 @@ public class TriggerIndexer : ITriggerIndexer
_workflowRegistry = workflowRegistry;
_expressionEvaluator = expressionEvaluator;
_identityGenerator = identityGenerator;
_requestSender = requestSender;
_commandSender = commandSender;
_eventPublisher = eventPublisher;
_serviceProvider = serviceProvider;
@ -51,7 +57,7 @@ public class TriggerIndexer : ITriggerIndexer
_logger = logger;
}
public async Task IndexTriggersAsync(CancellationToken cancellationToken = default)
public async Task<ICollection<IndexedWorkflow>> IndexTriggersAsync(CancellationToken cancellationToken = default)
{
var stopwatch = new Stopwatch();
@ -60,37 +66,49 @@ public class TriggerIndexer : ITriggerIndexer
// Only stream workflows from providers that are not "dynamic" (such as DatabaseWorkflowProvider).
var workflows = _workflowRegistry.StreamAllAsync(WorkflowRegistry.SkipDynamicProviders, cancellationToken);
//var collectedTriggers = new List<WorkflowTrigger>();
// Index each workflow.
var indexedWorkflows = new Collection<IndexedWorkflow>();
await foreach (var workflow in workflows.WithCancellation(cancellationToken))
{
//var triggers = await GetTriggersAsync(workflow, cancellationToken).ToListAsync(cancellationToken);
await IndexTriggersAsync(workflow, cancellationToken);
var indexedWorkflow = await IndexTriggersAsync(workflow, cancellationToken);
indexedWorkflows.Add(indexedWorkflow);
}
// // Replace triggers for the specified workflow.
// await _commandSender.ExecuteAsync(new ReplaceWorkflowTriggers(collectedTriggers), cancellationToken);
// Publish event.
await _eventPublisher.PublishAsync(new WorkflowIndexingCompleted(indexedWorkflows), cancellationToken);
stopwatch.Stop();
_logger.LogInformation("Finished indexing workflow triggers in {ElapsedTime}", stopwatch.Elapsed);
// // Publish event.
// await _eventPublisher.PublishAsync(new TriggerIndexingFinished(collectedTriggers), cancellationToken);
return indexedWorkflows;
}
public async Task<IEnumerable<WorkflowTrigger>> IndexTriggersAsync(Workflow workflow, CancellationToken cancellationToken = default)
public async Task<IndexedWorkflow> IndexTriggersAsync(Workflow workflow, CancellationToken cancellationToken = default)
{
// Collect new triggers.
var triggers = await GetTriggersAsync(workflow, cancellationToken).ToListAsync(cancellationToken);
// Get current triggers
var currentTriggers = await GetCurrentTriggersAsync(workflow.Identity.DefinitionId, cancellationToken);
// Collect new triggers **if workflow is published**.
var newTriggers = workflow.Publication.IsPublished
? await GetTriggersAsync(workflow, cancellationToken).ToListAsync(cancellationToken)
: new List<WorkflowTrigger>(0);
// Diff triggers.
var diff = Diff.For(currentTriggers, newTriggers);
// Replace triggers for the specified workflow.
await _commandSender.ExecuteAsync(new ReplaceWorkflowTriggers(workflow, triggers), cancellationToken);
await _commandSender.ExecuteAsync(new ReplaceWorkflowTriggers(workflow, diff.Removed, diff.Added), cancellationToken);
var indexedWorkflow = new IndexedWorkflow(workflow, diff.Added, diff.Removed);
// Publish event.
await _eventPublisher.PublishAsync(new TriggerIndexingFinished(triggers), cancellationToken);
return triggers;
await _eventPublisher.PublishAsync(new WorkflowTriggersIndexed(indexedWorkflow), cancellationToken);
return indexedWorkflow;
}
private async Task<ICollection<WorkflowTrigger>> GetCurrentTriggersAsync(string workflowDefinitionId, CancellationToken cancellationToken) =>
await _requestSender.RequestAsync(new FindWorkflowTriggersByWorkflowDefinition(workflowDefinitionId), cancellationToken);
private async IAsyncEnumerable<WorkflowTrigger> GetTriggersAsync(Workflow workflow, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var context = new WorkflowIndexingContext(workflow);

View file

@ -7,7 +7,7 @@ namespace Elsa.Runtime.Services;
/// <summary>
/// A workflow server represents a logical unit of services to invoke workflows.
/// It's basically a thin wrapper around a service provider built from a given service collection using <see cref="WorkflowEngineBuilder"/>.
/// It's essentially a thin wrapper around a service provider built from a given service collection using <see cref="WorkflowEngineBuilder"/>.
/// May be useful for scenarios where you have multiple tenants in a system or if you simply want to use different workflow engine configurations within the same application.
/// </summary>
public class WorkflowServer : IWorkflowServer

View file

@ -13,7 +13,7 @@ public class TriggerWorkflowsStimulusHandler : StimulusHandler<StandardStimulus>
protected override async ValueTask<IEnumerable<IWorkflowInstruction>> GetInstructionsAsync(StandardStimulus stimulus, CancellationToken cancellationToken = default)
{
var workflowTriggers = (await _mediator.RequestAsync(new FindWorkflowTriggers(stimulus.ActivityTypeName, stimulus.Hash), cancellationToken)).ToList();
var workflowTriggers = (await _mediator.RequestAsync(new FindWorkflowTriggersByName(stimulus.ActivityTypeName, stimulus.Hash), cancellationToken)).ToList();
return workflowTriggers.Select(x => new TriggerWorkflowInstruction(x));
}
}

View file

@ -16,7 +16,6 @@ using Elsa.Modules.Scheduling.Extensions;
using Elsa.Modules.Scheduling.Triggers;
using Elsa.Persistence.EntityFrameworkCore.Extensions;
using Elsa.Persistence.EntityFrameworkCore.Sqlite;
using Elsa.Persistence.Middleware.WorkflowExecution;
using Elsa.Pipelines.WorkflowExecution.Components;
using Elsa.Runtime.Middleware;
using Elsa.Runtime.ProtoActor.Extensions;
@ -29,6 +28,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WorkflowExecutionBuilderExtensions = Elsa.Runtime.Extensions.WorkflowExecutionBuilderExtensions;
var builder = WebApplication.CreateBuilder(args);
@ -49,11 +49,13 @@ services
.AddAzureServiceBusServices(options => configuration.GetSection("AzureServiceBus").Bind(options))
.ConfigureWorkflowRuntime(options =>
{
options.Workflows.Add("HelloWorldWorkflow", new HelloWorldWorkflow());
options.Workflows.Add("HttpWorkflow", new HttpWorkflow());
options.Workflows.Add("ForkedHttpWorkflow", new ForkedHttpWorkflow());
options.Workflows.Add("AzureServiceBusWorkflow", new AzureServiceBusWorkflow());
// Register workflows.
options.Workflows.Add(nameof(HelloWorldWorkflow), new HelloWorldWorkflow());
options.Workflows.Add(nameof(HttpWorkflow), new HttpWorkflow());
options.Workflows.Add(nameof(ForkedHttpWorkflow), new ForkedHttpWorkflow());
options.Workflows.Add(nameof(CompositeActivitiesWorkflow), new CompositeActivitiesWorkflow());
options.Workflows.Add(nameof(SendMessageWorkflow), new SendMessageWorkflow());
options.Workflows.Add(nameof(ReceiveMessageWorkflow), new ReceiveMessageWorkflow());
});
// Testing only: allow client app to connect from anywhere.
@ -70,7 +72,7 @@ services
.AddActivity<Delay>()
.AddActivity<ForEach>()
.AddActivity<Switch>()
.AddActivity<Send>()
.AddActivity<SendMessage>()
;
// Register available triggers.
@ -95,10 +97,7 @@ wellKnownTypeRegistry.RegisterType<bool>("boolean");
wellKnownTypeRegistry.RegisterType<string>("string");
// Configure workflow engine execution pipeline.
serviceProvider.ConfigureDefaultWorkflowExecutionPipeline(pipeline => pipeline
.UsePersistence()
.UseWorkflowExecutionLogPersistence()
.UseWorkflowExecutionEvents()
serviceProvider.ConfigureDefaultWorkflowExecutionPipeline(pipeline => WorkflowExecutionBuilderExtensions.UseWorkflowExecutionEvents(WorkflowExecutionBuilderExtensions.UseWorkflowExecutionLogPersistence(WorkflowExecutionBuilderExtensions.UsePersistence(pipeline)))
.UseActivityScheduler()
);

View file

@ -0,0 +1,28 @@
using Elsa.Activities.Console;
using Elsa.Activities.Workflows;
using Elsa.Contracts;
using Elsa.Models;
using Elsa.Modules.AzureServiceBus.Activities;
using Elsa.Modules.AzureServiceBus.Models;
using Elsa.Runtime.Contracts;
namespace Elsa.Samples.Web1.Workflows;
public class ReceiveMessageWorkflow : IWorkflow
{
public void Build(IWorkflowDefinitionBuilder workflow)
{
workflow.AddTrigger(new MessageReceived
{
QueueOrTopic = new Input<string>("inbox")
});
workflow.WithRoot(new Sequence
{
Activities =
{
new WriteLine("Message received!")
}
});
}
}

View file

@ -5,11 +5,11 @@ using Elsa.Runtime.Contracts;
namespace Elsa.Samples.Web1.Workflows;
public class AzureServiceBusWorkflow : IWorkflow
public class SendMessageWorkflow : IWorkflow
{
public void Build(IWorkflowDefinitionBuilder workflow)
{
workflow.WithRoot(new Send
workflow.WithRoot(new SendMessage
{
QueueOrTopic = new Input<string>("inbox"),
MessageBody = new Input<object>(new { Subject = "Greetings", Message = "Hello World!" }),

View file

@ -2,13 +2,14 @@ using Elsa.Api.Extensions;
using Elsa.Extensions;
using Elsa.Modules.Http.Extensions;
using Elsa.Persistence.InMemory.Extensions;
using Elsa.Persistence.Middleware.WorkflowExecution;
using Elsa.Pipelines.WorkflowExecution.Components;
using Elsa.Runtime.Middleware;
using Elsa.Runtime.ProtoActor.Extensions;
using Elsa.Samples.Web2.Workflows;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WorkflowExecutionBuilderExtensions = Elsa.Runtime.Extensions.WorkflowExecutionBuilderExtensions;
var builder = WebApplication.CreateBuilder(args);
@ -26,8 +27,7 @@ builder.Services
var app = builder.Build();
// Configure workflow engine execution pipeline.
app.Services.ConfigureDefaultWorkflowExecutionPipeline(pipeline => pipeline
.UsePersistence()
app.Services.ConfigureDefaultWorkflowExecutionPipeline(pipeline => WorkflowExecutionBuilderExtensions.UsePersistence(pipeline)
.UseActivityScheduler()
);

View file

@ -7,9 +7,10 @@ using Elsa.Extensions;
using Elsa.Mediator.Extensions;
using Elsa.Models;
using Elsa.Persistence.InMemory.Extensions;
using Elsa.Persistence.Middleware.WorkflowExecution;
using Elsa.Pipelines.ActivityExecution.Components;
using Elsa.Pipelines.WorkflowExecution.Components;
using Elsa.Runtime.Extensions;
using Elsa.Runtime.Middleware;
using Elsa.Samples.Console1.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;