Skip to content

Commit

Permalink
feat!: Infer second precision in cron expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
linkdotnet committed Jul 21, 2024
1 parent 5e69c40 commit d81d78f
Show file tree
Hide file tree
Showing 10 changed files with 62 additions and 68 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to **NCronJob** will be documented in this file. The project

## [Unreleased]

This is a new major version! A bit of cleanup! Check the `v3` migration guide for more information.

### Removed
- Removed `enableSecondPrecision` as it is now inferred automatically.

## [2.8.4] - 2024-06-23

### Fixed
Expand Down
21 changes: 21 additions & 0 deletions docs/migration/v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# v3 Migration Guide

This document will describe the changes made in `v3` of **NCronJob** and how to migrate from `v2`.

Version 3 of **NCronJob** brings some breaking changes to mae a better API.

### Second precision is automatically inferred
In `v2` one would define as such:
```csharp
builder.Services.AddNCronJob(
n => n.AddJob<SimpleJob>(
p => p.WithCronExpression("* * * * * *", true)));
```

Inside `WithCronExpression` was an optional parameter that if set to `true` the cron expression has to be in seconds precision. This was a bit confusing and not very intuitive. In `v3` the seconds precision is automatically inferred. So the above code can be simplified to:

```csharp
builder.Services.AddNCronJob(
n => n.AddJob<SimpleJob>(
p => p.WithCronExpression("* * * * * *")));
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ nav:
- Startup Jobs: features/startup-jobs.md
- Model dependencies: features/model-dependencies.md
- Migration:
- v3 Migration Guide: migration/v3.md
- v2 Migration Guide: migration/v2.md
- Advanced:
- Controlling the log level: advanced/log-level.md
Expand Down
11 changes: 0 additions & 11 deletions src/NCronJob/Configuration/Builder/JobOption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,6 @@ internal sealed class JobOption
/// </summary>
public bool IsStartupJob { get; set; }

/// <summary>
/// Determines whether the cron expression can specify second-level precision.
/// </summary>
/// <remarks>
/// When enabled, cron expressions must include a seconds field, allowing for more precise scheduling.
/// By default, this is disabled, and cron expressions are expected to start with the minute field.
/// Enabling this affects scheduling granularity and may influence performance, especially for jobs
/// that are scheduled to run very frequently.
/// </remarks>
public bool EnableSecondPrecision { get; set; }

/// <summary>
/// The job name given by the user, which can be used to identify the job.
/// </summary>
Expand Down
28 changes: 3 additions & 25 deletions src/NCronJob/Configuration/Builder/JobOptionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace NCronJob;
public sealed class JobOptionBuilder
{
private readonly List<JobOption> jobOptions = [];

/// <summary>
/// The jobOptions item we need to work with will always be the first.
/// This is because we only support one job per builder.
Expand All @@ -19,7 +19,7 @@ internal JobOptionBuilder SetRunAtStartup()
// Startup Jobs should not be initialized with a cron expression.
if(jobOptions[0].CronExpression != null)
throw new InvalidOperationException("Startup jobs cannot have a cron expression.");

jobOptions[0].IsStartupJob = true;
}
return this;
Expand All @@ -29,46 +29,24 @@ internal JobOptionBuilder SetRunAtStartup()
/// Adds a cron expression for the given job.
/// </summary>
/// <param name="cronExpression">The cron expression that defines when the job should be executed.</param>
/// <param name="enableSecondPrecision">
/// Specifies whether the cron expression should consider second-level precision.
/// This parameter is optional. If not provided, or set to null, it auto-detects based on the number
/// of parts in the cron expression (6 parts indicate second-level precision, otherwise minute-level precision).
/// </param>
/// <param name="timeZoneInfo">Optional, provides the timezone that is used to evaluate the cron expression. Defaults to UTC.</param>
/// <returns>Returns a <see cref="ParameterBuilder"/> that allows adding parameters to the job.</returns>
public ParameterBuilder WithCronExpression(string cronExpression, bool? enableSecondPrecision = null, TimeZoneInfo? timeZoneInfo = null)
public ParameterBuilder WithCronExpression(string cronExpression, TimeZoneInfo? timeZoneInfo = null)
{
ArgumentNullException.ThrowIfNull(cronExpression);

cronExpression = cronExpression.Trim();
var determinedPrecision = DetermineAndValidatePrecision(cronExpression, enableSecondPrecision);

var jobOption = new JobOption
{
CronExpression = cronExpression,
EnableSecondPrecision = determinedPrecision,
TimeZoneInfo = timeZoneInfo ?? TimeZoneInfo.Utc
};

jobOptions.Add(jobOption);

return new ParameterBuilder(this, jobOption);
}

internal static bool DetermineAndValidatePrecision(string cronExpression, bool? enableSecondPrecision)
{
var parts = cronExpression.Split(' ');
var precisionRequired = enableSecondPrecision ?? (parts.Length == 6);

var expectedLength = precisionRequired ? 6 : 5;
if (parts.Length != expectedLength)
{
var precisionText = precisionRequired ? "second precision" : "minute precision";
throw new ArgumentException($"Invalid cron expression format for {precisionText}.", nameof(cronExpression));
}

return precisionRequired;
}

internal List<JobOption> GetJobOptions()
{
Expand Down
28 changes: 21 additions & 7 deletions src/NCronJob/Configuration/Builder/NCronJobOptionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public IStartupStage<T> AddJob<T>(Action<JobOptionBuilder>? options = null)
foreach (var option in jobOptions)
{
var cron = option.CronExpression is not null
? GetCronExpression(option.CronExpression, option.EnableSecondPrecision)
? GetCronExpression(option.CronExpression)
: null;
var entry = new JobDefinition(typeof(T), option.Parameter, cron, option.TimeZoneInfo)
{
Expand Down Expand Up @@ -87,15 +87,12 @@ public NCronJobOptionBuilder AddJob(
ArgumentException.ThrowIfNullOrEmpty(cronExpression);
ValidateConcurrencySetting(jobDelegate.Method);

var determinedPrecision = JobOptionBuilder.DetermineAndValidatePrecision(cronExpression, null);

var jobOption = new JobOption
{
CronExpression = cronExpression,
EnableSecondPrecision = determinedPrecision,
TimeZoneInfo = timeZoneInfo ?? TimeZoneInfo.Utc
};
var cron = GetCronExpression(jobOption.CronExpression, jobOption.EnableSecondPrecision);
var cron = GetCronExpression(jobOption.CronExpression);

var jobPolicyMetadata = new JobExecutionAttributes(jobDelegate);
var entry = new JobDefinition(jobType, null, cron, jobOption.TimeZoneInfo,
Expand Down Expand Up @@ -124,9 +121,11 @@ private void ValidateConcurrencySetting(object jobIdentifier)
}
}

internal static CronExpression GetCronExpression(string expression, bool enableSecondPrecision)
internal static CronExpression GetCronExpression(string expression)
{
var cf = enableSecondPrecision ? CronFormat.IncludeSeconds : CronFormat.Standard;
var precisionRequired = DetermineAndValidatePrecision(expression);

var cf = precisionRequired ? CronFormat.IncludeSeconds : CronFormat.Standard;

return CronExpression.TryParse(expression, cf, out var cronExpression)
? cronExpression
Expand All @@ -140,6 +139,21 @@ internal void RegisterJobs()
Services.AddSingleton(job);
}
}

private static bool DetermineAndValidatePrecision(string cronExpression)
{
var parts = cronExpression.Split(' ');
var precisionRequired = parts.Length == 6;

var expectedLength = precisionRequired ? 6 : 5;
if (parts.Length != expectedLength)
{
var precisionText = precisionRequired ? "second precision" : "minute precision";
throw new ArgumentException($"Invalid cron expression format for {precisionText}.", nameof(cronExpression));
}

return precisionRequired;
}
}

/// <summary>
Expand Down
3 changes: 1 addition & 2 deletions src/NCronJob/Registry/RuntimeJobRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,9 @@ public void UpdateSchedule(string jobName, string cronExpression, TimeZoneInfo?
ArgumentNullException.ThrowIfNull(jobName);
ArgumentNullException.ThrowIfNull(cronExpression);

var precisionRequired = JobOptionBuilder.DetermineAndValidatePrecision(cronExpression, null);
var job = jobRegistry.FindJobDefinition(jobName) ?? throw new InvalidOperationException($"Job with name '{jobName}' not found.");

var cron = NCronJobOptionBuilder.GetCronExpression(cronExpression, precisionRequired);
var cron = NCronJobOptionBuilder.GetCronExpression(cronExpression);

job.CronExpression = cron;
job.TimeZone = timeZoneInfo ?? TimeZoneInfo.Utc;
Expand Down
22 changes: 8 additions & 14 deletions tests/NCronJob.Tests/ExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public void AddingCronJobWithSecondPrecisionExpressionNotThrowException()

Action act = () => builder.AddJob<FakeJob>(o =>
{
o.WithCronExpression("* * * * * *", true);
o.WithCronExpression("* * * * * *");
});

act.ShouldNotThrow();
Expand All @@ -42,8 +42,10 @@ public void AddingNullCronExpressionThrowsArgumentNullException()
[Fact]
public void AddingCronExpressionWithIncorrectSegmentCountThrowsArgumentException()
{
var builder = new JobOptionBuilder();
Should.Throw<ArgumentException>(() => builder.WithCronExpression("* * *"));
var collection = new ServiceCollection();
var settings = new ConcurrencySettings { MaxDegreeOfParallelism = Environment.ProcessorCount * 4 };
var builder = new NCronJobOptionBuilder(collection, settings);
Should.Throw<ArgumentException>(() => builder.AddJob<FakeJob>(p => p.WithCronExpression("* * *")));
}

[Fact]
Expand All @@ -57,28 +59,20 @@ public void AddingValidCronExpressionWithMinutePrecisionDoesNotThrowException()
public void AddingValidCronExpressionWithSecondPrecisionDoesNotThrowException()
{
var builder = new JobOptionBuilder();
Should.NotThrow(() => builder.WithCronExpression("30 5 * * * *", true));
}

[Fact]
public void AddingCronExpressionWithInvalidSecondPrecisionThrowsArgumentException()
{
var builder = new JobOptionBuilder();
Should.Throw<ArgumentException>(() => builder.WithCronExpression("5 * * * *", true));
Should.NotThrow(() => builder.WithCronExpression("30 5 * * * *"));
}


[Fact]
public void AutoDetectSecondPrecisionWhenNotSpecified()
{
var builder = new JobOptionBuilder();
builder.WithCronExpression("0 0 12 * * ?");
var options = builder.GetJobOptions();
options.ShouldContain(o => o.CronExpression == "0 0 12 * * ?" && o.EnableSecondPrecision);
options.ShouldContain(o => o.CronExpression == "0 0 12 * * ?");

builder.WithCronExpression("0 1 * * *");
options = builder.GetJobOptions();
options.ShouldContain(o => o.CronExpression == "0 1 * * *" && !o.EnableSecondPrecision);
options.ShouldContain(o => o.CronExpression == "0 1 * * *");
}

private sealed class FakeJob : IJob
Expand Down
7 changes: 0 additions & 7 deletions tests/NCronJob.Tests/JobOptionBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public void ShouldCreateJobOptionsWithCronExpression()

options.Count.ShouldBe(1);
options.Single().CronExpression.ShouldBe("* * * * *");
options.Single().EnableSecondPrecision.ShouldBeFalse();
options.Single().Parameter.ShouldBeNull();
}

Expand All @@ -32,10 +31,8 @@ public void ShouldCreateMultipleJobsWithParameters()

options.Count.ShouldBe(2);
options[0].CronExpression.ShouldBe("* * * * *");
options[0].EnableSecondPrecision.ShouldBeFalse();
options[0].Parameter.ShouldBe("foo");
options[1].CronExpression.ShouldBe("0 * * * *");
options[1].EnableSecondPrecision.ShouldBeFalse();
options[1].Parameter.ShouldBe("bar");
}

Expand All @@ -52,10 +49,8 @@ public void ShouldAddMultipleCronJobsEvenWithoutParameters()

options.Count.ShouldBe(2);
options[0].CronExpression.ShouldBe("* * * * *");
options[0].EnableSecondPrecision.ShouldBeFalse();
options[0].Parameter.ShouldBeNull();
options[1].CronExpression.ShouldBe("0 * * * *");
options[1].EnableSecondPrecision.ShouldBeFalse();
options[1].Parameter.ShouldBeNull();
}

Expand All @@ -73,10 +68,8 @@ public void ShouldCreateMultipleJobsWithoutAnd()

options.Count.ShouldBe(2);
options[0].CronExpression.ShouldBe("* * * * *");
options[0].EnableSecondPrecision.ShouldBeFalse();
options[0].Parameter.ShouldBe("foo");
options[1].CronExpression.ShouldBe("0 * * * *");
options[1].EnableSecondPrecision.ShouldBeFalse();
options[1].Parameter.ShouldBe("bar");
}
}
4 changes: 2 additions & 2 deletions tests/NCronJob.Tests/NCronJobIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public async Task InstantJobShouldGetParameter()
public async Task CronJobThatIsScheduledEverySecondShouldBeExecuted()
{
FakeTimer.Advance(TimeSpan.FromSeconds(1));
ServiceCollection.AddNCronJob(n => n.AddJob<SimpleJob>(p => p.WithCronExpression("* * * * * *", true)));
ServiceCollection.AddNCronJob(n => n.AddJob<SimpleJob>(p => p.WithCronExpression("* * * * * *")));
var provider = CreateServiceProvider();

await provider.GetRequiredService<IHostedService>().StartAsync(CancellationToken);
Expand All @@ -127,7 +127,7 @@ public async Task CronJobThatIsScheduledEverySecondShouldBeExecuted()
public async Task CanRunSecondPrecisionAndMinutePrecisionJobs()
{
ServiceCollection.AddNCronJob(n => n.AddJob<SimpleJob>(
p => p.WithCronExpression("* * * * * *", true).And.WithCronExpression("* * * * *")));
p => p.WithCronExpression("* * * * * *").And.WithCronExpression("* * * * *")));
var provider = CreateServiceProvider();

await provider.GetRequiredService<IHostedService>().StartAsync(CancellationToken);
Expand Down

0 comments on commit d81d78f

Please sign in to comment.