diff --git a/Elsa.sln b/Elsa.sln index b4e9ed417..12318883a 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -364,6 +364,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.InfiniteLoopDe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.Forks", "src\samples\console\Elsa.Samples.Forks\Elsa.Samples.Forks.csproj", "{AB67BA19-5BC2-4C6D-A994-61D51A6A6FD5}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "retention", "retention", "{235ABC3F-A075-4682-B6D7-837098BA6B00}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Retention", "src\modules\Elsa.Retention\Elsa.Retention.csproj", "{6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -862,6 +866,10 @@ Global {AB67BA19-5BC2-4C6D-A994-61D51A6A6FD5}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB67BA19-5BC2-4C6D-A994-61D51A6A6FD5}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB67BA19-5BC2-4C6D-A994-61D51A6A6FD5}.Release|Any CPU.Build.0 = Release|Any CPU + {6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1024,6 +1032,8 @@ Global {8EC3CF51-EBC1-4B45-881E-4B1B8190D579} = {E42743A0-FBDD-4150-9D53-6000496D9B87} {A2572DBF-AF11-4A02-AF99-B343871F2D85} = {22E75696-6FE9-436A-9097-EE21C603F818} {AB67BA19-5BC2-4C6D-A994-61D51A6A6FD5} = {FC9F520F-BA51-4AD2-BFEE-EF787798E734} + {235ABC3F-A075-4682-B6D7-837098BA6B00} = {69BB424A-AAA3-415C-9651-9F4349CA3AC5} + {6DD8EB07-95CD-4F5A-9C0C-FD7F317EBC5A} = {235ABC3F-A075-4682-B6D7-837098BA6B00} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B0975FD-7050-48B0-88C5-48C33378E158} diff --git a/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowCreatedBeforeSpecification.cs b/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowCreatedBeforeSpecification.cs new file mode 100644 index 000000000..b9aee4027 --- /dev/null +++ b/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowCreatedBeforeSpecification.cs @@ -0,0 +1,15 @@ +using System; +using System.Linq.Expressions; +using Elsa.Models; +using NodaTime; + +namespace Elsa.Persistence.Specifications.WorkflowInstances +{ + public class WorkflowCreatedBeforeSpecification : Specification + { + public WorkflowCreatedBeforeSpecification(Instant instant) => Instant = instant; + public Instant Instant { get; } + + public override Expression> ToExpression() => x => x.CreatedAt <= Instant; + } +} \ No newline at end of file diff --git a/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowStatusSpecification.cs b/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowStatusSpecification.cs index ce4321241..5ad00dba4 100644 --- a/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowStatusSpecification.cs +++ b/src/core/Elsa.Core/Persistence/Specifications/WorkflowInstances/WorkflowStatusSpecification.cs @@ -6,8 +6,8 @@ namespace Elsa.Persistence.Specifications.WorkflowInstances { public class WorkflowStatusSpecification : Specification { - public WorkflowStatus WorkflowStatus { get; set; } public WorkflowStatusSpecification(WorkflowStatus workflowStatus) => WorkflowStatus = workflowStatus; + public WorkflowStatus WorkflowStatus { get; set; } public override Expression> ToExpression() => x => x.WorkflowStatus == WorkflowStatus; } } \ No newline at end of file diff --git a/src/modules/Elsa.Retention/Elsa.Retention.csproj b/src/modules/Elsa.Retention/Elsa.Retention.csproj new file mode 100644 index 000000000..17a65f405 --- /dev/null +++ b/src/modules/Elsa.Retention/Elsa.Retention.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.1 + enable + + + + + + + + + + + + diff --git a/src/modules/Elsa.Retention/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Retention/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..68c774632 --- /dev/null +++ b/src/modules/Elsa.Retention/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Elsa.Retention.HostedServices; +using Elsa.Retention.Jobs; +using Elsa.Retention.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Retention.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddRetentionServices(this IServiceCollection services, Action configureOptions) + { + services + .Configure(configureOptions) + .AddScoped() + .AddHostedService(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Retention/HostedServices/CleanupService.cs b/src/modules/Elsa.Retention/HostedServices/CleanupService.cs new file mode 100644 index 000000000..08482213a --- /dev/null +++ b/src/modules/Elsa.Retention/HostedServices/CleanupService.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Elsa.Retention.Jobs; +using Elsa.Retention.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Elsa.Retention.HostedServices +{ + /// + /// Periodically wipes workflow instances and their execution logs. + /// + public class CleanupService : IHostedService, IAsyncDisposable + { + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly CleanupOptions _options; + private readonly Timer _timer; + + public CleanupService(IOptions options, IServiceScopeFactory serviceScopeFactory) + { + _serviceScopeFactory = serviceScopeFactory; + _options = options.Value; + _timer = new Timer(ExecuteAsync, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _timer.Change(_options.SweepInterval.ToTimeSpan(), Timeout.InfiniteTimeSpan); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() => await _timer.DisposeAsync(); + + private async void ExecuteAsync(object state) + { + using var scope = _serviceScopeFactory.CreateScope(); + var job = scope.ServiceProvider.GetRequiredService(); + await job.ExecuteAsync(); + + _timer.Change(_options.SweepInterval.ToTimeSpan(), Timeout.InfiniteTimeSpan); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Retention/Jobs/CleanupJob.cs b/src/modules/Elsa.Retention/Jobs/CleanupJob.cs new file mode 100644 index 000000000..f097dbdd8 --- /dev/null +++ b/src/modules/Elsa.Retention/Jobs/CleanupJob.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Elsa.Models; +using Elsa.Persistence; +using Elsa.Persistence.Specifications; +using Elsa.Persistence.Specifications.WorkflowInstances; +using Elsa.Retention.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodaTime; + +namespace Elsa.Retention.Jobs +{ + public class CleanupJob + { + private readonly IWorkflowInstanceStore _workflowInstanceStore; + private readonly IClock _clock; + private readonly CleanupOptions _options; + private readonly ILogger _logger; + + public CleanupJob(IWorkflowInstanceStore workflowInstanceStore, IClock clock, IOptions options, ILogger logger) + { + _workflowInstanceStore = workflowInstanceStore; + _clock = clock; + _options = options.Value; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + var threshold = _clock.GetCurrentInstant().Minus(_options.TimeToLive); + var specification = new WorkflowCreatedBeforeSpecification(threshold); + var take = _options.PageSize; + IList workflowInstanceIds; + + do + { + workflowInstanceIds = (await _workflowInstanceStore.FindManyAsync(specification, new OrderBy(x => x.CreatedAt, SortDirection.Descending), new Paging(0, take), cancellationToken: cancellationToken)) + .Select(x => x.Id).ToList(); + _logger.LogInformation("Deleting {WorkflowInstanceCount} workflow instances", workflowInstanceIds.Count); + + if (workflowInstanceIds.Any()) + await DeleteManyAsync(workflowInstanceIds, cancellationToken); + } while (workflowInstanceIds.Any()); + } + + private async Task DeleteManyAsync(IEnumerable workflowInstanceIds, CancellationToken cancellationToken) + { + var specification = new WorkflowInstanceIdsSpecification(workflowInstanceIds); + await _workflowInstanceStore.DeleteManyAsync(specification, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Retention/Options/CleanupOptions.cs b/src/modules/Elsa.Retention/Options/CleanupOptions.cs new file mode 100644 index 000000000..261c0bf51 --- /dev/null +++ b/src/modules/Elsa.Retention/Options/CleanupOptions.cs @@ -0,0 +1,22 @@ +using NodaTime; + +namespace Elsa.Retention.Options +{ + public class CleanupOptions + { + /// + /// Controls how often the database is checked for workflow instances and execution log records to remove. + /// + public Duration SweepInterval { get; set; } = Duration.FromHours(4); + + /// + /// The maximum age a workflow instance is allowed to exist before being removed. + /// + public Duration TimeToLive { get; set; } + + /// + /// The maximum number of workflow instances to delete at the same time. + /// + public int PageSize { get; set; } = 100; + } +} \ No newline at end of file diff --git a/src/samples/server/Elsa.Samples.Server.Host/Elsa.Samples.Server.Host.csproj b/src/samples/server/Elsa.Samples.Server.Host/Elsa.Samples.Server.Host.csproj index 3aca7d573..cc375458c 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/Elsa.Samples.Server.Host.csproj +++ b/src/samples/server/Elsa.Samples.Server.Host/Elsa.Samples.Server.Host.csproj @@ -36,6 +36,7 @@ + diff --git a/src/samples/server/Elsa.Samples.Server.Host/Startup.cs b/src/samples/server/Elsa.Samples.Server.Host/Startup.cs index 97d2b128f..7f558f0e0 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/Startup.cs +++ b/src/samples/server/Elsa.Samples.Server.Host/Startup.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Elsa.Retention.Extensions; using Elsa.Server.Hangfire.Extensions; using Hangfire; using Microsoft.AspNetCore.Builder; @@ -78,7 +79,8 @@ namespace Elsa.Samples.Server.Host .AddWorkflowsFrom() .AddFeatures(startups, Configuration) .ConfigureWorkflowChannels(options => elsaSection.GetSection("WorkflowChannels").Bind(options)) - ); + ) + .AddRetentionServices(options => elsaSection.GetSection("Retention").Bind(options)); // Elsa API endpoints. services diff --git a/src/samples/server/Elsa.Samples.Server.Host/appsettings.json b/src/samples/server/Elsa.Samples.Server.Host/appsettings.json index 1242e2d91..f49981c47 100644 --- a/src/samples/server/Elsa.Samples.Server.Host/appsettings.json +++ b/src/samples/server/Elsa.Samples.Server.Host/appsettings.json @@ -62,6 +62,11 @@ "Port": "2525", "DefaultSender": "noreply@acme.com" }, + "Retention": { + "SweepInterval": "0:00:00:10", + "TimeToLive": "0:00:10:00", + "PageSize": "10" + }, "Conductor": { "CommandsHookUrl": "https://localhost:16001/elsa-hook/commands", "TasksHookUrl": "https://localhost:16001/elsa-hook/tasks",