Update Hangfire integration (#3898)

* Add Hangfire support for workflow scheduler

* Fix scheduled job cleanup

* Fix duplicate handler invocation

* Default scheduler fixes
This commit is contained in:
Sipke Schoorstra 2023-04-12 15:10:44 +02:00 committed by GitHub
parent 56b284c230
commit 83449f0508
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 822 additions and 61 deletions

View file

@ -170,6 +170,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Api.Client", "src\clie
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.QuartzIntegration", "src\samples\aspnet\Elsa.Samples.QuartzIntegration\Elsa.Samples.QuartzIntegration.csproj", "{B0312D9E-FA30-43E9-B666-40A8782D6E1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.HangfireIntegration", "src\samples\aspnet\Elsa.Samples.HangfireIntegration\Elsa.Samples.HangfireIntegration.csproj", "{D2614FC7-102F-4F78-BB06-7C87304A10BA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -420,6 +422,10 @@ Global
{B0312D9E-FA30-43E9-B666-40A8782D6E1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0312D9E-FA30-43E9-B666-40A8782D6E1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0312D9E-FA30-43E9-B666-40A8782D6E1C}.Release|Any CPU.Build.0 = Release|Any CPU
{D2614FC7-102F-4F78-BB06-7C87304A10BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2614FC7-102F-4F78-BB06-7C87304A10BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2614FC7-102F-4F78-BB06-7C87304A10BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2614FC7-102F-4F78-BB06-7C87304A10BA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{155227F0-A33B-40AA-A4B4-06F813EB921B} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F}
@ -494,5 +500,6 @@ Global
{89608AA5-5ADE-4832-AC7B-871C4AE64210} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F}
{1FCB2200-28B8-4703-8E89-73241AAED047} = {89608AA5-5ADE-4832-AC7B-871C4AE64210}
{B0312D9E-FA30-43E9-B666-40A8782D6E1C} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5}
{D2614FC7-102F-4F78-BB06-7C87304A10BA} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5}
EndGlobalSection
EndGlobal

View file

