< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.CronSchedule
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/CronSchedule.cs
Line coverage
96%
Covered lines: 152
Uncovered lines: 5
Coverable lines: 157
Total lines: 382
Line coverage: 96.8%
Branch coverage
95%
Covered branches: 92
Total branches: 96
Branch coverage: 95.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Expression()100%210%
get_TimeZone()100%11100%
.ctor(...)100%11100%
Parse(...)100%66100%
IsStarShaped()100%22100%
GetNextOccurrence(...)100%44100%
Scan(...)91.66%242491.42%
MinuteMatches(...)100%11100%
HourMatches(...)100%11100%
MonthMatches(...)100%11100%
DayMatches(...)100%44100%
.cctor()100%11100%
ParseField(...)93.75%323296.55%
ParseValue(...)100%1616100%
RangeMask(...)100%88100%
Invalid(...)100%11100%

File(s)

/_/src/AsyncResponse.Core/CronSchedule.cs

#LineLine coverage
 1using System.Globalization;
 2
 3namespace AsyncResponse;
 4
 5/// <summary>
 6/// A parsed five-field cron expression (<c>minute hour day-of-month month day-of-week</c>) with an
 7/// optional time zone, used by <c>WithScheduledFlow</c> to start flows on a schedule.
 8/// <para>
 9/// Supported syntax per field: <c>*</c>, single values, lists (<c>1,15</c>), ranges (<c>1-5</c>,
 10/// wrap-around <c>22-2</c> included), steps (<c>*/15</c>, <c>10-40/5</c>, <c>8/2</c>), and names
 11/// (<c>JAN…DEC</c>, <c>SUN…SAT</c>, case-insensitive). <c>?</c> is accepted as <c>*</c> in the two
 12/// day fields. Day-of-month and day-of-week combine with classic Vixie-cron semantics: when both
 13/// fields are explicitly restricted (neither starts with <c>*</c>), a date matches if <em>either</em>
 14/// matches; otherwise both masks must match — a star-step field such as <c>*/2</c> stays out of the
 15/// either/or rule (exactly as Vixie's <c>DOM_STAR</c>/<c>DOW_STAR</c> flags keep it) while its step
 16/// mask still applies. Day-of-week accepts <c>0</c> and <c>7</c> as Sunday.
 17/// </para>
 18/// <para>
 19/// Occurrences are computed minute-aligned in the schedule's time zone (default UTC) and returned
 20/// as UTC instants. Around daylight-saving transitions: a local occurrence that does not exist
 21/// (spring-forward gap) fires at the moment the clock jumps past it; an occurrence in a repeated
 22/// hour (fall-back) fires on the first (earlier-offset) pass only.
 23/// </para>
 24/// </summary>
 25public sealed class CronSchedule
 26{
 27    private readonly ulong _minutes;      // bits 0..59
 28    private readonly uint _hours;         // bits 0..23
 29    private readonly uint _daysOfMonth;   // bits 1..31
 30    private readonly ushort _months;      // bits 1..12
 31    private readonly byte _daysOfWeek;    // bits 0..6 (Sunday = 0)
 32    private readonly bool _dayOfMonthRestricted;
 33    private readonly bool _dayOfWeekRestricted;
 34
 35    /// <summary>The original expression text.</summary>
 036    public string Expression { get; }
 37
 38    /// <summary>The time zone the schedule is evaluated in (default UTC).</summary>
 6945239    public TimeZoneInfo TimeZone { get; }
 40
 22641    private CronSchedule(
 22642        string expression,
 22643        TimeZoneInfo timeZone,
 22644        ulong minutes,
 22645        uint hours,
 22646        uint daysOfMonth,
 22647        ushort months,
 22648        byte daysOfWeek,
 22649        bool dayOfMonthRestricted,
 22650        bool dayOfWeekRestricted)
 51    {
 22652        Expression = expression;
 22653        TimeZone = timeZone;
 22654        _minutes = minutes;
 22655        _hours = hours;
 22656        _daysOfMonth = daysOfMonth;
 22657        _months = months;
 22658        _daysOfWeek = daysOfWeek;
 22659        _dayOfMonthRestricted = dayOfMonthRestricted;
 22660        _dayOfWeekRestricted = dayOfWeekRestricted;
 22661    }
 62
 63    /// <summary>Parses a five-field cron expression; throws <see cref="FormatException"/> with the offending field on i
 64    public static CronSchedule Parse(string expression, TimeZoneInfo? timeZone = null)
 65    {
 25666        ArgumentException.ThrowIfNullOrWhiteSpace(expression);
 67
 25468        var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 25469        if (fields.Length != 5)
 470            throw new FormatException($"Cron expression '{expression}' must have exactly 5 fields (minute hour day-of-mo
 71
 25072        var minutes = ParseField(expression, fields[0], 0, 59, names: null, allowQuestionMark: false);
 23873        var hours = ParseField(expression, fields[1], 0, 23, names: null, allowQuestionMark: false);
 23674        var daysOfMonth = ParseField(expression, fields[2], 1, 31, names: null, allowQuestionMark: true);
 23275        var months = ParseField(expression, fields[3], 1, 12, MonthNames, allowQuestionMark: false);
 76        // wrapCycle 7, not the 8-slot 0..7 span: slots 0 and 7 are both Sunday, so a stride that
 77        // wraps past Saturday must fold back by one real week or it lands a day early.
 22878        var daysOfWeek = ParseField(expression, fields[4], 0, 7, DayNames, allowQuestionMark: true, wrapCycle: 7);
 79
 80        // 7 is Sunday too; fold it onto bit 0 so matching only ever looks at bits 0..6.
 22681        if ((daysOfWeek & (1UL << 7)) != 0)
 18682            daysOfWeek = (daysOfWeek & ~(1UL << 7)) | 1UL;
 83
 22684        var dayOfMonthRestricted = !IsStarShaped(fields[2]);
 22685        var dayOfWeekRestricted = !IsStarShaped(fields[4]);
 86
 22687        return new CronSchedule(
 22688            expression,
 22689            timeZone ?? TimeZoneInfo.Utc,
 22690            minutes,
 22691            (uint)hours,
 22692            (uint)daysOfMonth,
 22693            (ushort)months,
 22694            (byte)daysOfWeek,
 22695            dayOfMonthRestricted,
 22696            dayOfWeekRestricted);
 97
 98        // Vixie sets its DOM_STAR/DOW_STAR flags on any day field whose text starts with '*'
 99        // ("*", "*/2"): such a field stays OUT of the either/or rule below, but its step mask
 100        // still applies — DayMatches ANDs the two masks unless BOTH fields are explicitly
 101        // restricted. That keeps "0 0 */2 * *" at every other day (not every day) without turning
 102        // "0 0 */2 * FRI" into odd-days-OR-Fridays (crontab(5): odd Fridays only).
 103        // '?' is documented as a synonym for '*' in the day fields, so it carries the flag on the
 104        // same terms — including when stepped: "?/2" must behave exactly like "*/2", not fall
 105        // through to the explicitly-restricted branch and silently flip the dom/dow rule to OR.
 452106        static bool IsStarShaped(string field) => field.StartsWith('*') || field.StartsWith('?');
 107    }
 108
 109    /// <summary>
 110    /// Returns the first occurrence strictly after <paramref name="afterUtc"/>, as a UTC instant,
 111    /// or <c>null</c> when the expression can never fire (an impossible date such as "Feb 30").
 112    /// </summary>
 113    public DateTimeOffset? GetNextOccurrence(DateTimeOffset afterUtc)
 114    {
 115        // Work minute-aligned in schedule-local time: advance to the next whole minute after
 116        // `afterUtc`, then scan forward. The scan is bounded, not clever — correctness and DST
 117        // honesty beat arithmetic shortcuts at one iteration per minute only in the worst field.
 17366118        var local = TimeZoneInfo.ConvertTime(afterUtc, TimeZone);
 17366119        var minuteAligned = new DateTime(local.Year, local.Month, local.Day, local.Hour, local.Minute, 0, DateTimeKind.U
 17366120        if (minuteAligned >= LastRepresentableMinute)
 2121            return null; // No whole minute after this one exists to schedule.
 122
 17364123        var candidate = minuteAligned.AddMinutes(1);
 124
 125        // Horizon: 400 Gregorian years — a full calendar cycle (146 097 days, exactly 20 871
 126        // weeks), so any (month, day, weekday) combination the calendar ever produces occurs
 127        // within the next 400 years of ANY start date. A miss is therefore a completeness proof
 128        // of unsatisfiability, not a heuristic: "0 0 29 2 */7" (Feb 29 on a Sunday, gaps of up to
 129        // 40 years around skipped century leap days) resolves; "Feb 30" is proven impossible.
 130        // The scan stays cheap because misses skip by month/day (an impossible date walks a few
 131        // dozen candidates per year, not half a million minutes). The horizon saturates at
 132        // DateTime.MaxValue rather than being pulled BACK to a fixed cap: a cap below the
 133        // candidate would end the loop before its first iteration and report even "* * * * *"
 134        // as unsatisfiable.
 17364135        var horizon = candidate < HorizonCap ? candidate.AddYears(400) : DateTime.MaxValue;
 136
 137        try
 138        {
 17364139            return Scan(candidate, horizon, afterUtc);
 140        }
 2141        catch (ArgumentOutOfRangeException)
 142        {
 143            // The scan walked off the end of the representable calendar: an advance past
 144            // DateTime.MaxValue, or a matched wall time whose UTC instant (wall minus a positive
 145            // offset) does not exist as a DateTimeOffset. Either way there is no further
 146            // occurrence to return, which is exactly what null means. Nothing else in the scan
 147            // can raise this — the matchers are bit tests and the time-zone lookups take only
 148            // in-range values.
 2149            return null;
 150        }
 17364151    }
 152
 153    private DateTimeOffset? Scan(DateTime candidate, DateTime horizon, DateTimeOffset afterUtc)
 154    {
 718095155        while (candidate < horizon)
 156        {
 718055157            if (!MonthMatches(candidate.Month))
 158            {
 159                // Jump to the first minute of the next month; day scanning below stays in-month.
 177550160                candidate = new DateTime(candidate.Year, candidate.Month, 1, 0, 0, 0, DateTimeKind.Unspecified).AddMonth
 177548161                continue;
 162            }
 163
 540505164            if (!DayMatches(candidate))
 165            {
 462874166                candidate = candidate.Date.AddDays(1);
 462874167                continue;
 168            }
 169
 77631170            if (!HourMatches(candidate.Hour))
 171            {
 12440172                candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day, candidate.Hour, 0, 0, DateTimeK
 12440173                continue;
 174            }
 175
 65191176            if (!MinuteMatches(candidate.Minute))
 177            {
 47869178                candidate = candidate.AddMinutes(1);
 47869179                continue;
 180            }
 181
 182            // A schedule-local match. Map it onto the UTC timeline honoring DST:
 17322183            if (TimeZone.IsInvalidTime(candidate))
 184            {
 185                // Spring-forward gap: the wall-clock time never happens, so the job fires at the
 186                // gap's END — the exact transition instant, the moment the clock jumps past the
 187                // scheduled time (what cron daemons do for jobs the jump skips). That instant is
 188                // the gap's FIRST wall minute interpreted with the offset in force just before
 189                // the gap. Interpreting the candidate minute itself with the pre-gap offset would
 190                // land PAST the transition (02:30 in a 02:00→03:00 jump would fire at 03:30, not
 191                // 03:00) — a later instant that also breaks next-occurrence ordering: it would be
 192                // reported as the future occurrence and then skipped by a re-query inside the
 193                // half-open window. Multiple scheduled minutes inside one gap all collapse onto
 194                // the same transition instant, and the strictly-after filter fires it once.
 6195                var gapStart = candidate;
 126196                while (TimeZone.IsInvalidTime(gapStart.AddMinutes(-1)))
 120197                    gapStart = gapStart.AddMinutes(-1);
 198
 6199                var preTransitionOffset = TimeZone.GetUtcOffset(gapStart.AddMinutes(-1));
 6200                var instant = new DateTimeOffset(gapStart, preTransitionOffset).ToUniversalTime();
 6201                if (instant > afterUtc)
 6202                    return instant;
 203
 0204                candidate = candidate.AddMinutes(1);
 0205                continue;
 206            }
 207
 208            DateTimeOffset occurrence;
 17316209            if (TimeZone.IsAmbiguousTime(candidate))
 210            {
 211                // Fall-back repeat: fire on the FIRST (earlier-offset, typically DST) pass only.
 2212                var offsets = TimeZone.GetAmbiguousTimeOffsets(candidate);
 2213                var first = offsets[0];
 12214                foreach (var offset in offsets)
 215                {
 4216                    if (offset > first)
 2217                        first = offset; // larger UTC offset = earlier UTC instant
 218                }
 219
 2220                occurrence = new DateTimeOffset(candidate, first).ToUniversalTime();
 221            }
 222            else
 223            {
 17314224                occurrence = new DateTimeOffset(candidate, TimeZone.GetUtcOffset(candidate)).ToUniversalTime();
 225            }
 226
 17316227            if (occurrence > afterUtc)
 17316228                return occurrence;
 229
 0230            candidate = candidate.AddMinutes(1);
 231        }
 232
 40233        return null;
 234    }
 235
 65191236    private bool MinuteMatches(int minute) => (_minutes & (1UL << minute)) != 0;
 77631237    private bool HourMatches(int hour) => (_hours & (1U << hour)) != 0;
 718055238    private bool MonthMatches(int month) => (_months & (1 << month)) != 0;
 239
 240    private bool DayMatches(DateTime date)
 241    {
 540505242        var dayOfMonth = (_daysOfMonth & (1U << date.Day)) != 0;
 540505243        var dayOfWeek = (_daysOfWeek & (1 << (int)date.DayOfWeek)) != 0;
 244
 245        // Vixie-cron dom/dow rule: only when BOTH fields are explicitly restricted does either
 246        // match suffice; when either field is star-shaped ("*", "*/N", "?") both masks must match
 247        // (a plain "*" mask is full, so the other field decides alone — the classic behavior).
 540505248        return _dayOfMonthRestricted && _dayOfWeekRestricted
 540505249            ? dayOfMonth || dayOfWeek
 540505250            : dayOfMonth && dayOfWeek;
 251    }
 252
 2253    private static readonly string[] MonthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT",
 2254    private static readonly string[] DayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
 255
 256    // Latest start for which "candidate + 400 years" stays inside DateTime; past it the horizon
 257    // saturates at DateTime.MaxValue instead.
 2258    private static readonly DateTime HorizonCap = DateTime.MaxValue.AddYears(-400);
 259
 260    // The last whole minute DateTime can represent; a start at or past it has no next minute.
 2261    private static readonly DateTime LastRepresentableMinute =
 2262        new(9999, 12, 31, 23, 59, 0, DateTimeKind.Unspecified);
 263
 264    private static ulong ParseField(string expression, string field, int min, int max, string[]? names, bool allowQuesti
 265    {
 1184266        var cycle = wrapCycle ?? (max - min + 1);
 1184267        if (field == "*" || (allowQuestionMark && field == "?"))
 686268            return RangeMask(min, max, min, max, 1, cycle);
 269
 498270        ulong mask = 0;
 1984271        foreach (var part in field.Split(','))
 272        {
 506273            if (part.Length == 0)
 0274                throw Invalid(expression, field, "empty list entry");
 275
 506276            var stepSplit = part.Split('/');
 506277            if (stepSplit.Length > 2)
 2278                throw Invalid(expression, field, $"'{part}' has more than one '/'");
 279
 504280            var step = 1;
 504281            if (stepSplit.Length == 2)
 282            {
 84283                if (!int.TryParse(stepSplit[1], NumberStyles.None, CultureInfo.InvariantCulture, out step) || step <= 0)
 2284                    throw Invalid(expression, field, $"step '{stepSplit[1]}' must be a positive integer");
 285            }
 286
 502287            var rangePart = stepSplit[0];
 288            int low, high;
 502289            if (rangePart == "*" || (allowQuestionMark && rangePart == "?"))
 290            {
 42291                low = min;
 42292                high = max;
 293            }
 294            else
 295            {
 460296                var rangeSplit = rangePart.Split('-');
 460297                if (rangeSplit.Length > 2)
 2298                    throw Invalid(expression, field, $"'{rangePart}' has more than one '-'");
 299
 458300                low = ParseValue(expression, field, rangeSplit[0], min, max, names);
 440301                if (rangeSplit.Length == 2)
 302                {
 52303                    high = ParseValue(expression, field, rangeSplit[1], min, max, names);
 304                }
 388305                else if (stepSplit.Length == 2)
 306                {
 307                    // Vixie extension "N/step": start at N, run to the field maximum.
 8308                    high = max;
 309                }
 310                else
 311                {
 380312                    high = low;
 313                }
 314            }
 315
 482316            mask |= RangeMask(min, max, low, high, step, cycle);
 317        }
 318
 474319        return mask;
 320    }
 321
 322    private static int ParseValue(string expression, string field, string token, int min, int max, string[]? names)
 323    {
 510324        if (names is not null)
 325        {
 1588326            for (var index = 0; index < names.Length; index++)
 327            {
 746328                if (string.Equals(token, names[index], StringComparison.OrdinalIgnoreCase))
 329                {
 330                    // Month names are 1-based (JAN=1); day names 0-based (SUN=0).
 62331                    return names == MonthNames ? index + 1 : index;
 332                }
 333            }
 334        }
 335
 448336        if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var value))
 4337            throw Invalid(expression, field, $"'{token}' is not a number{(names is null ? "" : " or name")}");
 444338        if (value < min || value > max)
 14339            throw Invalid(expression, field, $"'{token}' is outside {min}-{max}");
 430340        return value;
 341    }
 342
 343    private static ulong RangeMask(int min, int max, int low, int high, int step, int cycle)
 344    {
 345        // The accumulators are long: with an int, a step near int.MaxValue overflows `value += step`
 346        // to a negative, and the (six-bit-masked) shift then sets phantom low bits — "1/2147483647"
 347        // would silently gain minute 0. In long arithmetic an oversized step simply runs past `high`
 348        // after the first value, which is exactly Vixie's semantics for steps beyond the field span.
 1168349        ulong mask = 0;
 1168350        if (low <= high)
 351        {
 32844352            for (long value = low; value <= high; value += step)
 15292353                mask |= 1UL << (int)value;
 1130354            return mask;
 355        }
 356
 357        // Wrap-around range (e.g. hours 22-2): low..max then min..high, stepping continuously.
 38358        long position = low;
 96359        while (position <= max)
 360        {
 58361            mask |= 1UL << (int)position;
 58362            position += step;
 363        }
 364
 365        // Continue the stride into the wrapped segment so 50-10/4 keeps its cadence across the
 366        // wrap: fold back by one full cycle of the field's VALUES. For every field but
 367        // day-of-week that equals the slot span; day-of-week has 8 slots for 7 values (0 and 7
 368        // are both Sunday), and folding by the span would credit the duplicate slot as a real
 369        // day — "SAT-MON/2" fired Saturday+Sunday instead of Saturday+Monday.
 38370        position -= cycle;
 80371        while (position <= high)
 372        {
 42373            mask |= 1UL << (int)position;
 42374            position += step;
 375        }
 376
 38377        return mask;
 378    }
 379
 380    private static FormatException Invalid(string expression, string field, string reason)
 24381        => new($"Cron expression '{expression}' has an invalid field '{field}': {reason}.");
 382}