229 lines
8.1 KiB
C#
229 lines
8.1 KiB
C#
using System.Globalization;
|
||
|
||
namespace w4c_workflows.Services.Triggers;
|
||
|
||
/// <summary>
|
||
/// Minimal 5-field cron parser (minute hour day-of-month month day-of-week)
|
||
/// supporting <c>*</c>, <c>,</c> (list), <c>-</c> (range) and <c>/</c> (step), plus
|
||
/// numeric values. Enough for workflow scheduling (<c>"0 */6 * * *"</c>,
|
||
/// <c>"0 9 * * 1-5"</c>); no seconds/year fields.
|
||
///
|
||
/// Day-of-week 0 and 7 both mean Sunday. Day matching uses AND semantics across
|
||
/// day-of-month and day-of-week, so <c>"0 9 * * 1-5"</c> runs Mon–Fri and
|
||
/// <c>"0 0 1 * *"</c> runs on the 1st of the month (the <c>*</c> field matches all
|
||
/// values and therefore never filters).
|
||
/// </summary>
|
||
public sealed class CronExpression
|
||
{
|
||
private readonly CronField _minute;
|
||
private readonly CronField _hour;
|
||
private readonly CronField _dayOfMonth;
|
||
private readonly CronField _month;
|
||
private readonly CronField _dayOfWeek;
|
||
|
||
private CronExpression(CronField minute, CronField hour, CronField dayOfMonth, CronField month, CronField dayOfWeek)
|
||
{
|
||
_minute = minute;
|
||
_hour = hour;
|
||
_dayOfMonth = dayOfMonth;
|
||
_month = month;
|
||
_dayOfWeek = dayOfWeek;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses a 5-field cron expression. On success <paramref name="parsed"/> is
|
||
/// set and <paramref name="error"/> is null; otherwise the reverse.
|
||
/// </summary>
|
||
public static bool TryParse(string? expression, out CronExpression? parsed, out string? error)
|
||
{
|
||
parsed = null;
|
||
error = null;
|
||
|
||
if (string.IsNullOrWhiteSpace(expression))
|
||
{
|
||
error = "cron expression is empty";
|
||
return false;
|
||
}
|
||
|
||
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
if (fields.Length != 5)
|
||
{
|
||
error = $"cron expression must have 5 fields, got {fields.Length}";
|
||
return false;
|
||
}
|
||
|
||
if (!CronField.TryParse(fields[0], 0, 59, out var minute, out error)) return false;
|
||
if (!CronField.TryParse(fields[1], 0, 23, out var hour, out error)) return false;
|
||
if (!CronField.TryParse(fields[2], 1, 31, out var dayOfMonth, out error)) return false;
|
||
if (!CronField.TryParse(fields[3], 1, 12, out var month, out error)) return false;
|
||
// 0–7: 7 is the Sunday alias and is folded into 0 below.
|
||
if (!CronField.TryParse(fields[4], 0, 7, out var dayOfWeek, out error)) return false;
|
||
dayOfWeek!.AliasValue(7, 0);
|
||
|
||
parsed = new CronExpression(minute!, hour!, dayOfMonth!, month!, dayOfWeek);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Whether the given UTC instant matches this cron expression.</summary>
|
||
public bool Matches(DateTime utc) =>
|
||
_minute.Matches(utc.Minute)
|
||
&& _hour.Matches(utc.Hour)
|
||
&& _dayOfMonth.Matches(utc.Day)
|
||
&& _month.Matches(utc.Month)
|
||
&& _dayOfWeek.Matches((int)utc.DayOfWeek);
|
||
|
||
/// <summary>
|
||
/// The first occurrence strictly after <paramref name="after"/>, truncated to
|
||
/// the minute and evaluated in UTC. Returns null when no occurrence exists
|
||
/// within a bounded 5-year horizon (a guard against malformed expressions
|
||
/// such as a 31st-of-the-month-only schedule that never matches).
|
||
///
|
||
/// The search jumps by field (month → day → hour → minute) instead of walking
|
||
/// every minute, so an unmatchable expression costs a few thousand steps
|
||
/// rather than ~2.6 million per call.
|
||
/// </summary>
|
||
public DateTimeOffset? GetNextOccurrence(DateTimeOffset after)
|
||
{
|
||
var utc = after.UtcDateTime;
|
||
var cursor = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc)
|
||
.AddMinutes(1);
|
||
var horizon = cursor.AddYears(5);
|
||
|
||
// Safety net for an expression that matches no real instant; the coarse
|
||
// jumps above make the real worst case only a few thousand iterations.
|
||
var guard = 0;
|
||
|
||
while (cursor <= horizon)
|
||
{
|
||
if (++guard > 100_000)
|
||
return null;
|
||
|
||
if (!_month.Matches(cursor.Month))
|
||
{
|
||
cursor = new DateTime(cursor.Year, cursor.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1);
|
||
continue;
|
||
}
|
||
|
||
if (!MatchesDay(cursor))
|
||
{
|
||
cursor = cursor.Date.AddDays(1);
|
||
continue;
|
||
}
|
||
|
||
if (!_hour.Matches(cursor.Hour))
|
||
{
|
||
cursor = cursor.Date.AddHours(cursor.Hour + 1);
|
||
continue;
|
||
}
|
||
|
||
if (!_minute.Matches(cursor.Minute))
|
||
{
|
||
cursor = cursor.AddMinutes(1);
|
||
continue;
|
||
}
|
||
|
||
return new DateTimeOffset(cursor, TimeSpan.Zero);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Day matches when both day-of-month and day-of-week match. A <c>*</c> field
|
||
/// matches every value, so the common case behaves as expected.
|
||
/// </summary>
|
||
private bool MatchesDay(DateTime utc)
|
||
=> _dayOfMonth.Matches(utc.Day) && _dayOfWeek.Matches((int)utc.DayOfWeek);
|
||
|
||
/// <summary>One field of a cron expression, as a fixed-size allowed-value set.</summary>
|
||
private sealed class CronField
|
||
{
|
||
private readonly int _min;
|
||
private readonly bool[] _allowed;
|
||
|
||
private CronField(int min, bool[] allowed)
|
||
{
|
||
_min = min;
|
||
_allowed = allowed;
|
||
}
|
||
|
||
public bool Matches(int value) => value >= _min && value - _min < _allowed.Length && _allowed[value - _min];
|
||
|
||
/// <summary>Folds one value into another (Sunday alias 7 → 0).</summary>
|
||
public void AliasValue(int from, int to)
|
||
{
|
||
if (from >= _min && from - _min < _allowed.Length)
|
||
_allowed[to - _min] |= _allowed[from - _min];
|
||
}
|
||
|
||
public static bool TryParse(string text, int min, int max, out CronField? field, out string? error)
|
||
{
|
||
field = null;
|
||
error = null;
|
||
|
||
var allowed = new bool[max - min + 1];
|
||
foreach (var part in text.Split(','))
|
||
{
|
||
if (part.Length == 0)
|
||
{
|
||
error = $"empty cron field segment in '{text}'";
|
||
return false;
|
||
}
|
||
|
||
var step = 1;
|
||
var range = part;
|
||
if (part.Contains('/'))
|
||
{
|
||
var pieces = part.Split('/');
|
||
if (pieces.Length != 2 || !TryParseNumber(pieces[1], out step) || step <= 0)
|
||
{
|
||
error = $"invalid step in cron field segment '{part}'";
|
||
return false;
|
||
}
|
||
range = pieces[0];
|
||
}
|
||
|
||
int lo, hi;
|
||
if (range == "*")
|
||
{
|
||
lo = min;
|
||
hi = max;
|
||
}
|
||
else if (range.Contains('-'))
|
||
{
|
||
var pieces = range.Split('-');
|
||
if (pieces.Length != 2 || !TryParseNumber(pieces[0], out lo) || !TryParseNumber(pieces[1], out hi))
|
||
{
|
||
error = $"invalid range in cron field segment '{part}'";
|
||
return false;
|
||
}
|
||
}
|
||
else if (TryParseNumber(range, out lo))
|
||
{
|
||
hi = lo;
|
||
}
|
||
else
|
||
{
|
||
error = $"invalid value '{range}' in cron field";
|
||
return false;
|
||
}
|
||
|
||
if (lo < min || hi > max || lo > hi)
|
||
{
|
||
error = $"value out of range [{min},{max}] in cron field segment '{part}'";
|
||
return false;
|
||
}
|
||
|
||
for (var value = lo; value <= hi; value += step)
|
||
allowed[value - min] = true;
|
||
}
|
||
|
||
field = new CronField(min, allowed);
|
||
return true;
|
||
}
|
||
|
||
private static bool TryParseNumber(string text, out int value)
|
||
=> int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||
}
|
||
}
|