@ -3,7 +3,7 @@ import {Moment} from "moment";
export interface NotificationType {
id?: number | any;
title: string;
text: string | JSX.Element;
text: string | any;
type?: NotificationDisplayType;
timestamp?: Moment;
showToast?: boolean;

View file

@ -14,10 +14,12 @@
<ItemGroup>
<PackageReference Include="Hangfire" Version="1.7.33" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
<ProjectReference Include="..\Elsa.Scheduling\Elsa.Scheduling.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,32 @@
using Hangfire;
using Hangfire.Storage.Monitoring;
namespace Elsa.Hangfire.Extensions;
/// <summary>
/// A set of extension methods for <see cref="JobStorage"/>.
/// </summary>
public static class JobStorageExtensions
{
/// <summary>
/// Enumerates all scheduled jobs of a given type.
/// </summary>
public static IEnumerable<KeyValuePair<string, ScheduledJobDto>> EnumerateScheduledJobs<TJob>(this JobStorage storage, string name)
{
var api = storage.GetMonitoringApi();
var skip = 0;
const int take = 100;
JobList<ScheduledJobDto> jobList;
do
{
jobList = api.ScheduledJobs(skip, take);
var jobs = jobList.FindAll(x => x.Value.Job.Type == typeof(TJob));
foreach (var job in jobs.Where(x => (string)x.Value.Job.Args[0] == name))
yield return job;
skip += take;
} while (jobList.Count == take);
}
}

View file

@ -0,0 +1,61 @@
using Elsa.Features.Services;
using Elsa.Hangfire.Features;
using Elsa.Hangfire.Services;
using Elsa.Scheduling.Contracts;
using Elsa.Scheduling.Features;
using Elsa.Workflows.Runtime.Features;
using JetBrains.Annotations;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods for the <see cref="HangfireFeature"/>.
/// </summary>
[PublicAPI]
public static class ModuleExtensions
{
/// <summary>
/// Installs and configures Hangfire. Only use this feature if you are not configuring Hangfire yourself.
/// </summary>
public static IModule UseHangfire(this IModule module, Action<HangfireFeature>? configure = default)
{
return module.Use(configure);
}
/// <summary>
/// Configures Hangfire to use SQL Server storage. Only use this feature if you are not configuring Hangfire yourself.
/// </summary>
public static HangfireFeature UseSqlServerStorage(this HangfireFeature feature, Action<HangfireSqlServerStorageFeature> configure)
{
feature.Module.Use(configure);
return feature;
}
/// <summary>
/// Configures Hangfire to use SQLite storage. Only use this feature if you are not configuring Hangfire yourself.
/// </summary>
public static HangfireFeature UseSqliteStorage(this HangfireFeature feature, Action<HangfireSqliteStorageFeature> configure)
{
feature.Module.Use(configure);
return feature;
}
/// <summary>
/// Installs a Hangfire implementation for <see cref="IWorkflowScheduler"/>.
/// </summary>
public static SchedulingFeature UseHangfireScheduler(this SchedulingFeature feature, Action<HangfireSchedulerFeature>? configure = default)
{
feature.Module.Use(configure);
return feature;
}
/// <summary>
/// Installs a Hangfire implementation for <see cref="IWorkflowScheduler"/>.
/// </summary>
public static WorkflowRuntimeFeature UseHangfireBackgroundActivityScheduler(this WorkflowRuntimeFeature feature, Action<HangfireBackgroundActivitySchedulerFeature>? configure = default)
{
feature.Module.Use(configure);
return feature;
}
}

View file

@ -0,0 +1,23 @@
namespace Elsa.Hangfire.Extensions;
/// <summary>
/// Adds extension methods to <see cref="TimeSpan"/> that converts it to a cron expression.
/// </summary>
public static class TimeSpanExtensions
{
/// <summary>
/// Converts the specified time span to a cron expression.
/// </summary>
/// <param name="timeSpan">The time span.</param>
/// <returns>The cron expression.</returns>
public static string ToCronExpression(this TimeSpan timeSpan)
{
static string CreateCronComponent(int number) => (number > 0 ? $"*/{number}" : "*");
var cron = CreateCronComponent(timeSpan.Seconds);
cron += ' ' + CreateCronComponent(timeSpan.Minutes);
cron += ' ' + CreateCronComponent(timeSpan.Hours);
cron += ' ' + CreateCronComponent(timeSpan.Days);
return cron + " * *";
}
}

View file

@ -0,0 +1,38 @@
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Hangfire.Services;
using Elsa.Scheduling.Contracts;
using Elsa.Scheduling.Features;
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Features;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Hangfire.Features;
/// <summary>
/// Installs a Hangfire implementation for <see cref="IBackgroundActivityScheduler"/>.
/// </summary>
[DependsOn(typeof(WorkflowRuntimeFeature))]
public class HangfireBackgroundActivitySchedulerFeature : FeatureBase
{
/// <inheritdoc />
public HangfireBackgroundActivitySchedulerFeature(IModule module) : base(module)
{
}
/// <inheritdoc />
public override void Configure()
{
Module.Configure<WorkflowRuntimeFeature>(workflowRuntimeFeature =>
{
workflowRuntimeFeature.BackgroundActivityInvoker = sp => sp.GetRequiredService<HangfireBackgroundActivityScheduler>();
});
}
/// <inheritdoc />
public override void Apply()
{
Services.AddSingleton<HangfireBackgroundActivityScheduler>();
}
}

View file

@ -2,13 +2,12 @@ using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Hangfire;
using Hangfire.MemoryStorage;
using Hangfire.SqlServer;
using Newtonsoft.Json;
namespace Elsa.Hangfire.Features;
/// <summary>
/// Sets up Hangfire.
/// Sets up Hangfire. If you're setting up Hangfire yourself, then you should not enable this feature.
/// </summary>
public class HangfireFeature : FeatureBase
{
@ -18,47 +17,39 @@ public class HangfireFeature : FeatureBase
}
/// <summary>
/// Whether to use SQL Server storage.
/// A delegate that configures Hangfire.
/// </summary>
public bool UseSqlServerStorage { get; set; }
public Action<IServiceProvider, IGlobalConfiguration> ConfigureHangfire { get; set; } = (_, cfg) => cfg.UseMemoryStorage();
/// <summary>
/// A delegate that configures Hangfire's background job server options.
/// </summary>
public Action<IServiceProvider, BackgroundJobServerOptions> ConfigureBackgroundServerOptions { get; set; } = (_, _) => { };
/// <summary>
/// The SQL Server storage options.
/// A delegate that creates a job storage instance.
/// </summary>
public SqlServerStorageOptions? SqlServerStorageOptions { get; set; }
/// <summary>
/// The SQL Server connection string.
/// </summary>
public string? SqlServerConnectionString { get; set; }
/// <summary>
/// The Hangfire background server options.
/// </summary>
public Action<BackgroundJobServerOptions>? ConfigureBackgroundServerOptions { get; set; }
public Func<JobStorage> CreateJobStorage { get; set; } = () => new MemoryStorage();
/// <inheritdoc />
public override void Configure()
public override void Apply()
{
Services.AddHangfire(configuration =>
Action<IServiceProvider, IGlobalConfiguration> configAction = (sp, cfg) =>
{
configuration.UseSimpleAssemblyNameTypeSerializer();
configuration.UseRecommendedSerializerSettings(json => json.TypeNameHandling = TypeNameHandling.Objects);
if (UseSqlServerStorage)
{
var storageOptions = SqlServerStorageOptions ?? new SqlServerStorageOptions();
configuration.UseSqlServerStorage(SqlServerConnectionString, storageOptions);
}
else
{
configuration.UseMemoryStorage();
}
});
if (UseSqlServerStorage)
Services.AddHangfireServer((_, options) => ConfigureBackgroundServerOptions?.Invoke(options), new SqlServerStorage(SqlServerConnectionString));
else
Services.AddHangfireServer(options => { ConfigureBackgroundServerOptions?.Invoke(options); });
cfg.UseSimpleAssemblyNameTypeSerializer();
cfg.UseRecommendedSerializerSettings(json => json.TypeNameHandling = TypeNameHandling.Objects);
};
Action<IServiceProvider, BackgroundJobServerOptions> serverOptionsAction = (sp, options) =>
{
options.WorkerCount = 1;
options.SchedulePollingInterval = TimeSpan.FromSeconds(1);
};
configAction += ConfigureHangfire;
serverOptionsAction += ConfigureBackgroundServerOptions;
Services.AddHangfire(configAction);
Services.AddHangfireServer(serverOptionsAction, CreateJobStorage());
}
}

