diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index 568ea02ca..4495d1a08 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -14,7 +14,7 @@ using Proto.Persistence.Sqlite; const bool useMongoDb = false; const bool useProtoActor = false; -const bool useHangfire = true; +const bool useHangfire = false; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; @@ -90,6 +90,8 @@ services e.UseEntityFrameworkCore(); }); runtime.UseMassTransitDispatcher(); + + runtime.WorkflowInboxCleanupOptions = options => configuration.GetSection("Runtime:WorkflowInboxCleanup").Bind(options); }) .UseEnvironments(environments => environments.EnvironmentsOptions = options => configuration.GetSection("Environments").Bind(options)) .UseScheduling(scheduling => @@ -99,13 +101,12 @@ services }) .UseWorkflowsApi(api => api.AddFastEndpointsAssembly()) .UseRealTimeWorkflows() - .UseJavaScript() + .UseJavaScript(js => js.JintOptions = options => options.AllowClrAccess = true) .UseLiquid() .UseHttp(http => http.HttpEndpointAuthorizationHandler = sp => sp.GetRequiredService()) .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)); }); -services.Configure(options => options.AllowClrAccess = true); services.AddHealthChecks(); services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin().WithExposedHeaders("*"))); diff --git a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json index aba4c7941..a3bbd52f5 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json +++ b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json @@ -3,6 +3,7 @@ "LogLevel": { "Default": "Warning", "Elsa.Mediator": "Warning", + "Elsa.Workflows.Runtime.HostedServices": "Information", "MassTransit": "Warning", "Microsoft.Extensions.Http": "Warning", "Microsoft.Hosting.Lifetime": "Information", @@ -78,5 +79,11 @@ "ServerUrl": "https://production.acme.com/elsa/api" } ] + }, + "Runtime": { + "WorkflowInboxCleanup": { + "SweepInterval": "00:00:10:00", + "BatchSize": 1000 + } } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Common/Extensions/QueryableExtensions.cs b/src/modules/Elsa.Common/Extensions/QueryableExtensions.cs index c0001075d..e4fc50b05 100644 --- a/src/modules/Elsa.Common/Extensions/QueryableExtensions.cs +++ b/src/modules/Elsa.Common/Extensions/QueryableExtensions.cs @@ -53,7 +53,7 @@ public static class QueryableExtensions /// The pagination arguments. /// The type of the queryable. /// The paginated queryable. - public static IQueryable Paginate(this IQueryable queryable, PageArgs pageArgs) + public static IQueryable Paginate(this IQueryable queryable, PageArgs? pageArgs) { if (pageArgs?.Offset != null) queryable = queryable.Skip(pageArgs.Offset.Value); if (pageArgs?.Limit != null) queryable = queryable.Take(pageArgs.Limit.Value); diff --git a/src/modules/Elsa.Dapper/Modules/Runtime/Features/DapperWorkflowRuntimePersistenceFeature.cs b/src/modules/Elsa.Dapper/Modules/Runtime/Features/DapperWorkflowRuntimePersistenceFeature.cs index 8825462e0..01563c5bf 100644 --- a/src/modules/Elsa.Dapper/Modules/Runtime/Features/DapperWorkflowRuntimePersistenceFeature.cs +++ b/src/modules/Elsa.Dapper/Modules/Runtime/Features/DapperWorkflowRuntimePersistenceFeature.cs @@ -28,7 +28,7 @@ public class DapperWorkflowRuntimePersistenceFeature : FeatureBase { feature.TriggerStore = sp => sp.GetRequiredService(); feature.BookmarkStore = sp => sp.GetRequiredService(); - feature.WorkflowInboxStore = sp => sp.GetRequiredService(); + feature.WorkflowInboxStore = sp => sp.GetRequiredService(); }); } @@ -39,6 +39,6 @@ public class DapperWorkflowRuntimePersistenceFeature : FeatureBase Services.AddSingleton(); Services.AddSingleton(); - Services.AddSingleton(); + Services.AddSingleton(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxStore.cs b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxMessageStore.cs similarity index 85% rename from src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxStore.cs rename to src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxMessageStore.cs index 5c5d8097f..b7292c4bc 100644 --- a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxStore.cs +++ b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperWorkflowInboxMessageStore.cs @@ -1,4 +1,7 @@ using System.Text.Json; +using Elsa.Common.Contracts; +using Elsa.Common.Entities; +using Elsa.Common.Models; using Elsa.Dapper.Contracts; using Elsa.Dapper.Extensions; using Elsa.Dapper.Models; @@ -14,19 +17,21 @@ namespace Elsa.Dapper.Modules.Runtime.Stores; /// /// A Dapper-based implementation. /// -public class DapperWorkflowInboxStore : IWorkflowInboxStore +public class DapperWorkflowInboxMessageStore : IWorkflowInboxMessageStore { private readonly IPayloadSerializer _payloadSerializer; + private readonly ISystemClock _systemClock; private const string TableName = "WorkflowInboxMessages"; private const string PrimaryKeyName = "Id"; private readonly Store _store; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public DapperWorkflowInboxStore(IDbConnectionProvider dbConnectionProvider, IPayloadSerializer payloadSerializer) + public DapperWorkflowInboxMessageStore(IDbConnectionProvider dbConnectionProvider, IPayloadSerializer payloadSerializer, ISystemClock systemClock) { _payloadSerializer = payloadSerializer; + _systemClock = systemClock; _store = new Store(dbConnectionProvider, TableName, PrimaryKeyName); } @@ -52,9 +57,12 @@ public class DapperWorkflowInboxStore : IWorkflowInboxStore } /// - public async ValueTask DeleteAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) + public async ValueTask DeleteManyAsync(WorkflowInboxMessageFilter filter, PageArgs? pageArgs = default, CancellationToken cancellationToken = default) { - return await _store.DeleteAsync(q => ApplyFilter(q, filter), cancellationToken); + if (pageArgs == null) + return await _store.DeleteAsync(q => ApplyFilter(q, filter), cancellationToken); + + return await _store.DeleteAsync(q => ApplyFilter(q, filter), pageArgs, new[] { new OrderField(nameof(WorkflowInboxMessage.CreatedAt), OrderDirection.Ascending) }, cancellationToken); } private void ApplyFilter(ParameterizedQuery query, params WorkflowInboxMessageFilter[] filters) diff --git a/src/modules/Elsa.Dapper/Services/Store.cs b/src/modules/Elsa.Dapper/Services/Store.cs index 414532ab6..e7574309e 100644 --- a/src/modules/Elsa.Dapper/Services/Store.cs +++ b/src/modules/Elsa.Dapper/Services/Store.cs @@ -231,7 +231,7 @@ public class Store where T : notnull /// The conditions to apply to the query. /// The cancellation token. /// The number of records deleted. - public async Task DeleteAsync(Action filter, CancellationToken cancellationToken = default) + public async Task DeleteAsync(Action filter, CancellationToken cancellationToken = default) { var query = _dbConnectionProvider.CreateQuery().Delete(TableName); filter(query); @@ -239,6 +239,23 @@ public class Store where T : notnull return await query.ExecuteAsync(connection); } + /// + /// Deletes all records matching the specified query. + /// + /// The conditions to apply to the query. + /// The page arguments. + /// The fields by which to order the results. + /// The cancellation token. + /// The number of records deleted. + public async Task DeleteAsync(Action filter, PageArgs pageArgs, IEnumerable orderFields, CancellationToken cancellationToken = default) + { + using var connection = _dbConnectionProvider.GetConnection(); + var query = _dbConnectionProvider.CreateQuery().Delete(TableName); + filter(query); + query = query.OrderBy(orderFields.ToArray()).Page(pageArgs); + return await query.ExecuteAsync(connection); + } + /// /// Returns true if any records match the specified query. /// diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowInboxStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowInboxStore.cs index 53ce6436d..c37f4cee1 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowInboxStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowInboxStore.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Elsa.Common.Contracts; +using Elsa.Common.Models; using Elsa.EntityFrameworkCore.Common; using Elsa.Workflows.Core.Contracts; using Elsa.Workflows.Runtime.Contracts; @@ -10,39 +12,47 @@ namespace Elsa.EntityFrameworkCore.Modules.Runtime; /// /// An EF Core implementation of . /// -public class EFCoreWorkflowInboxStore : IWorkflowInboxStore +public class EFCoreWorkflowInboxMessageStore : IWorkflowInboxMessageStore { private readonly EntityStore _store; private readonly IPayloadSerializer _payloadSerializer; + private readonly ISystemClock _systemClock; /// /// Constructor. /// - public EFCoreWorkflowInboxStore(EntityStore store, IPayloadSerializer payloadSerializer) + public EFCoreWorkflowInboxMessageStore(EntityStore store, IPayloadSerializer payloadSerializer, ISystemClock systemClock) { _store = store; _payloadSerializer = payloadSerializer; + _systemClock = systemClock; } /// public async ValueTask SaveAsync(WorkflowInboxMessage record, CancellationToken cancellationToken = default) => await _store.SaveAsync(record, OnSaveAsync, cancellationToken); /// - public async ValueTask> FindManyAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) => await _store.QueryAsync(filter.Apply, OnLoadAsync, cancellationToken); + public async ValueTask> FindManyAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) + { + return await _store.QueryAsync(q => filter.Apply(q, _systemClock.UtcNow), OnLoadAsync, cancellationToken); + } /// public async ValueTask> FindManyAsync(IEnumerable filters, CancellationToken cancellationToken = default) { return await _store.QueryAsync(query => { - foreach (var filter in filters) filter.Apply(query); + foreach (var filter in filters) filter.Apply(query, _systemClock.UtcNow); return query; }, OnLoadAsync, cancellationToken); } /// - public async ValueTask DeleteAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) => await _store.DeleteWhereAsync(filter.Apply, cancellationToken); - + public async ValueTask DeleteManyAsync(WorkflowInboxMessageFilter filter, PageArgs? pageArgs = default, CancellationToken cancellationToken = default) + { + return await _store.DeleteWhereAsync(q => Paginate(filter.Apply(q, _systemClock.UtcNow), pageArgs), cancellationToken); + } + private ValueTask OnSaveAsync(RuntimeElsaDbContext dbContext, WorkflowInboxMessage entity, CancellationToken cancellationToken) { dbContext.Entry(entity).Property("SerializedBookmarkPayload").CurrentValue = _payloadSerializer.Serialize(entity.BookmarkPayload); @@ -59,11 +69,18 @@ public class EFCoreWorkflowInboxStore : IWorkflowInboxStore var bookmarkPayloadJson = dbContext.Entry(entity).Property("SerializedBookmarkPayload").CurrentValue; var inputJson = dbContext.Entry(entity).Property("SerializedInput").CurrentValue; var affectedWorkflowInstancesIdsJson = dbContext.Entry(entity).Property("SerializedAffectedWorkflowInstancesIds").CurrentValue; - + entity.BookmarkPayload = _payloadSerializer.Deserialize(bookmarkPayloadJson); entity.Input = !string.IsNullOrEmpty(inputJson) ? _payloadSerializer.Deserialize>(inputJson) : null; entity.AffectedWorkflowInstancesIds = !string.IsNullOrEmpty(affectedWorkflowInstancesIdsJson) ? JsonSerializer.Deserialize>(affectedWorkflowInstancesIdsJson) : null; return ValueTask.CompletedTask; } + + private static IQueryable Paginate(IQueryable queryable, PageArgs? pageArgs) + { + if (pageArgs?.Offset != null) queryable = queryable.Skip(pageArgs.Offset.Value); + if (pageArgs?.Limit != null) queryable = queryable.Take(pageArgs.Limit.Value); + return queryable; + } } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs index 69edb93db..969ef7576 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs @@ -25,7 +25,7 @@ public class EFCoreWorkflowRuntimePersistenceFeature : PersistenceFeatureBase sp.GetRequiredService(); feature.BookmarkStore = sp => sp.GetRequiredService(); - feature.WorkflowInboxStore = sp => sp.GetRequiredService(); + feature.WorkflowInboxStore = sp => sp.GetRequiredService(); }); } @@ -36,6 +36,6 @@ public class EFCoreWorkflowRuntimePersistenceFeature : PersistenceFeatureBase(); AddStore(); - AddEntityStore(); + AddEntityStore(); } } \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Features/JavaScriptFeature.cs b/src/modules/Elsa.JavaScript/Features/JavaScriptFeature.cs index ba2211105..110f8ff27 100644 --- a/src/modules/Elsa.JavaScript/Features/JavaScriptFeature.cs +++ b/src/modules/Elsa.JavaScript/Features/JavaScriptFeature.cs @@ -8,6 +8,7 @@ using Elsa.Features.Services; using Elsa.JavaScript.Contracts; using Elsa.JavaScript.Expressions; using Elsa.JavaScript.Extensions; +using Elsa.JavaScript.Options; using Elsa.JavaScript.Providers; using Elsa.JavaScript.Services; using Elsa.JavaScript.TypeDefinitions.Contracts; @@ -28,10 +29,17 @@ public class JavaScriptFeature : FeatureBase public JavaScriptFeature(IModule module) : base(module) { } - + + /// + /// Configures the Jint options. + /// + public Action JintOptions { get; set; } = _ => { }; + /// public override void Apply() { + Services.Configure(JintOptions); + // JavaScript services. Services .AddSingleton() diff --git a/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowInboxStore.cs b/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowInboxStore.cs index dda0b3b7c..0731c03d7 100644 --- a/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowInboxStore.cs +++ b/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowInboxStore.cs @@ -1,3 +1,6 @@ +using Elsa.Common.Contracts; +using Elsa.Common.Models; +using Elsa.Extensions; using Elsa.MongoDb.Common; using Elsa.Workflows.Runtime.Contracts; using Elsa.Workflows.Runtime.Entities; @@ -9,16 +12,18 @@ namespace Elsa.MongoDb.Modules.Runtime; /// /// A MongoDb implementation of . /// -public class MongoWorkflowInboxStore : IWorkflowInboxStore +public class MongoWorkflowInboxMessageStore : IWorkflowInboxMessageStore { private readonly MongoDbStore _mongoDbStore; + private readonly ISystemClock _systemClock; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public MongoWorkflowInboxStore(MongoDbStore mongoDbStore) + public MongoWorkflowInboxMessageStore(MongoDbStore mongoDbStore, ISystemClock systemClock) { _mongoDbStore = mongoDbStore; + _systemClock = systemClock; } /// @@ -38,12 +43,14 @@ public class MongoWorkflowInboxStore : IWorkflowInboxStore } /// - public async ValueTask DeleteAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) => - await _mongoDbStore.DeleteWhereAsync(query => Filter(query, filter), x => x.Id, cancellationToken); + public async ValueTask DeleteManyAsync(WorkflowInboxMessageFilter filter, PageArgs? pageArgs = default, CancellationToken cancellationToken = default) => + await _mongoDbStore.DeleteWhereAsync(query => Paginate(Filter(query, filter), pageArgs), x => x.Id, cancellationToken); - private static IMongoQueryable Filter(IMongoQueryable queryable, params WorkflowInboxMessageFilter[] filters) + private IMongoQueryable Filter(IMongoQueryable queryable, params WorkflowInboxMessageFilter[] filters) { - foreach (var filter in filters) filter.Apply(queryable); + foreach (var filter in filters) filter.Apply(queryable, _systemClock.UtcNow); return queryable; } + + private IMongoQueryable Paginate(IMongoQueryable queryable, PageArgs? pageArgs) => (queryable.Paginate(pageArgs) as IMongoQueryable)!; } \ No newline at end of file diff --git a/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs b/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs index b73942a9f..1bde3d573 100644 --- a/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs +++ b/src/modules/Elsa.MongoDb/Modules/Runtime/WorkflowRuntimePersistenceFeature.cs @@ -26,7 +26,7 @@ public class MongoWorkflowRuntimePersistenceFeature : PersistenceFeatureBase feature.TriggerStore = sp => sp.GetRequiredService(); feature.BookmarkStore = sp => sp.GetRequiredService(); feature.ActivityExecutionLogStore = sp => sp.GetRequiredService(); - feature.WorkflowInboxStore = sp => sp.GetRequiredService(); + feature.WorkflowInboxStore = sp => sp.GetRequiredService(); }); } @@ -43,7 +43,7 @@ public class MongoWorkflowRuntimePersistenceFeature : PersistenceFeatureBase AddStore(); AddStore(); AddStore(); - AddStore(); + AddStore(); Services.AddHostedService(); } diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxStore.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxMessageStore.cs similarity index 85% rename from src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxStore.cs rename to src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxMessageStore.cs index ae4fa5319..116d97cfc 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxStore.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowInboxMessageStore.cs @@ -1,3 +1,4 @@ +using Elsa.Common.Models; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Filters; @@ -6,7 +7,7 @@ namespace Elsa.Workflows.Runtime.Contracts; /// /// A store for workflow inbox messages. /// -public interface IWorkflowInboxStore +public interface IWorkflowInboxMessageStore { /// /// Adds a message to the store. @@ -37,7 +38,8 @@ public interface IWorkflowInboxStore /// Deletes all messages matching the specified filter. /// /// The filter to apply. + /// An optional page arguments. /// An optional cancellation token. /// The number of deleted messages. - ValueTask DeleteAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default); + ValueTask DeleteManyAsync(WorkflowInboxMessageFilter filter, PageArgs? pageArgs = default, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj index 1f22e7f38..8e6c75388 100644 --- a/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj +++ b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj @@ -25,4 +25,5 @@ + diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index ab7016d5c..e62ceac2e 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -71,9 +71,9 @@ public class WorkflowRuntimeFeature : FeatureBase public Func ActivityExecutionLogStore { get; set; } = sp => sp.GetRequiredService(); /// - /// A factory that instantiates an . + /// A factory that instantiates an . /// - public Func WorkflowInboxStore { get; set; } = sp => sp.GetRequiredService(); + public Func WorkflowInboxStore { get; set; } = sp => sp.GetRequiredService(); /// /// A factory that instantiates an . @@ -99,6 +99,11 @@ public class WorkflowRuntimeFeature : FeatureBase /// A delegate to configure the . /// public Action DistributedLockingOptions { get; set; } = _ => { }; + + /// + /// A delegate to configure the . + /// + public Action WorkflowInboxCleanupOptions { get; set; } = _ => { }; /// /// Register the specified workflow type. @@ -135,7 +140,11 @@ public class WorkflowRuntimeFeature : FeatureBase } /// - public override void ConfigureHostedServices() => Module.ConfigureHostedService(); + public override void ConfigureHostedServices() + { + Module.ConfigureHostedService(); + Module.ConfigureHostedService(); + } /// public override void Apply() @@ -143,6 +152,7 @@ public class WorkflowRuntimeFeature : FeatureBase // Options. Services.Configure(DistributedLockingOptions); Services.Configure(options => { options.Workflows = Workflows; }); + Services.Configure(WorkflowInboxCleanupOptions); Services // Core. @@ -183,7 +193,7 @@ public class WorkflowRuntimeFeature : FeatureBase .AddMemoryStore() .AddMemoryStore() .AddMemoryStore() - .AddMemoryStore() + .AddMemoryStore() // Distributed locking. .AddSingleton(DistributedLockProvider) diff --git a/src/modules/Elsa.Workflows.Runtime/Filters/WorkflowInboxMessageFilter.cs b/src/modules/Elsa.Workflows.Runtime/Filters/WorkflowInboxMessageFilter.cs index 5ddc8617a..fe5f011f0 100644 --- a/src/modules/Elsa.Workflows.Runtime/Filters/WorkflowInboxMessageFilter.cs +++ b/src/modules/Elsa.Workflows.Runtime/Filters/WorkflowInboxMessageFilter.cs @@ -1,3 +1,4 @@ +using Elsa.Common.Contracts; using Elsa.Workflows.Runtime.Entities; namespace Elsa.Workflows.Runtime.Filters; @@ -37,11 +38,16 @@ public class WorkflowInboxMessageFilter /// public bool? IsHandled { get; set; } + /// + /// A flag indicating whether to filter by messages that have expired. + /// + public bool? IsExpired { get; set; } + /// /// Applies the filter to the specified query. /// /// The filtered query. - public IQueryable Apply(IQueryable query) + public IQueryable Apply(IQueryable query, DateTimeOffset now) { var filter = this; if (filter.CorrelationId != null) query = query.Where(x => filter.CorrelationId == x.CorrelationId); @@ -50,6 +56,7 @@ public class WorkflowInboxMessageFilter if (filter.Hash != null) query = query.Where(x => filter.Hash == x.Hash); if (filter.ActivityTypeName != null) query = query.Where(x => filter.ActivityTypeName == x.ActivityTypeName); if (filter.ActivityInstanceId != null) query = query.Where(x => filter.ActivityInstanceId == x.ActivityInstanceId); + if (filter.IsExpired != null) query = query.Where(x => x.ExpiresAt <= now); return query; } diff --git a/src/modules/Elsa.Workflows.Runtime/HostedServices/WorkflowInboxCleanupHostedService.cs b/src/modules/Elsa.Workflows.Runtime/HostedServices/WorkflowInboxCleanupHostedService.cs new file mode 100644 index 000000000..8afb184bc --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/HostedServices/WorkflowInboxCleanupHostedService.cs @@ -0,0 +1,64 @@ +using Elsa.Common.Models; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Options; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.Runtime.HostedServices; + +/// +/// Cleans up expired messages from the database. +/// +public class WorkflowInboxCleanupHostedService : BackgroundService +{ + private readonly IWorkflowInboxMessageStore _workflowInboxMessageStore; + private readonly IOptions _options; + private readonly ILogger _logger; + + /// + public WorkflowInboxCleanupHostedService( + IWorkflowInboxMessageStore workflowInboxMessageStore, + IOptions options, + ILogger logger) + { + _workflowInboxMessageStore = workflowInboxMessageStore; + _options = options; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Entering expired workflow inbox messages cleanup service loop"); + + try + { + await CleanupExpiredMessages(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while cleaning up expired workflow inbox messages"); + } + + var sweepInterval = _options.Value.SweepInterval; + _logger.LogInformation("Expired messages cleanup service is going to sleep for {SweepInterval} minutes", sweepInterval.TotalMinutes); + await Task.Delay(sweepInterval, stoppingToken); + } + } + + private async Task CleanupExpiredMessages(CancellationToken cancellationToken) + { + var filter = new WorkflowInboxMessageFilter + { + IsExpired = true + }; + + var pageArgs = PageArgs.FromRange(0, _options.Value.BatchSize); + var deleteCount = await _workflowInboxMessageStore.DeleteManyAsync(filter, pageArgs, cancellationToken); + _logger.LogInformation("Cleaned up {DeleteCount} expired messages", deleteCount); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs b/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs new file mode 100644 index 000000000..fc694689e --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs @@ -0,0 +1,17 @@ +namespace Elsa.Workflows.Runtime.Options; + +/// +/// Options for cleaning up the workflow inbox. +/// +public class WorkflowInboxCleanupOptions +{ + /// + /// The sweep interval at which to clean up the workflow inbox. + /// + public TimeSpan SweepInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// The number of messages to clean up per sweep. + /// + public int BatchSize { get; set; } = 1000; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Results/DeliverWorkflowInboxMessageResult.cs b/src/modules/Elsa.Workflows.Runtime/Results/DeliverWorkflowInboxMessageResult.cs index b50e04483..478c8fd8e 100644 --- a/src/modules/Elsa.Workflows.Runtime/Results/DeliverWorkflowInboxMessageResult.cs +++ b/src/modules/Elsa.Workflows.Runtime/Results/DeliverWorkflowInboxMessageResult.cs @@ -1,3 +1,5 @@ -namespace Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Contracts; + +namespace Elsa.Workflows.Runtime.Results; public record DeliverWorkflowInboxMessageResult(ICollection WorkflowExecutionResults); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowInbox.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowInbox.cs index 3b7af0ff2..4b75fc32e 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowInbox.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowInbox.cs @@ -15,7 +15,7 @@ namespace Elsa.Workflows.Runtime.Services; public class DefaultWorkflowInbox : IWorkflowInbox { private readonly IWorkflowRuntime _workflowRuntime; - private readonly IWorkflowInboxStore _store; + private readonly IWorkflowInboxMessageStore _messageStore; private readonly INotificationSender _notificationSender; private readonly ISystemClock _systemClock; private readonly IIdentityGenerator _identityGenerator; @@ -26,14 +26,14 @@ public class DefaultWorkflowInbox : IWorkflowInbox /// public DefaultWorkflowInbox( IWorkflowRuntime workflowRuntime, - IWorkflowInboxStore store, + IWorkflowInboxMessageStore messageStore, INotificationSender notificationSender, ISystemClock systemClock, IIdentityGenerator identityGenerator, IBookmarkHasher bookmarkHasher) { _workflowRuntime = workflowRuntime; - _store = store; + _messageStore = messageStore; _notificationSender = notificationSender; _systemClock = systemClock; _identityGenerator = identityGenerator; @@ -68,7 +68,7 @@ public class DefaultWorkflowInbox : IWorkflowInbox }; // Store the message. - await _store.SaveAsync(message, cancellationToken); + await _messageStore.SaveAsync(message, cancellationToken); // Send a notification. var strategy = options.EventPublishingStrategy; @@ -91,7 +91,7 @@ public class DefaultWorkflowInbox : IWorkflowInbox { message.AffectedWorkflowInstancesIds = triggeredWorkflows.Select(x => x.WorkflowInstanceId).ToList(); message.HandledAt = _systemClock.UtcNow; - await _store.SaveAsync(message, cancellationToken); + await _messageStore.SaveAsync(message, cancellationToken); } return new DeliverWorkflowInboxMessageResult(triggeredWorkflows); @@ -117,12 +117,12 @@ public class DefaultWorkflowInbox : IWorkflowInbox /// public async ValueTask> FindManyAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) { - return await _store.FindManyAsync(filter, cancellationToken); + return await _messageStore.FindManyAsync(filter, cancellationToken); } /// public async ValueTask> FindManyAsync(IEnumerable filters, CancellationToken cancellationToken = default) { - return await _store.FindManyAsync(filters, cancellationToken); + return await _messageStore.FindManyAsync(filters, cancellationToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxMessageStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxMessageStore.cs new file mode 100644 index 000000000..f4b0b5544 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxMessageStore.cs @@ -0,0 +1,69 @@ +using Elsa.Common.Contracts; +using Elsa.Common.Models; +using Elsa.Common.Services; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; + +namespace Elsa.Workflows.Runtime.Stores; + +/// +/// An in-memory implementation of . +/// +public class MemoryWorkflowInboxMessageStore : IWorkflowInboxMessageStore +{ + private readonly MemoryStore _store; + private readonly ISystemClock _systemClock; + + /// + /// Initializes a new instance of the class. + /// + public MemoryWorkflowInboxMessageStore(MemoryStore store, ISystemClock systemClock) + { + _store = store; + _systemClock = systemClock; + } + + /// + public ValueTask SaveAsync(WorkflowInboxMessage message, CancellationToken cancellationToken = default) + { + _store.Save(message, x => x.Id); + return ValueTask.CompletedTask; + } + + /// + public ValueTask> FindManyAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) + { + var entities = _store.Query(query => Filter(query, filter)).ToList(); + return new(entities); + } + + /// + public ValueTask> FindManyAsync(IEnumerable filters, CancellationToken cancellationToken = default) + { + var entities = _store.Query(query => Filter(query, filters.ToArray())).ToList(); + return new(entities); + } + + /// + public ValueTask DeleteManyAsync(WorkflowInboxMessageFilter filter, PageArgs? pageArgs = default, CancellationToken cancellationToken = default) + { + var entities = _store.Query(query => Paginate(Filter(query, filter), pageArgs)).ToList(); + var ids = entities.Select(x => x.Id); + var deleteCount = _store.DeleteMany(ids); + return new (deleteCount); + } + + private IQueryable Filter(IQueryable query, params WorkflowInboxMessageFilter[] filters) + { + foreach (var filter in filters) filter.Apply(query, _systemClock.UtcNow); + return query; + } + + private static IQueryable Paginate(IQueryable queryable, PageArgs? pageArgs) + { + if (pageArgs?.Offset != null) queryable = queryable.Skip(pageArgs.Offset.Value); + if (pageArgs?.Limit != null) queryable = queryable.Take(pageArgs.Limit.Value); + return queryable; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxStore.cs deleted file mode 100644 index 82f1d8f20..000000000 --- a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryWorkflowInboxStore.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Elsa.Common.Services; -using Elsa.Workflows.Runtime.Contracts; -using Elsa.Workflows.Runtime.Entities; -using Elsa.Workflows.Runtime.Filters; - -namespace Elsa.Workflows.Runtime.Stores; - -/// -/// An in-memory implementation of . -/// -public class MemoryWorkflowInboxStore : IWorkflowInboxStore -{ - private readonly MemoryStore _store; - - /// - /// Initializes a new instance of the class. - /// - public MemoryWorkflowInboxStore(MemoryStore store) - { - _store = store; - } - - /// - public ValueTask SaveAsync(WorkflowInboxMessage message, CancellationToken cancellationToken = default) - { - _store.Save(message, x => x.Id); - return ValueTask.CompletedTask; - } - - /// - public ValueTask> FindManyAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) - { - var entities = _store.Query(query => Filter(query, filter)).ToList(); - return new(entities); - } - - /// - public ValueTask> FindManyAsync(IEnumerable filters, CancellationToken cancellationToken = default) - { - var entities = _store.Query(query => Filter(query, filters.ToArray())).ToList(); - return new(entities); - } - - /// - public async ValueTask DeleteAsync(WorkflowInboxMessageFilter filter, CancellationToken cancellationToken = default) - { - var ids = (await FindManyAsync(filter, cancellationToken)).Select(x => x.Id); - return _store.DeleteMany(ids); - } - - private static IQueryable Filter(IQueryable query, params WorkflowInboxMessageFilter[] filters) - { - foreach (var filter in filters) filter.Apply(query); - return query; - } -} \ No newline at end of file