diff --git a/src/modules/Elsa.Modules.Scheduling/Activities/RunAt.cs b/src/modules/Elsa.Modules.Scheduling/Activities/RunAt.cs deleted file mode 100644 index 18fb99926..000000000 --- a/src/modules/Elsa.Modules.Scheduling/Activities/RunAt.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Threading.Tasks; -using Elsa.Attributes; -using Elsa.Models; -using Elsa.Services; -using Microsoft.Extensions.Logging; - -namespace Elsa.Modules.Scheduling.Activities; - -[Activity("Elsa", "Scheduling", "Delay execution for the specified amount of time.")] -public class RunAt : Activity -{ - public RunAt() - { - } - - public RunAt(Input dateTime) => DateTime = dateTime; - - public RunAt(Func dateTime) : this(new Input(dateTime)) - { - } - - public RunAt(Func> dateTime) : this(new Input(dateTime)) - { - } - - public RunAt(Func> dateTime) : this(new Input(dateTime)) - { - } - - public RunAt(Func dateTime) : this(new Input(dateTime)) - { - } - - public RunAt(DateTimeOffset dateTime) => DateTime = new Input(dateTime); - public RunAt(Variable dateTime) => DateTime = new Input(dateTime); - - [Input] public Input DateTime { get; set; } = default!; - - protected override void Execute(ActivityExecutionContext context) - { - // TODO: Update e.g. ScheduleWorkflows and other places to make sure this activity works correctly when suspending & resuming workflows. - - var executeAt = context.ExpressionExecutionContext.Get(DateTime); - var clock = context.ExpressionExecutionContext.GetRequiredService(); - var now = clock.UtcNow; - var logger = context.GetRequiredService>(); - - if (executeAt <= now) - { - logger.LogDebug("Scheduled trigger time lies in the past ('{Delta}'). Skipping scheduling", now - executeAt); - context.JournalData.Add("Executed At", now); - return; - } - - var payload = new RunAtPayload(executeAt); - - context.CreateBookmark(payload); - } - - public static RunAt From(DateTimeOffset value) => new(value); -} - -public record RunAtPayload(DateTimeOffset ResumeAt); \ No newline at end of file diff --git a/src/modules/Elsa.Modules.Scheduling/Activities/StartAt.cs b/src/modules/Elsa.Modules.Scheduling/Activities/StartAt.cs new file mode 100644 index 000000000..d66ee0fbc --- /dev/null +++ b/src/modules/Elsa.Modules.Scheduling/Activities/StartAt.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Elsa.Attributes; +using Elsa.Models; +using Elsa.Services; +using Microsoft.Extensions.Logging; + +namespace Elsa.Modules.Scheduling.Activities; + +[Activity("Elsa", "Scheduling", "Trigger execution at a specific time in the future.")] +public class StartAt : Trigger +{ + public StartAt() + { + } + + public StartAt(Input dateTime) => DateTime = dateTime; + + public StartAt(Func dateTime) : this(new Input(dateTime)) + { + } + + public StartAt(Func> dateTime) : this(new Input(dateTime)) + { + } + + public StartAt(Func> dateTime) : this(new Input(dateTime)) + { + } + + public StartAt(Func dateTime) : this(new Input(dateTime)) + { + } + + public StartAt(DateTimeOffset dateTime) => DateTime = new Input(dateTime); + public StartAt(Variable dateTime) => DateTime = new Input(dateTime); + + [Input] public Input DateTime { get; set; } = default!; + + protected override object GetTriggerDatum(TriggerIndexingContext context) + { + var executeAt = context.ExpressionExecutionContext.Get(DateTime); + return new StartAtPayload(executeAt); + } + + protected override void Execute(ActivityExecutionContext context) + { + var executeAt = context.ExpressionExecutionContext.Get(DateTime); + var clock = context.ExpressionExecutionContext.GetRequiredService(); + var now = clock.UtcNow; + var logger = context.GetRequiredService>(); + + if (executeAt <= now) + { + logger.LogDebug("Scheduled trigger time lies in the past ('{Delta}'). Skipping scheduling", now - executeAt); + context.JournalData.Add("Executed At", now); + return; + } + + var payload = new StartAtPayload(executeAt); + + context.CreateBookmark(payload); + } + + public static StartAt From(DateTimeOffset value) => new(value); +} + +public record StartAtPayload(DateTimeOffset ExecuteAt); \ No newline at end of file diff --git a/src/modules/Elsa.Modules.Scheduling/Activities/Timer.cs b/src/modules/Elsa.Modules.Scheduling/Activities/Timer.cs index 37423d014..162fc6363 100644 --- a/src/modules/Elsa.Modules.Scheduling/Activities/Timer.cs +++ b/src/modules/Elsa.Modules.Scheduling/Activities/Timer.cs @@ -29,12 +29,12 @@ public class Timer : EventGenerator [Input] public Input Interval { get; set; } = default!; - protected override IEnumerable GetTriggerData(TriggerIndexingContext context) + protected override object GetTriggerDatum(TriggerIndexingContext context) { var interval = context.ExpressionExecutionContext.Get(Interval); var clock = context.ExpressionExecutionContext.GetRequiredService(); var executeAt = clock.UtcNow.Add(interval); - yield return new TimerPayload(executeAt, interval); + return new TimerPayload(executeAt, interval); } public static Timer FromTimeSpan(TimeSpan value) => new(value); diff --git a/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowBookmarkScheduler.cs b/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowBookmarkScheduler.cs index dd79e0340..e4403add9 100644 --- a/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowBookmarkScheduler.cs +++ b/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowBookmarkScheduler.cs @@ -29,9 +29,13 @@ public class WorkflowBookmarkScheduler : IWorkflowBookmarkScheduler // Select all Delay bookmarks. var delayBookmarks = bookmarkList.Filter().ToList(); + + // Select all StartAt bookmarks. + var startAtBookmarks = bookmarkList.Filter().ToList(); + var groupKeys = new[] { RootGroupKey, workflowInstanceId }; - // Schedule a trigger for each bookmark. + // Schedule a trigger for each Delay bookmark. foreach (var bookmark in delayBookmarks) { var payload = JsonSerializer.Deserialize(bookmark.Data!)!; @@ -40,16 +44,26 @@ public class WorkflowBookmarkScheduler : IWorkflowBookmarkScheduler var schedule = new SpecificInstantSchedule(resumeAt); await _jobScheduler.ScheduleAsync(job, bookmark.Id, schedule, groupKeys, cancellationToken); } + + // Schedule a trigger for each StartAt bookmark. + foreach (var bookmark in startAtBookmarks) + { + var payload = JsonSerializer.Deserialize(bookmark.Data!)!; + var executeAt = payload.ExecuteAt; + var job = new ResumeWorkflowJob(workflowInstanceId, bookmark.ToBookmark()); + var schedule = new SpecificInstantSchedule(executeAt); + await _jobScheduler.ScheduleAsync(job, bookmark.Id, schedule, groupKeys, cancellationToken); + } } public async Task UnscheduleBookmarksAsync(string workflowInstanceId, IEnumerable bookmarks, CancellationToken cancellationToken = default) { var bookmarkList = bookmarks.ToList(); var delayBookmarks = bookmarkList.Filter().ToList(); + var startAtBookmarks = bookmarkList.Filter().ToList(); + var bookmarksToUnSchedule = delayBookmarks.Concat(startAtBookmarks).ToList(); - foreach (var bookmark in delayBookmarks) - { + foreach (var bookmark in bookmarksToUnSchedule) await _jobScheduler.UnscheduleAsync(bookmark.Id, cancellationToken); - } } } \ No newline at end of file diff --git a/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowTriggerScheduler.cs b/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowTriggerScheduler.cs index 016e89a24..64470086c 100644 --- a/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowTriggerScheduler.cs +++ b/src/modules/Elsa.Modules.Scheduling/Implementations/WorkflowTriggerScheduler.cs @@ -27,26 +27,45 @@ public class WorkflowTriggerScheduler : IWorkflowTriggerScheduler public async Task ScheduleTriggersAsync(IEnumerable triggers, CancellationToken cancellationToken = default) { - // Select all Timer triggers. - var timerTriggers = triggers.Filter().ToList(); + var triggerList = triggers.ToList(); - // Schedule each trigger. + // Select all Timer triggers. + var timerTriggers = triggerList.Filter().ToList(); + var startAtTriggers = triggerList.Filter().ToList(); + + // Schedule each Timer trigger. foreach (var trigger in timerTriggers) { - // Schedule trigger. var (dateTime, timeSpan) = JsonSerializer.Deserialize(trigger.Data!)!; var groupKeys = new[] { RootGroupKey, trigger.WorkflowDefinitionId }; await _jobScheduler.ScheduleAsync(new RunWorkflowJob(trigger.WorkflowDefinitionId), trigger.WorkflowDefinitionId, new RecurringSchedule(dateTime, timeSpan), groupKeys, cancellationToken); } + + // Schedule each StartAt trigger. + foreach (var trigger in startAtTriggers) + { + var executeAt = JsonSerializer.Deserialize(trigger.Data!)!.ExecuteAt; + var groupKeys = new[] { RootGroupKey, trigger.WorkflowDefinitionId }; + await _jobScheduler.ScheduleAsync(new RunWorkflowJob(trigger.WorkflowDefinitionId), trigger.WorkflowDefinitionId, new SpecificInstantSchedule(executeAt), groupKeys, cancellationToken); + } } public async Task UnscheduleTriggersAsync(IEnumerable triggers, CancellationToken cancellationToken = default) { + var triggerList = triggers.ToList(); + // Select all Timer triggers. - var timerTriggers = triggers.Filter().ToList(); + var timerTriggers = triggerList.Filter().ToList(); + + // Select all StartAt triggers. + var startAtTriggers = triggerList.Filter().ToList(); // Unschedule all triggers for the distinct set of affected workflows. - var workflowDefinitionIds = timerTriggers.Select(x => x.WorkflowDefinitionId).Distinct().ToList(); + var workflowDefinitionIds = timerTriggers + .Select(x => x.WorkflowDefinitionId) + .Concat(startAtTriggers.Select(x => x.WorkflowDefinitionId)) + .Distinct() + .ToList(); foreach (var workflowDefinitionId in workflowDefinitionIds) { diff --git a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.Designer.cs b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.Designer.cs similarity index 90% rename from src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.Designer.cs rename to src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.Designer.cs index 57db119b1..8bcbec616 100644 --- a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.Designer.cs +++ b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.Designer.cs @@ -11,13 +11,13 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations { [DbContext(typeof(ElsaDbContext))] - [Migration("20220309231832_Initial")] + [Migration("20220427095656_Initial")] partial class Initial { protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.3"); modelBuilder.Entity("Elsa.Persistence.Entities.WorkflowBookmark", b => { @@ -192,7 +192,6 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations .HasColumnType("TEXT"); b.Property("CorrelationId") - .IsRequired() .HasColumnType("TEXT"); b.Property("CreatedAt") @@ -221,10 +220,13 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations b.Property("Name") .HasColumnType("TEXT"); - b.Property("Version") + b.Property("Status") .HasColumnType("INTEGER"); - b.Property("WorkflowStatus") + b.Property("SubStatus") + .HasColumnType("INTEGER"); + + b.Property("Version") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -250,14 +252,23 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations b.HasIndex("Name") .HasDatabaseName("IX_WorkflowInstance_Name"); - b.HasIndex("WorkflowStatus") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus"); + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); - b.HasIndex("WorkflowStatus", "DefinitionId") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus_DefinitionId"); + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); - b.HasIndex("WorkflowStatus", "DefinitionId", "Version") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus_DefinitionId_Version"); + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); b.ToTable("WorkflowInstances"); }); diff --git a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.cs b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.cs similarity index 90% rename from src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.cs rename to src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.cs index b60bc9eb2..51925a53a 100644 --- a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220309231832_Initial.cs +++ b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/20220427095656_Initial.cs @@ -75,8 +75,9 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations DefinitionId = table.Column(type: "TEXT", nullable: false), DefinitionVersionId = table.Column(type: "TEXT", nullable: false), Version = table.Column(type: "INTEGER", nullable: false), - WorkflowStatus = table.Column(type: "INTEGER", nullable: false), - CorrelationId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + SubStatus = table.Column(type: "INTEGER", nullable: false), + CorrelationId = table.Column(type: "TEXT", nullable: true), Name = table.Column(type: "TEXT", nullable: true), CreatedAt = table.Column(type: "TEXT", nullable: false), LastExecutedAt = table.Column(type: "TEXT", nullable: true), @@ -222,19 +223,34 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations column: "Name"); migrationBuilder.CreateIndex( - name: "IX_WorkflowInstance_WorkflowStatus", + name: "IX_WorkflowInstance_Status", table: "WorkflowInstances", - column: "WorkflowStatus"); + column: "Status"); migrationBuilder.CreateIndex( - name: "IX_WorkflowInstance_WorkflowStatus_DefinitionId", + name: "IX_WorkflowInstance_Status_DefinitionId", table: "WorkflowInstances", - columns: new[] { "WorkflowStatus", "DefinitionId" }); + columns: new[] { "Status", "DefinitionId" }); migrationBuilder.CreateIndex( - name: "IX_WorkflowInstance_WorkflowStatus_DefinitionId_Version", + name: "IX_WorkflowInstance_Status_SubStatus", table: "WorkflowInstances", - columns: new[] { "WorkflowStatus", "DefinitionId", "Version" }); + columns: new[] { "Status", "SubStatus" }); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version", + table: "WorkflowInstances", + columns: new[] { "Status", "SubStatus", "DefinitionId", "Version" }); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowInstance_SubStatus", + table: "WorkflowInstances", + column: "SubStatus"); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowInstance_SubStatus_DefinitionId", + table: "WorkflowInstances", + columns: new[] { "SubStatus", "DefinitionId" }); migrationBuilder.CreateIndex( name: "IX_WorkflowTrigger_Hash", diff --git a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/ElsaDbContextModelSnapshot.cs b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/ElsaDbContextModelSnapshot.cs index d505cec79..dbc001245 100644 --- a/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/ElsaDbContextModelSnapshot.cs +++ b/src/persistence/Elsa.Persistence.EntityFrameworkCore.Sqlite/Migrations/ElsaDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.3"); modelBuilder.Entity("Elsa.Persistence.Entities.WorkflowBookmark", b => { @@ -190,7 +190,6 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations .HasColumnType("TEXT"); b.Property("CorrelationId") - .IsRequired() .HasColumnType("TEXT"); b.Property("CreatedAt") @@ -219,10 +218,13 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations b.Property("Name") .HasColumnType("TEXT"); - b.Property("Version") + b.Property("Status") .HasColumnType("INTEGER"); - b.Property("WorkflowStatus") + b.Property("SubStatus") + .HasColumnType("INTEGER"); + + b.Property("Version") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -248,14 +250,23 @@ namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations b.HasIndex("Name") .HasDatabaseName("IX_WorkflowInstance_Name"); - b.HasIndex("WorkflowStatus") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus"); + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); - b.HasIndex("WorkflowStatus", "DefinitionId") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus_DefinitionId"); + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); - b.HasIndex("WorkflowStatus", "DefinitionId", "Version") - .HasDatabaseName("IX_WorkflowInstance_WorkflowStatus_DefinitionId_Version"); + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); b.ToTable("WorkflowInstances"); }); diff --git a/src/samples/aspnet/Elsa.Samples.Web1/Program.cs b/src/samples/aspnet/Elsa.Samples.Web1/Program.cs index dd37c9cce..61a6068ff 100644 --- a/src/samples/aspnet/Elsa.Samples.Web1/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.Web1/Program.cs @@ -20,6 +20,8 @@ using Elsa.Modules.Scheduling.Extensions; using Elsa.Modules.WorkflowContexts.Extensions; using Elsa.Persistence.EntityFrameworkCore.Extensions; using Elsa.Persistence.EntityFrameworkCore.Sqlite; +using Elsa.Pipelines.ActivityExecution; +using Elsa.Pipelines.ActivityExecution.Components; using Elsa.Pipelines.WorkflowExecution.Components; using Elsa.Runtime.Extensions; using Elsa.Runtime.ProtoActor.Extensions; @@ -70,6 +72,8 @@ services options.Workflows.Add(); options.Workflows.Add(); options.Workflows.Add(); + options.Workflows.Add(); + options.Workflows.Add(); }); // Testing only: allow client app to connect from anywhere. diff --git a/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtBookmarkWorkflow.cs b/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtBookmarkWorkflow.cs new file mode 100644 index 000000000..b0cb394bf --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtBookmarkWorkflow.cs @@ -0,0 +1,29 @@ +using Elsa.Activities; +using Elsa.Modules.Activities.Console; +using Elsa.Modules.Scheduling.Activities; +using Elsa.Services; + +namespace Elsa.Samples.Web1.Workflows; + +public class StartAtBookmarkWorkflow : IWorkflow +{ + private readonly ISystemClock _systemClock; + + public StartAtBookmarkWorkflow(ISystemClock systemClock) + { + _systemClock = systemClock; + } + + public void Build(IWorkflowDefinitionBuilder workflow) + { + workflow.WithRoot(new Sequence + { + Activities = + { + new WriteLine("Waiting for 5 seconds..."), + new StartAt(() => _systemClock.UtcNow.AddSeconds(5)), + new WriteLine(() => $"Executed at {_systemClock.UtcNow}") + } + }); + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtTriggerWorkflow.cs b/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtTriggerWorkflow.cs new file mode 100644 index 000000000..ca6e684d6 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.Web1/Workflows/StartAtTriggerWorkflow.cs @@ -0,0 +1,31 @@ +using System; +using Elsa.Activities; +using Elsa.Modules.Activities.Console; +using Elsa.Modules.Scheduling.Activities; +using Elsa.Services; + +namespace Elsa.Samples.Web1.Workflows; + +public class StartAtTriggerWorkflow : IWorkflow +{ + private readonly ISystemClock _systemClock; + private readonly DateTimeOffset _executeAt; + + public StartAtTriggerWorkflow(ISystemClock systemClock) + { + _systemClock = systemClock; + _executeAt = systemClock.UtcNow.AddSeconds(10); + } + + public void Build(IWorkflowDefinitionBuilder workflow) + { + workflow.WithRoot(new Sequence + { + Activities = + { + new StartAt(_executeAt) { CanStartWorkflow = true }, + new WriteLine(() => $"Executed at {_systemClock.UtcNow}") + } + }); + } +} \ No newline at end of file