View file

@ -0,0 +1,33 @@
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Hangfire.Services;
using Elsa.Scheduling.Contracts;
using Elsa.Scheduling.Features;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Hangfire.Features;
/// <summary>
/// Installs a Hangfire implementation for <see cref="IWorkflowScheduler"/>.
/// </summary>
[DependsOn(typeof(SchedulingFeature))]
public class HangfireSchedulerFeature : FeatureBase
{
/// <inheritdoc />
public HangfireSchedulerFeature(IModule module) : base(module)
{
}
/// <inheritdoc />
public override void Configure()
{
Module.Configure<SchedulingFeature>(schedulingFeature => { schedulingFeature.WorkflowScheduler = sp => sp.GetRequiredService<HangfireWorkflowScheduler>(); });
}
/// <inheritdoc />
public override void Apply()
{
Services.AddSingleton<HangfireWorkflowScheduler>();
}
}

View file

@ -0,0 +1,53 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Hangfire;
using Hangfire.SqlServer;
namespace Elsa.Hangfire.Features;
/// <summary>
/// Configures the Hangfire feature to use SQL Server storage. If you're setting up Hangfire yourself, then you should not enable this feature.
/// </summary>
[DependsOn(typeof(HangfireFeature))]
public class HangfireSqlServerStorageFeature : FeatureBase
{
/// <inheritdoc />
public HangfireSqlServerStorageFeature(IModule module) : base(module)
{
}
/// <summary>
/// The connection string to use when connecting to SQL Server, or the name of the connection string.
/// </summary>
public string NameOrConnectionString { get; set; } = default!;
/// <summary>
/// Configures the SQL Server storage options.
/// </summary>
public Action<SqlServerStorageOptions> ConfigureSqlServerStorageOptions { get; set; } = _ => { };
/// <inheritdoc />
public override void Configure()
{
Module.Use<HangfireFeature>(hangfireFeature =>
{
var storageOptions = new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.FromSeconds(15),
UseRecommendedIsolationLevel = true
};
ConfigureSqlServerStorageOptions(storageOptions);
hangfireFeature.ConfigureHangfire = (_, cfg) =>
{
cfg.UseSqlServerStorage(NameOrConnectionString, storageOptions);
};
hangfireFeature.CreateJobStorage = () => new SqlServerStorage(NameOrConnectionString, storageOptions);
});
}
}

View file

@ -0,0 +1,51 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Hangfire;
using Hangfire.SqlServer;
using Hangfire.Storage.SQLite;
namespace Elsa.Hangfire.Features;
/// <summary>
/// Configures the Hangfire feature to use SQLite storage. If you're setting up Hangfire yourself, then you should not enable this feature.
/// </summary>
[DependsOn(typeof(HangfireFeature))]
public class HangfireSqliteStorageFeature : FeatureBase
{
/// <inheritdoc />
public HangfireSqliteStorageFeature(IModule module) : base(module)
{
}
/// <summary>
/// The connection string to use when connecting to SQL Server, or the name of the connection string.
/// </summary>
public string NameOrConnectionString { get; set; } = default!;
/// <summary>
/// Configures the SQL Server storage options.
/// </summary>
public Action<SQLiteStorageOptions> ConfigureSqlServerStorageOptions { get; set; } = _ => { };
/// <inheritdoc />
public override void Configure()
{
Module.Use<HangfireFeature>(hangfireFeature =>
{
var storageOptions = new SQLiteStorageOptions
{
QueuePollInterval = TimeSpan.FromSeconds(1)
};
ConfigureSqlServerStorageOptions(storageOptions);
hangfireFeature.ConfigureHangfire = (_, cfg) =>
{
cfg.UseSQLiteStorage(NameOrConnectionString, storageOptions);
};
hangfireFeature.CreateJobStorage = () => new SQLiteStorage(NameOrConnectionString, storageOptions);
});
}
}

View file

@ -0,0 +1,29 @@
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models;
namespace Elsa.Hangfire.Jobs;
/// <summary>
/// A job that executes a background activity.
/// </summary>
public class ExecuteBackgroundActivityJob
{
private readonly IBackgroundActivityInvoker _backgroundActivityInvoker;
/// <summary>
/// Initializes a new instance of the <see cref="ExecuteBackgroundActivityJob"/> class.
/// </summary>
/// <param name="backgroundActivityInvoker"></param>
public ExecuteBackgroundActivityJob(IBackgroundActivityInvoker backgroundActivityInvoker)
{
_backgroundActivityInvoker = backgroundActivityInvoker;
}
/// <summary>
/// Executes the job.
/// </summary>
public async Task ExecuteAsync(ScheduledBackgroundActivity scheduledBackgroundActivity, CancellationToken cancellationToken = default)
{
await _backgroundActivityInvoker.ExecuteAsync(scheduledBackgroundActivity, cancellationToken);
}
}

