From f8e3559b34a50a6482561194c42fdc39e6eb80ea Mon Sep 17 00:00:00 2001 From: Jack Bird Date: Mon, 15 Jun 2026 16:09:03 +0100 Subject: [PATCH] feat: add overwrite rules to recurringSchedule.CreateOrUpdate --- samples/Sheddueller.SampleHost/Program.cs | 2 +- ...reateOrUpdateRecurringScheduleOperation.cs | 20 ++- .../Internal/Operations/PostgresSchedules.cs | 10 +- .../CapturingRecurringScheduleManager.cs | 80 +++++++++ .../FakeRecurringScheduleManager.cs | 114 +++++++++++-- src/Sheddueller/IRecurringScheduleManager.cs | 156 ++++++++++++++++++ .../RecurringScheduleUpdateOptions.cs | 16 ++ .../Runtime/RecurringScheduleManager.cs | 100 ++++++++++- .../Storage/UpsertRecurringScheduleRequest.cs | 3 +- .../DashboardEndpointTests.cs | 8 + ...OrUpdateRecurringScheduleOperationTests.cs | 43 ++++- .../PostgresTestData.cs | 6 +- .../JobStoreContractTests.cs | 50 +++++- .../FakeRecurringScheduleManagerTests.cs | 47 ++++++ test/Sheddueller.Tests/RecordingJobStore.cs | 12 +- .../RecurringScheduleManagerTests.cs | 31 ++++ 16 files changed, 660 insertions(+), 38 deletions(-) create mode 100644 src/Sheddueller/RecurringScheduleUpdateOptions.cs diff --git a/samples/Sheddueller.SampleHost/Program.cs b/samples/Sheddueller.SampleHost/Program.cs index 7fa5137..745f8a5 100644 --- a/samples/Sheddueller.SampleHost/Program.cs +++ b/samples/Sheddueller.SampleHost/Program.cs @@ -179,7 +179,7 @@ "* * * * *", (service, ct, progress) => service.RunRecurringAsync(progress, ct), new RecurringScheduleOptions(Priority: 10, OverlapMode: RecurringOverlapMode.Skip), - cancellationToken).ConfigureAwait(false); + cancellationToken: cancellationToken).ConfigureAwait(false); return RedirectWithMessage($"Recurring schedule 'demo:recurring' is {result}. The next occurrence will fire on the next minute boundary."); }); diff --git a/src/Sheddueller.Postgres/Internal/Operations/CreateOrUpdateRecurringScheduleOperation.cs b/src/Sheddueller.Postgres/Internal/Operations/CreateOrUpdateRecurringScheduleOperation.cs index 3651a22..a8dc07d 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/CreateOrUpdateRecurringScheduleOperation.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/CreateOrUpdateRecurringScheduleOperation.cs @@ -10,36 +10,44 @@ public static async ValueTask ExecuteAsync( UpsertRecurringScheduleRequest request, CancellationToken cancellationToken) { - var retry = PostgresRetryPolicies.Normalize(request.RetryPolicy); await using var connection = await context.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var existing = await PostgresSchedules.ReadScheduleDefinitionForUpdateAsync(context, connection, transaction, request.ScheduleKey, cancellationToken) .ConfigureAwait(false); + var updateOptions = request.UpdateOptions ?? RecurringScheduleUpdateOptions.Default; if (existing is null) { + var insertRetry = PostgresRetryPolicies.Normalize(request.RetryPolicy); var nextFireAtUtc = CronSchedule.GetNextOccurrenceAfter( request.CronExpression, await PostgresOperationContext.ReadTransactionTimestampAsync(connection, transaction, cancellationToken).ConfigureAwait(false)); - await PostgresSchedules.InsertScheduleAsync(context, connection, transaction, request, retry, nextFireAtUtc, cancellationToken) + await PostgresSchedules.InsertScheduleAsync(context, connection, transaction, request, insertRetry, nextFireAtUtc, cancellationToken) .ConfigureAwait(false); await context.NotifyAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return RecurringScheduleUpsertResult.Created; } - if (existing.EqualsRequest(request)) + var effectiveRequest = request with + { + CronExpression = updateOptions.OverwriteCronExpression ? request.CronExpression : existing.CronExpression, + }; + var effectiveIsPaused = !updateOptions.OverwritePausedState && existing.IsPaused; + + if (existing.EqualsRequest(effectiveRequest) && existing.IsPaused == effectiveIsPaused) { await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return RecurringScheduleUpsertResult.Unchanged; } - DateTimeOffset? updatedNextFireAtUtc = existing.IsPaused + var retry = PostgresRetryPolicies.Normalize(effectiveRequest.RetryPolicy); + DateTimeOffset? updatedNextFireAtUtc = effectiveIsPaused ? null : CronSchedule.GetNextOccurrenceAfter( - request.CronExpression, + effectiveRequest.CronExpression, await PostgresOperationContext.ReadTransactionTimestampAsync(connection, transaction, cancellationToken).ConfigureAwait(false)); - await PostgresSchedules.UpdateScheduleAsync(context, connection, transaction, request, retry, updatedNextFireAtUtc, cancellationToken) + await PostgresSchedules.UpdateScheduleAsync(context, connection, transaction, effectiveRequest, retry, effectiveIsPaused, updatedNextFireAtUtc, cancellationToken) .ConfigureAwait(false); await context.NotifyAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Sheddueller.Postgres/Internal/Operations/PostgresSchedules.cs b/src/Sheddueller.Postgres/Internal/Operations/PostgresSchedules.cs index e8c978a..7b41d47 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/PostgresSchedules.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/PostgresSchedules.cs @@ -102,7 +102,7 @@ await PostgresOperationContext.ExecuteCountAsync( values ( @schedule_key, @cron_expression, - false, + @is_paused, @overlap_mode, @priority, @service_type, @@ -121,7 +121,7 @@ await PostgresOperationContext.ExecuteCountAsync( transaction_timestamp(), transaction_timestamp()); """, - command => AddScheduleParameters(command, request, retry, nextFireAtUtc), + command => AddScheduleParameters(command, request, retry, isPaused: false, nextFireAtUtc), cancellationToken) .ConfigureAwait(false); await ReplaceScheduleGroupsAsync(context, connection, transaction, request.ScheduleKey, request.ConcurrencyGroupKeys, cancellationToken) @@ -136,6 +136,7 @@ public static async ValueTask UpdateScheduleAsync( NpgsqlTransaction transaction, UpsertRecurringScheduleRequest request, PostgresRetryPolicy retry, + bool isPaused, DateTimeOffset? nextFireAtUtc, CancellationToken cancellationToken) { @@ -145,6 +146,7 @@ await PostgresOperationContext.ExecuteCountAsync( $""" update {context.Names.RecurringSchedules} set cron_expression = @cron_expression, + is_paused = @is_paused, overlap_mode = @overlap_mode, priority = @priority, service_type = @service_type, @@ -163,7 +165,7 @@ await PostgresOperationContext.ExecuteCountAsync( updated_at_utc = transaction_timestamp() where schedule_key = @schedule_key; """, - command => AddScheduleParameters(command, request, retry, nextFireAtUtc), + command => AddScheduleParameters(command, request, retry, isPaused, nextFireAtUtc), cancellationToken) .ConfigureAwait(false); await PostgresOperationContext.ExecuteCountAsync( @@ -476,10 +478,12 @@ private static void AddScheduleParameters( NpgsqlCommand command, UpsertRecurringScheduleRequest request, PostgresRetryPolicy retry, + bool isPaused, DateTimeOffset? nextFireAtUtc) { command.Parameters.AddWithValue("schedule_key", request.ScheduleKey); command.Parameters.AddWithValue("cron_expression", request.CronExpression); + command.Parameters.AddWithValue("is_paused", isPaused); command.Parameters.AddWithValue("overlap_mode", PostgresConversion.ToText(request.OverlapMode)); command.Parameters.AddWithValue("priority", request.Priority); command.Parameters.AddWithValue("service_type", request.ServiceType); diff --git a/src/Sheddueller.Testing/CapturingRecurringScheduleManager.cs b/src/Sheddueller.Testing/CapturingRecurringScheduleManager.cs index 8118c6d..e05e508 100644 --- a/src/Sheddueller.Testing/CapturingRecurringScheduleManager.cs +++ b/src/Sheddueller.Testing/CapturingRecurringScheduleManager.cs @@ -37,7 +37,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -46,7 +56,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -55,7 +75,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -64,7 +94,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -73,7 +113,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -82,7 +132,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -91,7 +151,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// @@ -100,7 +170,17 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) + => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => this.CurrentOrDiscardingFake().CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, cancellationToken); /// diff --git a/src/Sheddueller.Testing/FakeRecurringScheduleManager.cs b/src/Sheddueller.Testing/FakeRecurringScheduleManager.cs index aea77b0..1e28c79 100644 --- a/src/Sheddueller.Testing/FakeRecurringScheduleManager.cs +++ b/src/Sheddueller.Testing/FakeRecurringScheduleManager.cs @@ -86,8 +86,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -95,8 +105,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -104,8 +124,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -113,8 +143,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -122,8 +162,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -131,8 +181,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -140,8 +200,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask CreateOrUpdateAsync( @@ -149,8 +219,18 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, JobExpressionParser.Parse(work), options, updateOptions, cancellationToken); + + /// + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); /// public ValueTask TriggerAsync( @@ -360,12 +440,14 @@ private async ValueTask CreateOrUpdateCoreAsync( string cronExpression, ParsedJob parsedJob, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { SubmissionValidator.ValidateScheduleKey(scheduleKey); CronSchedule.Validate(cronExpression); cancellationToken.ThrowIfCancellationRequested(); + var effectiveUpdateOptions = updateOptions ?? RecurringScheduleUpdateOptions.Default; var normalizedOptions = NormalizeOptions(options); var serializedArguments = await this.SerializeArgumentsAsync(parsedJob, cancellationToken).ConfigureAwait(false); var preparedSchedule = new PreparedSchedule(scheduleKey, cronExpression, parsedJob, serializedArguments, normalizedOptions); @@ -378,15 +460,23 @@ private async ValueTask CreateOrUpdateCoreAsync( return RecurringScheduleUpsertResult.Created; } - if (MatchesDefinition(existing, preparedSchedule)) + var effectivePreparedSchedule = effectiveUpdateOptions.OverwriteCronExpression + ? preparedSchedule + : preparedSchedule with { CronExpression = existing.CronExpression }; + var effectiveIsPaused = !effectiveUpdateOptions.OverwritePausedState && existing.IsPaused; + DateTimeOffset? effectiveNextFireAtUtc = effectiveIsPaused + ? null + : this.GetNextFireAtUtc(effectivePreparedSchedule.CronExpression); + + if (MatchesDefinition(existing, effectivePreparedSchedule) && existing.IsPaused == effectiveIsPaused) { return RecurringScheduleUpsertResult.Unchanged; } this._schedules[scheduleKey] = CreateSchedule( - preparedSchedule, - existing.IsPaused, - existing.IsPaused ? null : this.GetNextFireAtUtc(cronExpression)); + effectivePreparedSchedule, + effectiveIsPaused, + effectiveNextFireAtUtc); return RecurringScheduleUpsertResult.Updated; } diff --git a/src/Sheddueller/IRecurringScheduleManager.cs b/src/Sheddueller/IRecurringScheduleManager.cs index fdcc28f..6339501 100644 --- a/src/Sheddueller/IRecurringScheduleManager.cs +++ b/src/Sheddueller/IRecurringScheduleManager.cs @@ -18,6 +18,7 @@ public interface IRecurringScheduleManager /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -25,8 +26,26 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a Task-returning recurring schedule definition. + /// + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a ValueTask-returning recurring schedule definition. /// @@ -34,6 +53,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -41,8 +61,26 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a ValueTask-returning recurring schedule definition. + /// + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a Task-returning recurring schedule definition with scheduler-supplied progress reporting. /// @@ -50,6 +88,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -57,8 +96,26 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a Task-returning recurring schedule definition with scheduler-supplied progress reporting. + /// + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a ValueTask-returning recurring schedule definition with scheduler-supplied progress reporting. /// @@ -66,6 +123,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -73,8 +131,26 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a ValueTask-returning recurring schedule definition with scheduler-supplied progress reporting. + /// + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a Task-returning recurring schedule definition. /// @@ -83,6 +159,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -90,8 +167,27 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a Task-returning recurring schedule definition. + /// + /// The service type resolved from dependency injection when an occurrence runs. + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a ValueTask-returning recurring schedule definition. /// @@ -100,6 +196,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -107,8 +204,27 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a ValueTask-returning recurring schedule definition. + /// + /// The service type resolved from dependency injection when an occurrence runs. + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a Task-returning recurring schedule definition with scheduler-supplied progress reporting. /// @@ -117,6 +233,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -124,8 +241,27 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a Task-returning recurring schedule definition with scheduler-supplied progress reporting. + /// + /// The service type resolved from dependency injection when an occurrence runs. + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Creates or replaces a ValueTask-returning recurring schedule definition with scheduler-supplied progress reporting. /// @@ -134,6 +270,7 @@ ValueTask CreateOrUpdateAsync( /// A standard five-field cron expression evaluated in UTC. /// The method-call expression to materialize when an occurrence is due. /// Options applied to jobs created by the schedule. + /// Controls which existing schedule fields are overwritten. /// A token for canceling the storage operation. /// Whether the schedule was created, changed, or already matched the submitted definition. ValueTask CreateOrUpdateAsync( @@ -141,8 +278,27 @@ ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default); + /// + /// Creates or replaces a ValueTask-returning recurring schedule definition with scheduler-supplied progress reporting. + /// + /// The service type resolved from dependency injection when an occurrence runs. + /// The stable unique key for the schedule. + /// A standard five-field cron expression evaluated in UTC. + /// The method-call expression to materialize when an occurrence is due. + /// Options applied to jobs created by the schedule. + /// A token for canceling the storage operation. + /// Whether the schedule was created, changed, or already matched the submitted definition. + ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) => + this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); + /// /// Manually triggers a recurring schedule by cloning its current stored template into one queued job. /// diff --git a/src/Sheddueller/RecurringScheduleUpdateOptions.cs b/src/Sheddueller/RecurringScheduleUpdateOptions.cs new file mode 100644 index 0000000..3c7b696 --- /dev/null +++ b/src/Sheddueller/RecurringScheduleUpdateOptions.cs @@ -0,0 +1,16 @@ +namespace Sheddueller; + +/// +/// Controls which existing recurring schedule state is overwritten by CreateOrUpdateAsync. +/// +/// Whether an existing schedule's cron expression is replaced by the submitted cron expression. +/// Whether an existing schedule is reconciled to the submitted enabled state. +public sealed record RecurringScheduleUpdateOptions( + bool OverwriteCronExpression = true, + bool OverwritePausedState = true) +{ + /// + /// Gets the default update behavior. + /// + public static RecurringScheduleUpdateOptions Default { get; } = new(); +} diff --git a/src/Sheddueller/Runtime/RecurringScheduleManager.cs b/src/Sheddueller/Runtime/RecurringScheduleManager.cs index 560e9ab..09cb531 100644 --- a/src/Sheddueller/Runtime/RecurringScheduleManager.cs +++ b/src/Sheddueller/Runtime/RecurringScheduleManager.cs @@ -24,64 +24,136 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, Task>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public ValueTask CreateOrUpdateAsync( string scheduleKey, string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) - => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, cancellationToken); + => this.CreateOrUpdateCoreAsync(scheduleKey, cronExpression, work, options, updateOptions, cancellationToken); + + public ValueTask CreateOrUpdateAsync( + string scheduleKey, + string cronExpression, + Expression, ValueTask>> work, + RecurringScheduleOptions? options, + CancellationToken cancellationToken) + => this.CreateOrUpdateAsync(scheduleKey, cronExpression, work, options, updateOptions: null, cancellationToken); public async ValueTask TriggerAsync( string scheduleKey, @@ -152,6 +224,7 @@ private async ValueTask CreateOrUpdateCoreAsync> work, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { SubmissionValidator.ValidateScheduleKey(scheduleKey); @@ -163,6 +236,7 @@ private async ValueTask CreateOrUpdateCoreAsync CreateOrUpdateCoreAsync, TResult>> work, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { SubmissionValidator.ValidateScheduleKey(scheduleKey); @@ -183,6 +258,7 @@ private async ValueTask CreateOrUpdateCoreAsync CreateOrUpdateCoreAsync> work, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { SubmissionValidator.ValidateScheduleKey(scheduleKey); @@ -203,6 +280,7 @@ private async ValueTask CreateOrUpdateCoreAsync CreateOrUpdateCoreAsync, TResult>> work, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { SubmissionValidator.ValidateScheduleKey(scheduleKey); @@ -223,6 +302,7 @@ private async ValueTask CreateOrUpdateCoreAsync CreateOrUpdateCoreAsync( string cronExpression, ParsedJob parsedTask, RecurringScheduleOptions? options, + RecurringScheduleUpdateOptions? updateOptions, CancellationToken cancellationToken) { var groups = SubmissionValidator.NormalizeConcurrencyGroupKeys(options?.ConcurrencyGroupKeys); @@ -256,7 +337,8 @@ private async ValueTask CreateOrUpdateCoreAsync( timeProvider.GetUtcNow(), tags, parsedTask.InvocationTargetKind, - parsedTask.MethodParameterBindings); + parsedTask.MethodParameterBindings, + updateOptions ?? RecurringScheduleUpdateOptions.Default); var result = await store.CreateOrUpdateRecurringScheduleAsync(request, cancellationToken).ConfigureAwait(false); wakeSignal.Notify(); diff --git a/src/Sheddueller/Storage/UpsertRecurringScheduleRequest.cs b/src/Sheddueller/Storage/UpsertRecurringScheduleRequest.cs index c939a6e..fbfaddd 100644 --- a/src/Sheddueller/Storage/UpsertRecurringScheduleRequest.cs +++ b/src/Sheddueller/Storage/UpsertRecurringScheduleRequest.cs @@ -19,4 +19,5 @@ public sealed record UpsertRecurringScheduleRequest( DateTimeOffset UpsertedAtUtc, IReadOnlyList? Tags = null, JobInvocationTargetKind InvocationTargetKind = JobInvocationTargetKind.Instance, - IReadOnlyList? MethodParameterBindings = null); + IReadOnlyList? MethodParameterBindings = null, + RecurringScheduleUpdateOptions? UpdateOptions = null); diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index c2d0cd7..6c81c56 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -1003,6 +1003,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1011,6 +1012,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1019,6 +1021,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1027,6 +1030,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1035,6 +1039,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1043,6 +1048,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1051,6 +1057,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, Task>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -1059,6 +1066,7 @@ public ValueTask CreateOrUpdateAsync( string cronExpression, Expression, ValueTask>> work, RecurringScheduleOptions? options = null, + RecurringScheduleUpdateOptions? updateOptions = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs b/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs index bf323d8..d1bcd25 100644 --- a/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs +++ b/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs @@ -91,7 +91,7 @@ await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.Create } [Fact] - public async Task CreateOrUpdateRecurringSchedule_UpdatePausedSchedule_PreservesPausedStateAndNullNextFire() + public async Task CreateOrUpdateRecurringSchedule_UpdatePausedSchedule_DefaultUpdateOptionsResumesSchedule() { await using var context = await PostgresTestContext.CreateMigratedAsync(fixture); await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 1)); @@ -99,10 +99,51 @@ public async Task CreateOrUpdateRecurringSchedule_UpdatePausedSchedule_Preserves var result = await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 2)); + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + var schedule = await context.ReadScheduleAsync("schedule-a"); + schedule.IsPaused.ShouldBeFalse(); + schedule.NextFireAtUtc.ShouldNotBeNull(); + schedule.Priority.ShouldBe(2); + } + + [Fact] + public async Task CreateOrUpdateRecurringSchedule_UpdateOptionsCanPreservePausedStateAndNullNextFire() + { + await using var context = await PostgresTestContext.CreateMigratedAsync(fixture); + await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 1)); + await context.Store.PauseRecurringScheduleAsync("schedule-a", DateTimeOffset.UtcNow); + + var result = await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule( + "schedule-a", + priority: 2, + updateOptions: new RecurringScheduleUpdateOptions(OverwritePausedState: false))); + result.ShouldBe(RecurringScheduleUpsertResult.Updated); var schedule = await context.ReadScheduleAsync("schedule-a"); schedule.IsPaused.ShouldBeTrue(); schedule.NextFireAtUtc.ShouldBeNull(); schedule.Priority.ShouldBe(2); } + + [Fact] + public async Task CreateOrUpdateRecurringSchedule_UpdateOptionsCanPreserveCronExpression() + { + await using var context = await PostgresTestContext.CreateMigratedAsync(fixture); + await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 1)); + + var result = await context.Store.CreateOrUpdateRecurringScheduleAsync( + PostgresTestData.CreateSchedule( + "schedule-a", + priority: 2, + updateOptions: new RecurringScheduleUpdateOptions(OverwriteCronExpression: false)) + with + { + CronExpression = "*/5 * * * *", + }); + + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + var schedule = await context.ReadScheduleAsync("schedule-a"); + schedule.CronExpression.ShouldBe("* * * * *"); + schedule.Priority.ShouldBe(2); + } } diff --git a/test/Sheddueller.Postgres.Tests/PostgresTestData.cs b/test/Sheddueller.Postgres.Tests/PostgresTestData.cs index 27ce009..0bce6b8 100644 --- a/test/Sheddueller.Postgres.Tests/PostgresTestData.cs +++ b/test/Sheddueller.Postgres.Tests/PostgresTestData.cs @@ -71,7 +71,8 @@ public static UpsertRecurringScheduleRequest CreateSchedule( RecurringOverlapMode overlapMode = RecurringOverlapMode.Skip, IReadOnlyList? tags = null, JobInvocationTargetKind invocationTargetKind = JobInvocationTargetKind.Instance, - IReadOnlyList? methodParameterBindings = null) + IReadOnlyList? methodParameterBindings = null, + RecurringScheduleUpdateOptions? updateOptions = null) => new( scheduleKey, "* * * * *", @@ -86,7 +87,8 @@ public static UpsertRecurringScheduleRequest CreateSchedule( DateTimeOffset.UtcNow, Tags: tags, InvocationTargetKind: invocationTargetKind, - MethodParameterBindings: methodParameterBindings); + MethodParameterBindings: methodParameterBindings, + UpdateOptions: updateOptions); public static JobFailureInfo CreateFailure() => new("TestException", "failed", "stack"); diff --git a/test/Sheddueller.ProviderContracts/JobStoreContractTests.cs b/test/Sheddueller.ProviderContracts/JobStoreContractTests.cs index 273848a..a876c13 100644 --- a/test/Sheddueller.ProviderContracts/JobStoreContractTests.cs +++ b/test/Sheddueller.ProviderContracts/JobStoreContractTests.cs @@ -554,6 +554,50 @@ public async Task RecurringSchedule_Lifecycle_CreateUpdatePauseResumeListDelete( (await context.Store.DeleteRecurringScheduleAsync("schedule-a")).ShouldBeFalse(); } + [Fact] + public async Task RecurringSchedule_UpdateOptions_CanPreserveCronExpression() + { + await using var context = await this.CreateContextAsync(); + + await context.Store.CreateOrUpdateRecurringScheduleAsync(CreateSchedule("schedule-a", priority: 1)); + var result = await context.Store.CreateOrUpdateRecurringScheduleAsync( + CreateSchedule( + "schedule-a", + priority: 5, + updateOptions: new RecurringScheduleUpdateOptions(OverwriteCronExpression: false)) + with + { + CronExpression = "*/5 * * * *", + }); + + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + var schedule = await context.Store.GetRecurringScheduleAsync("schedule-a"); + schedule.ShouldNotBeNull(); + schedule.CronExpression.ShouldBe("* * * * *"); + schedule.Priority.ShouldBe(5); + } + + [Fact] + public async Task RecurringSchedule_UpdateOptions_CanPreservePausedState() + { + await using var context = await this.CreateContextAsync(); + + await context.Store.CreateOrUpdateRecurringScheduleAsync(CreateSchedule("schedule-a", priority: 1)); + await context.Store.PauseRecurringScheduleAsync("schedule-a", ContractClock); + + var result = await context.Store.CreateOrUpdateRecurringScheduleAsync(CreateSchedule( + "schedule-a", + priority: 5, + updateOptions: new RecurringScheduleUpdateOptions(OverwritePausedState: false))); + + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + var schedule = await context.Store.GetRecurringScheduleAsync("schedule-a"); + schedule.ShouldNotBeNull(); + schedule.IsPaused.ShouldBeTrue(); + schedule.NextFireAtUtc.ShouldBeNull(); + schedule.Priority.ShouldBe(5); + } + [Fact] public async Task RecurringSchedule_DueOccurrence_MaterializesClaimableJob() { @@ -790,7 +834,8 @@ protected static UpsertRecurringScheduleRequest CreateSchedule( RecurringOverlapMode overlapMode = RecurringOverlapMode.Skip, DateTimeOffset? upsertedAtUtc = null, JobInvocationTargetKind invocationTargetKind = JobInvocationTargetKind.Instance, - IReadOnlyList? methodParameterBindings = null) + IReadOnlyList? methodParameterBindings = null, + RecurringScheduleUpdateOptions? updateOptions = null) => new( scheduleKey, "* * * * *", @@ -804,7 +849,8 @@ protected static UpsertRecurringScheduleRequest CreateSchedule( overlapMode, upsertedAtUtc ?? DateTimeOffset.UtcNow, InvocationTargetKind: invocationTargetKind, - MethodParameterBindings: methodParameterBindings); + MethodParameterBindings: methodParameterBindings, + UpdateOptions: updateOptions); protected static UpsertRecurringScheduleRequest CreateDueSchedule( string scheduleKey, diff --git a/test/Sheddueller.Testing.Tests/FakeRecurringScheduleManagerTests.cs b/test/Sheddueller.Testing.Tests/FakeRecurringScheduleManagerTests.cs index f480751..e5092fc 100644 --- a/test/Sheddueller.Testing.Tests/FakeRecurringScheduleManagerTests.cs +++ b/test/Sheddueller.Testing.Tests/FakeRecurringScheduleManagerTests.cs @@ -110,6 +110,53 @@ public async Task CreateOrUpdate_DefinitionChanges_ReturnsCreatedUnchangedAndUpd fake.Schedules[0].CronExpression.ShouldBe("*/5 * * * *"); } + [Fact] + public async Task CreateOrUpdate_UpdateOptionsCanPreserveCronExpression() + { + var fake = new FakeRecurringScheduleManager(); + + await fake.CreateOrUpdateAsync( + "schedule-a", + "* * * * *", + (s, ct) => s.HandleStringAsync("alpha", ct), + new RecurringScheduleOptions(Priority: 1)); + var result = await fake.CreateOrUpdateAsync( + "schedule-a", + "*/5 * * * *", + (s, ct) => s.HandleStringAsync("alpha", ct), + new RecurringScheduleOptions(Priority: 5), + new RecurringScheduleUpdateOptions(OverwriteCronExpression: false)); + + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + fake.Schedules[0].CronExpression.ShouldBe("* * * * *"); + fake.Schedules[0].Priority.ShouldBe(5); + } + + [Fact] + public async Task CreateOrUpdate_UpdateOptionsCanPreservePausedState() + { + var fake = new FakeRecurringScheduleManager(); + + await fake.CreateOrUpdateAsync( + "schedule-a", + "* * * * *", + (s, ct) => s.HandleStringAsync("alpha", ct), + new RecurringScheduleOptions(Priority: 1)); + await fake.PauseAsync("schedule-a"); + + var result = await fake.CreateOrUpdateAsync( + "schedule-a", + "* * * * *", + (s, ct) => s.HandleStringAsync("alpha", ct), + new RecurringScheduleOptions(Priority: 5), + new RecurringScheduleUpdateOptions(OverwritePausedState: false)); + + result.ShouldBe(RecurringScheduleUpsertResult.Updated); + fake.Schedules[0].IsPaused.ShouldBeTrue(); + fake.Schedules[0].NextFireAtUtc.ShouldBeNull(); + fake.Schedules[0].Priority.ShouldBe(5); + } + [Fact] public async Task PauseResumeDeleteGetAndList_ExistingSchedule_UpdatesCurrentState() { diff --git a/test/Sheddueller.Tests/RecordingJobStore.cs b/test/Sheddueller.Tests/RecordingJobStore.cs index 4e627ce..7b6feb5 100644 --- a/test/Sheddueller.Tests/RecordingJobStore.cs +++ b/test/Sheddueller.Tests/RecordingJobStore.cs @@ -5,13 +5,18 @@ namespace Sheddueller.Tests; internal sealed class RecordingJobStore : IJobStore { private readonly List enqueuedRequests = []; + private readonly List recurringScheduleRequests = []; private readonly List triggerRequests = []; private long nextSequence; public IReadOnlyList EnqueuedRequests => this.enqueuedRequests; + public IReadOnlyList RecurringScheduleRequests => this.recurringScheduleRequests; + public IReadOnlyList TriggerRequests => this.triggerRequests; + public RecurringScheduleUpsertResult CreateOrUpdateRecurringScheduleResult { get; set; } = RecurringScheduleUpsertResult.Created; + public RecurringScheduleTriggerResult TriggerResult { get; set; } = new(RecurringScheduleTriggerStatus.NotFound); public EnqueueJobRequest GetRequest(Guid jobId) @@ -108,7 +113,12 @@ public ValueTask SetConcurrencyLimitAsync( public ValueTask CreateOrUpdateRecurringScheduleAsync( UpsertRecurringScheduleRequest request, CancellationToken cancellationToken = default) - => throw CreateUnsupportedException(); + { + cancellationToken.ThrowIfCancellationRequested(); + this.recurringScheduleRequests.Add(request); + + return ValueTask.FromResult(this.CreateOrUpdateRecurringScheduleResult); + } public ValueTask TriggerRecurringScheduleAsync( TriggerRecurringScheduleRequest request, diff --git a/test/Sheddueller.Tests/RecurringScheduleManagerTests.cs b/test/Sheddueller.Tests/RecurringScheduleManagerTests.cs index 4e46a2a..5b2ad85 100644 --- a/test/Sheddueller.Tests/RecurringScheduleManagerTests.cs +++ b/test/Sheddueller.Tests/RecurringScheduleManagerTests.cs @@ -10,6 +10,31 @@ namespace Sheddueller.Tests; public sealed class RecurringScheduleManagerTests { + [Fact] + public async Task CreateOrUpdate_UpdateOptions_PassesUpdateOptionsToStore() + { + var store = new RecordingJobStore(); + var wakeSignal = new RecordingWakeSignal(); + var manager = CreateManager(store, wakeSignal); + var updateOptions = new RecurringScheduleUpdateOptions( + OverwriteCronExpression: false, + OverwritePausedState: false); + + var result = await manager.CreateOrUpdateAsync( + "schedule-a", + "* * * * *", + (service, cancellationToken) => service.ExecuteAsync("feed-1", "job-1", cancellationToken), + new RecurringScheduleOptions(Priority: 7), + updateOptions); + + result.ShouldBe(RecurringScheduleUpsertResult.Created); + var request = store.RecurringScheduleRequests.ShouldHaveSingleItem(); + request.ScheduleKey.ShouldBe("schedule-a"); + request.Priority.ShouldBe(7); + request.UpdateOptions.ShouldBe(updateOptions); + wakeSignal.NotifyCount.ShouldBe(1); + } + [Fact] public async Task Trigger_InvalidScheduleKey_ThrowsWithoutCallingStore() { @@ -89,4 +114,10 @@ public void Notify() public ValueTask WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) => ValueTask.CompletedTask; } + + private sealed class TestScheduleService + { + public Task ExecuteAsync(string feedId, string jobKey, CancellationToken cancellationToken) + => Task.CompletedTask; + } }