View file

@ -0,0 +1,28 @@
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models.Requests;
namespace Elsa.Hangfire.Jobs;
/// <summary>
/// A job that resumes a workflow.
/// </summary>
public class ResumeWorkflowJob
{
private readonly IWorkflowDispatcher _workflowDispatcher;
/// <summary>
/// Initializes a new instance of the <see cref="ResumeWorkflowJob"/> class.
/// </summary>
public ResumeWorkflowJob(IWorkflowDispatcher workflowDispatcher)
{
_workflowDispatcher = workflowDispatcher;
}
/// <summary>
/// Executes the job.
/// </summary>
/// <param name="name">The name of the job.</param>
/// <param name="request">The workflow request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task ExecuteAsync(string name, DispatchWorkflowInstanceRequest request, CancellationToken cancellationToken) => await _workflowDispatcher.DispatchAsync(request, cancellationToken);
}

View file

@ -0,0 +1,28 @@
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models.Requests;
namespace Elsa.Hangfire.Jobs;
/// <summary>
/// A job that resumes a workflow.
/// </summary>
public class RunWorkflowJob
{
private readonly IWorkflowDispatcher _workflowDispatcher;
/// <summary>
/// Initializes a new instance of the <see cref="RunWorkflowJob"/> class.
/// </summary>
public RunWorkflowJob(IWorkflowDispatcher workflowDispatcher)
{
_workflowDispatcher = workflowDispatcher;
}
/// <summary>
/// Executes the job.
/// </summary>
/// <param name="name">The name of the job.</param>
/// <param name="request">The workflow request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task ExecuteAsync(string name, DispatchWorkflowDefinitionRequest request, CancellationToken cancellationToken) => await _workflowDispatcher.DispatchAsync(request, cancellationToken);
}

View file

@ -0,0 +1,29 @@
using Elsa.Hangfire.Jobs;
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models;
using Hangfire;
namespace Elsa.Hangfire.Services;
/// <summary>
/// Invokes activities from a background worker within the context of its workflow instance using Hangfire.
/// </summary>
public class HangfireBackgroundActivityScheduler : IBackgroundActivityScheduler
{
private readonly IBackgroundJobClient _backgroundJobClient;
/// <summary>
/// Initializes a new instance of the <see cref="HangfireBackgroundActivityScheduler"/> class.
/// </summary>
public HangfireBackgroundActivityScheduler(IBackgroundJobClient backgroundJobClient)
{
_backgroundJobClient = backgroundJobClient;
}
/// <inheritdoc />
public Task<string> ScheduleAsync(ScheduledBackgroundActivity scheduledBackgroundActivity, CancellationToken cancellationToken = default)
{
var jobId = _backgroundJobClient.Enqueue<ExecuteBackgroundActivityJob>(x => x.ExecuteAsync(scheduledBackgroundActivity, CancellationToken.None));
return Task.FromResult(jobId);
}
}

View file

@ -0,0 +1,99 @@
using Elsa.Hangfire.Extensions;
using Elsa.Hangfire.Jobs;
using Elsa.Scheduling.Contracts;
using Elsa.Workflows.Runtime.Models.Requests;
using Hangfire;
using Hangfire.Storage;
namespace Elsa.Hangfire.Services;
/// <summary>
/// An implementation of <see cref="Scheduling.Contracts.IWorkflowScheduler"/> that uses Hangfire.
/// </summary>
public class HangfireWorkflowScheduler : IWorkflowScheduler
{
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly IRecurringJobManager _recurringJobManager;
private readonly JobStorage _jobStorage;
/// <summary>
/// Initializes a new instance of the <see cref="HangfireWorkflowScheduler"/> class.
/// </summary>
public HangfireWorkflowScheduler(IBackgroundJobClient backgroundJobClient, IRecurringJobManager recurringJobManager, JobStorage jobStorage)
{
_backgroundJobClient = backgroundJobClient;
_recurringJobManager = recurringJobManager;
_jobStorage = jobStorage;
}
/// <inheritdoc />
public ValueTask ScheduleAtAsync(string taskName, DispatchWorkflowDefinitionRequest request, DateTimeOffset at, CancellationToken cancellationToken = default)
{
_backgroundJobClient.Schedule<RunWorkflowJob>(job => job.ExecuteAsync(taskName, request, CancellationToken.None), at);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask ScheduleAtAsync(string taskName, DispatchWorkflowInstanceRequest request, DateTimeOffset at, CancellationToken cancellationToken = default)
{
_backgroundJobClient.Schedule<ResumeWorkflowJob>(job => job.ExecuteAsync(taskName, request, CancellationToken.None), at);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask ScheduleRecurringAsync(string taskName, DispatchWorkflowDefinitionRequest request, DateTimeOffset startAt, TimeSpan interval, CancellationToken cancellationToken = default)
{
await ScheduleCronAsync(taskName, request, interval.ToCronExpression(), cancellationToken);
}
/// <inheritdoc />
public async ValueTask ScheduleRecurringAsync(string taskName, DispatchWorkflowInstanceRequest request, DateTimeOffset startAt, TimeSpan interval, CancellationToken cancellationToken = default)
{
await ScheduleCronAsync(taskName, request, interval.ToCronExpression(), cancellationToken);
}
/// <inheritdoc />
public ValueTask ScheduleCronAsync(string taskName, DispatchWorkflowDefinitionRequest request, string cronExpression, CancellationToken cancellationToken = default)
{
_recurringJobManager.AddOrUpdate<RunWorkflowJob>(taskName, job => job.ExecuteAsync(taskName, request, CancellationToken.None), cronExpression);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask ScheduleCronAsync(string taskName, DispatchWorkflowInstanceRequest request, string cronExpression, CancellationToken cancellationToken = default)
{
_recurringJobManager.AddOrUpdate<ResumeWorkflowJob>(taskName, job => job.ExecuteAsync(taskName, request, CancellationToken.None), cronExpression);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask UnscheduleAsync(string taskName, CancellationToken cancellationToken = default)
{
DeleteJobByTaskName(taskName);
return ValueTask.CompletedTask;
}
private void DeleteJobByTaskName(string taskName)
{
var scheduledJobIds = GetScheduledJobIds<RunWorkflowJob>(taskName);
foreach (var jobId in scheduledJobIds) _backgroundJobClient.Delete(jobId);
var recurringJobIds = GetRecurringJobIds<RunWorkflowJob>(taskName);
foreach (var jobId in recurringJobIds) _recurringJobManager.RemoveIfExists(jobId);
}
private IEnumerable<string> GetScheduledJobIds<TJob>(string taskName)
{
return _jobStorage.EnumerateScheduledJobs<TJob>(taskName)
.Select(x => x.Key)
.Distinct()
.ToList();
}
private IEnumerable<string> GetRecurringJobIds<TJob>(string taskName)
{
using var connection = _jobStorage.GetConnection();
var jobs = connection.GetRecurringJobs().Where(x => x.Job.Type == typeof(TJob) && (string)x.Job.Args[0] == taskName);
return jobs.Select(x => x.Id).Distinct().ToList();
}
}

View file

@ -29,7 +29,7 @@ public class DispatchWorkflowRequestConsumer :
var message = context.Message;
var options = new StartWorkflowRuntimeOptions(message.CorrelationId, message.Input, message.VersionOptions, InstanceId: message.InstanceId);
await _workflowRuntime.StartWorkflowAsync(message.DefinitionId, options, context.CancellationToken);
await _workflowRuntime.TryStartWorkflowAsync(message.DefinitionId, options, context.CancellationToken);
}
/// <inheritdoc />

View file

@ -20,7 +20,7 @@ public class NotificationHandlerInvokerMiddleware : INotificationMiddleware
var notification = context.Notification;
var notificationType = notification.GetType();
var handlerType = typeof(INotificationHandler<>).MakeGenericType(notificationType);
var handlers = _notificationHandlers.Where(x => handlerType.IsInstanceOfType(x)).ToArray();
var handlers = _notificationHandlers.Where(x => handlerType.IsInstanceOfType(x)).DistinctBy(x => x.GetType()).ToArray();
var handleMethod = handlerType.GetMethod("HandleAsync")!;
var cancellationToken = context.CancellationToken;

View file

@ -22,6 +22,7 @@ public class ProtoActorWorkflowRuntime : IWorkflowRuntime
private readonly ITriggerStore _triggerStore;
private readonly IIdentityGenerator _identityGenerator;
private readonly IBookmarkHasher _hasher;
private readonly IWorkflowDefinitionService _workflowDefinitionService;
private readonly IWorkflowInstanceFactory _workflowInstanceFactory;
/// <summary>
@ -33,6 +34,7 @@ public class ProtoActorWorkflowRuntime : IWorkflowRuntime
ITriggerStore triggerStore,
IIdentityGenerator identityGenerator,
IBookmarkHasher hasher,
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowInstanceFactory workflowInstanceFactory)
{
_cluster = cluster;
@ -40,6 +42,7 @@ public class ProtoActorWorkflowRuntime : IWorkflowRuntime
_triggerStore = triggerStore;
_identityGenerator = identityGenerator;
_hasher = hasher;
_workflowDefinitionService = workflowDefinitionService;
_workflowInstanceFactory = workflowInstanceFactory;
}
@ -67,6 +70,18 @@ public class ProtoActorWorkflowRuntime : IWorkflowRuntime
return new CanStartWorkflowResult(workflowInstanceId, response!.CanStart);
}
/// <inheritdoc />
public async Task<WorkflowExecutionResult?> TryStartWorkflowAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
// Load the workflow definition.
var workflowDefinition = await _workflowDefinitionService.FindAsync(definitionId, options.VersionOptions, cancellationToken);
if (workflowDefinition == null)
return null;
return await StartWorkflowAsync(definitionId, options, cancellationToken);
}
/// <inheritdoc />
public async Task<WorkflowExecutionResult> StartWorkflowAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
@ -177,7 +192,7 @@ public class ProtoActorWorkflowRuntime : IWorkflowRuntime
var collectedResumableWorkflow = (match as ResumableWorkflowMatch)!;
var runtimeOptions = new ResumeWorkflowRuntimeOptions(collectedResumableWorkflow.CorrelationId, Input: input);
var resumeResult = await ResumeWorkflowAsync(
match.WorkflowInstanceId,
runtimeOptions with { BookmarkId = collectedResumableWorkflow.BookmarkId },

View file

@ -33,4 +33,13 @@ public static class JobDataMapExtensions
return map;
}
/// <summary>
/// Gets a dictionary from the map.
/// </summary>
public static IDictionary<string, object>? GetDictionary(this JobDataMap map, string key)
{
var json = (string?)map.Get(key);
return json == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(json);
}
}

View file

@ -7,7 +7,7 @@ using Elsa.Scheduling.Features;
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods for <see cref="SchedulingFeature"/>.
/// Provides extension methods for the <see cref="SchedulingFeature"/>.
/// </summary>
public static class ModuleExtensions
{

View file

@ -1,3 +1,4 @@
using Elsa.Extensions;
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models.Requests;
using Quartz;
@ -35,7 +36,8 @@ public class ResumeWorkflowJob : IJob
ActivityId = (string?)map.Get(nameof(DispatchWorkflowInstanceRequest.ActivityId)),
ActivityNodeId = (string?)map.Get(nameof(DispatchWorkflowInstanceRequest.ActivityNodeId)),
ActivityInstanceId = (string?)map.Get(nameof(DispatchWorkflowInstanceRequest.ActivityInstanceId)),
CorrelationId = (string?)map.Get(nameof(DispatchWorkflowInstanceRequest.CorrelationId))
CorrelationId = (string?)map.Get(nameof(DispatchWorkflowInstanceRequest.CorrelationId)),
Input = map.GetDictionary(nameof(DispatchWorkflowInstanceRequest.Input))
};
await _workflowDispatcher.DispatchAsync(request, context.CancellationToken);
}

View file

@ -1,4 +1,5 @@
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models.Requests;
using Quartz;
@ -40,7 +41,8 @@ public class RunWorkflowJob : IJob
VersionOptions = VersionOptions.FromString((string)map.Get(nameof(DispatchWorkflowDefinitionRequest.VersionOptions))),
TriggerActivityId = (string?)map.Get(nameof(DispatchWorkflowDefinitionRequest.TriggerActivityId)),
InstanceId = (string?)map.Get(nameof(DispatchWorkflowDefinitionRequest.InstanceId)),
CorrelationId = (string?)map.Get(nameof(DispatchWorkflowDefinitionRequest.CorrelationId))
CorrelationId = (string?)map.Get(nameof(DispatchWorkflowDefinitionRequest.CorrelationId)),
Input = map.GetDictionary(nameof(DispatchWorkflowDefinitionRequest.Input))
};
await _workflowDispatcher.DispatchAsync(request, context.CancellationToken);
}

View file

@ -33,6 +33,7 @@ public class SchedulingFeature : FeatureBase
.AddSingleton<ITriggerScheduler, DefaultTriggerScheduler>()
.AddSingleton<IBookmarkScheduler, DefaultBookmarkScheduler>()
.AddSingleton<IScheduler, LocalScheduler>()
.AddSingleton<DefaultWorkflowScheduler>()
.AddSingleton(WorkflowScheduler)
.AddHandlersFrom<ScheduleWorkflows>();

View file

@ -48,28 +48,36 @@ public class ScheduledRecurringTask : IScheduledTask
private void Schedule()
{
var startAt = _startAt;
var adjusted = false;
while (true)
{
var now = _systemClock.UtcNow;
var delay = _startAt - now;
var delay = startAt - now;
if (delay.Milliseconds <= 0)
if (!adjusted && delay.Milliseconds <= 0)
{
adjusted = true;
continue;
}
SetupTimer(delay, now);
SetupTimer(delay);
break;
}
}
private void SetupTimer(TimeSpan delay, DateTimeOffset now)
private void SetupTimer(TimeSpan delay)
{
if(delay < TimeSpan.Zero) delay = TimeSpan.FromSeconds(1);
_timer = new Timer(delay.TotalMilliseconds) { Enabled = true };
_timer.Elapsed += async (_, _) =>
{
_timer.Dispose();
_timer = null;
_startAt = now + _interval;
_startAt = _systemClock.UtcNow + _interval;
var cancellationToken = _cancellationTokenSource.Token;
if (!cancellationToken.IsCancellationRequested) await _commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);

View file

@ -77,7 +77,7 @@ public class DefaultTriggerScheduler : ITriggerScheduler
var timerTriggers = triggerList.Filter<Activities.Timer>().ToList();
// Select all StartAt triggers.
var startAtTriggers = triggerList.Filter<Activities.Timer>().ToList();
var startAtTriggers = triggerList.Filter<StartAt>().ToList();
// Concatenate the filtered triggers.
var filteredTriggers = timerTriggers.Concat(startAtTriggers).ToList();

View file

@ -34,6 +34,11 @@ public interface IWorkflowRuntime
TriggerWorkflowsRuntimeOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Tries to start a workflow and returns the result if successful.
/// </summary>
Task<WorkflowExecutionResult?> TryStartWorkflowAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Resumes an existing workflow instance.
/// </summary>

View file

@ -31,7 +31,7 @@ internal class DispatchWorkflowRequestHandler :
{
var options = new StartWorkflowRuntimeOptions(command.CorrelationId, command.Input, command.VersionOptions, InstanceId: command.InstanceId, TriggerActivityId: command.TriggerActivityId);
await _workflowRuntime.StartWorkflowAsync(command.DefinitionId, options, cancellationToken);
await _workflowRuntime.TryStartWorkflowAsync(command.DefinitionId, options, cancellationToken);
return Unit.Instance;
}

View file

@ -2,9 +2,11 @@ using Elsa.Common.Models;
using Elsa.Workflows.Core.Contracts;
using Elsa.Workflows.Core.Models;
using Elsa.Workflows.Core.State;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Runtime.Contracts;
using Elsa.Workflows.Runtime.Models;
using Medallion.Threading;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Runtime.Services;
@ -21,6 +23,7 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
private readonly IBookmarkHasher _hasher;
private readonly IDistributedLockProvider _distributedLockProvider;
private readonly IWorkflowInstanceFactory _workflowInstanceFactory;
private readonly ILogger _logger;
/// <summary>
/// Constructor.
@ -33,7 +36,8 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
IBookmarkStore bookmarkStore,
IBookmarkHasher hasher,
IDistributedLockProvider distributedLockProvider,
IWorkflowInstanceFactory workflowInstanceFactory)
IWorkflowInstanceFactory workflowInstanceFactory,
ILogger<DefaultWorkflowRuntime> logger)
{
_workflowHostFactory = workflowHostFactory;
_workflowDefinitionService = workflowDefinitionService;
@ -43,6 +47,7 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
_hasher = hasher;
_distributedLockProvider = distributedLockProvider;
_workflowInstanceFactory = workflowInstanceFactory;
_logger = logger;
}
/// <inheritdoc />
@ -59,16 +64,19 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
/// <inheritdoc />
public async Task<WorkflowExecutionResult> StartWorkflowAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
var input = options.Input;
var correlationId = options.CorrelationId;
var workflowHost = await CreateWorkflowHostAsync(definitionId, options, cancellationToken);
var startWorkflowOptions = new StartWorkflowHostOptions(options.InstanceId, correlationId, input, options.TriggerActivityId);
await workflowHost.StartWorkflowAsync(startWorkflowOptions, cancellationToken);
var workflowState = workflowHost.WorkflowState;
return await StartWorkflowAsync(workflowHost, options, cancellationToken);
}
await SaveWorkflowStateAsync(workflowState, cancellationToken);
/// <inheritdoc />
public async Task<WorkflowExecutionResult?> TryStartWorkflowAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
var workflowDefinition = await FindWorkflowDefinitionAsync(definitionId, options.VersionOptions, cancellationToken);
return new WorkflowExecutionResult(workflowState.Id, workflowState.Bookmarks);
if (workflowDefinition == null)
return null;
return await StartWorkflowAsync(workflowDefinition, options, cancellationToken);
}
/// <inheritdoc />
@ -125,7 +133,10 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
cancellationToken);
if (workflowDefinition == null)
throw new Exception("Specified workflow definition and version does not exist");
{
_logger.LogInformation("The workflow definition {DefinitionId} version {Version} was not found", definitionId, version);
return new ResumeWorkflowResult(Array.Empty<Bookmark>());
}
var workflow = await _workflowDefinitionService.MaterializeWorkflowAsync(workflowDefinition, cancellationToken);
var workflowHost = await _workflowHostFactory.CreateAsync(workflow, workflowState, cancellationToken);
@ -215,14 +226,43 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime
/// <inheritdoc />
public async Task<int> CountRunningWorkflowsAsync(CountRunningWorkflowsArgs args, CancellationToken cancellationToken = default) => await _workflowStateStore.CountAsync(args, cancellationToken);
private async Task<WorkflowExecutionResult> StartWorkflowAsync(WorkflowDefinition workflowDefinition, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
var workflowHost = await CreateWorkflowHostAsync(workflowDefinition, cancellationToken);
return await StartWorkflowAsync(workflowHost, options, cancellationToken);
}
private async Task<WorkflowExecutionResult> StartWorkflowAsync(IWorkflowHost workflowHost, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken = default)
{
var input = options.Input;
var correlationId = options.CorrelationId;
var startWorkflowOptions = new StartWorkflowHostOptions(options.InstanceId, correlationId, input, options.TriggerActivityId);
await workflowHost.StartWorkflowAsync(startWorkflowOptions, cancellationToken);
var workflowState = workflowHost.WorkflowState;
await SaveWorkflowStateAsync(workflowState, cancellationToken);
return new WorkflowExecutionResult(workflowState.Id, workflowState.Bookmarks);
}
private async Task<WorkflowDefinition?> FindWorkflowDefinitionAsync(string definitionId, VersionOptions versionOptions, CancellationToken cancellationToken)
{
return await _workflowDefinitionService.FindAsync(definitionId, versionOptions, cancellationToken);
}
private async Task<IWorkflowHost> CreateWorkflowHostAsync(string definitionId, StartWorkflowRuntimeOptions options, CancellationToken cancellationToken)
{
var versionOptions = options.VersionOptions;
var workflowDefinition = await _workflowDefinitionService.FindAsync(definitionId, versionOptions, cancellationToken);
var workflowDefinition = await FindWorkflowDefinitionAsync(definitionId, versionOptions, cancellationToken);
if (workflowDefinition == null)
throw new Exception("Specified workflow definition and version does not exist");
return await CreateWorkflowHostAsync(workflowDefinition, cancellationToken);
}
private async Task<IWorkflowHost> CreateWorkflowHostAsync(WorkflowDefinition workflowDefinition, CancellationToken cancellationToken)
{
var workflow = await _workflowDefinitionService.MaterializeWorkflowAsync(workflowDefinition, cancellationToken);
return await _workflowHostFactory.CreateAsync(workflow, cancellationToken);
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\bundles\Elsa\Elsa.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.EntityFrameworkCore.Sqlite\Elsa.EntityFrameworkCore.Sqlite.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.Hangfire\Elsa.Hangfire.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.Http\Elsa.Http.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.Identity\Elsa.Identity.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.Scheduling\Elsa.Scheduling.csproj" />
<ProjectReference Include="..\..\..\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,69 @@
using Elsa.EntityFrameworkCore.Extensions;
using Elsa.EntityFrameworkCore.Modules.Management;
using Elsa.EntityFrameworkCore.Modules.Runtime;
using Elsa.Extensions;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
var identitySection = configuration.GetSection("Identity");
var identityTokenSection = identitySection.GetSection("Tokens");
// Add Elsa to the container.
builder.Services.AddElsa(elsa =>
{
// Configure management feature to use EF Core.
elsa.UseWorkflowManagement(management => management.UseEntityFrameworkCore(ef => ef.UseSqlite()));
elsa.UseWorkflowRuntime(runtime =>
{
runtime.UseDefaultRuntime(dr => dr.UseEntityFrameworkCore(ef => ef.UseSqlite()));
// Use Hangfire to schedule background activities.
runtime.UseHangfireBackgroundActivityScheduler();
// Capture execution log records.
runtime.UseExecutionLogRecords(e => e.UseEntityFrameworkCore(ef => ef.UseSqlite()));
// Capture workflow state.
runtime.UseAsyncWorkflowStateExporter();
});
// Expose API endpoints.
elsa.UseWorkflowsApi();
// Use Hangfire.
elsa.UseHangfire(hangfire => hangfire.UseSqliteStorage(sqlite => sqlite.NameOrConnectionString = "elsa.sqlite.db"));
// Use hangfire for scheduling timer events.
elsa.UseScheduling(scheduling => scheduling.UseHangfireScheduler());
// Configure identity.
elsa.UseIdentity(identity =>
{
identity.IdentityOptions = options => identitySection.Bind(options);
identity.TokenOptions = options => identityTokenSection.Bind(options);
identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options));
});
// Use default authentication (JWT).
elsa.UseDefaultAuthentication();
});
// Configure CORS to allow designer app hosted on a different origin to invoke the APIs.
builder.Services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// Build the web app.
var app = builder.Build();
// Configure the web app's request pipeline.
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseWorkflowsApi();
app.UseWorkflows();
// Run the web app.
app.Run();

View file

@ -0,0 +1,37 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:3978",
"sslPort": 44367
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5090",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7020;http://localhost:5090",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,6 @@
## Secrets
The following are the secrets stored in hashed form in appsettings.json:
**API key**: `4E753976726458745954355043687772-e54d5a2c-33a3-4c05-a216-b09569062aed`
**Admin user**: `admin`
**Admin password**: `password`

View file

@ -0,0 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.Hosting": "Information",
"Hangfire": "Warning"
}
},
"AllowedHosts": "*",
"Identity": {
"Tokens": {
"SigningKey": "secret-signing-key",
"AccessTokenLifetime": "1:00:00:00",
"RefreshTokenLifetime": "1:00:10:00"
},
"Roles": [{
"Id": "admin",
"Name": "Administrator",
"Permissions": ["*"]
}],
"Users": [
{
"Id": "a2323f46-42db-4e15-af8b-94238717d817",
"Name": "admin",
"HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=",
"HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=",
"Roles": ["admin"]
}
],
"Applications": [{
"id": "529572c2df854b13807b8bf23f1784cd",
"name": "Postman",
"roles": [
"admin"
],
"clientId": "Nu9vrdXtYT5PChwr",
"clientSecret": "011pp2C$|j01-qrMZpC9VC0F00XCJq(5",
"hashedApiKey": "d0rDld3A+ugKmdctGtMzOLTYjQFkOlUWN+kt0VyW9D0=",
"hashedApiKeySalt": "EnutGOyy5MuJWV0fF5jCQiciK7a8PU/DRF+fr6nekSY=",
"hashedClientSecret": "ERia2zBcCSWb/9dvB0grQ9yf7fWgFrClNeR8A5RMTzk=",
"hashedClientSecretSalt": "z3z8KmzHt+xkAj/zYTXcB8I7y0xAkLm95v4Er/oNqiY="
}]
}
}