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

Information
Class: AsyncResponse.ScheduledFlowService
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/ScheduledFlows.cs
Line coverage
72%
Covered lines: 147
Uncovered lines: 56
Coverable lines: 203
Total lines: 548
Line coverage: 72.4%
Branch coverage
74%
Covered branches: 64
Total branches: 86
Branch coverage: 74.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
ExecuteAsync()50%171059.25%
get_FlowId()100%11100%
get_Occurrence()100%11100%
get_DueUtc()100%11100%
get_AwaitingFirstPublish()100%11100%
RunScheduleAsync()76.92%312681.08%
StartOccurrenceAsync()100%1191.3%
Enqueue(...)50%8661.11%
RedriveDueAsync()83.33%6681.81%
RedriveAsync()62.5%27833.33%
ProbeUndispatchedAtStartupAsync()90%111080.95%
RecentOccurrences(...)93.75%1616100%
OccurrenceFlowId(...)100%11100%

File(s)

/_/src/AsyncResponse.Core/ScheduledFlows.cs

#LineLine coverage
 1using System.Globalization;
 2using Microsoft.Extensions.Hosting;
 3using Microsoft.Extensions.Logging;
 4
 5namespace AsyncResponse;
 6
 7/// <summary>Per-schedule options for <c>WithScheduledFlow</c>.</summary>
 8public sealed class ScheduledFlowOptions
 9{
 10    /// <summary>The time zone the cron expression is evaluated in. Default: UTC.</summary>
 11    public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Utc;
 12
 13    /// <summary>
 14    /// Whether this schedule runs. Default: <c>true</c>. Set <c>false</c> to keep the registration
 15    /// (and its flow-type routing) while pausing new occurrences — e.g. per environment.
 16    /// </summary>
 17    public bool Enabled { get; set; } = true;
 18
 19    /// <summary>
 20    /// How long the scheduler waits between attempts to re-drive an occurrence whose start job
 21    /// could not be published (a broker outage outlasting the start's own in-process retry
 22    /// ladder). The publish is the start's commit point, so such an occurrence has <em>no
 23    /// ledger</em> yet; a re-drive is the same idempotent start — it publishes the job, and the
 24    /// executor creates the run from it — and repeats at this interval until the job is published
 25    /// or the run is seen to exist and to have executed (another replica started it). Default:
 26    /// 30 seconds.
 27    /// </summary>
 28    public TimeSpan RedriveInterval { get; set; } = TimeSpan.FromSeconds(30);
 29
 30    /// <summary>
 31    /// How far back the scheduler looks at startup for occurrences of this schedule whose ledger
 32    /// exists, is still <see cref="FlowRunStatus.Running"/>, and has never been executed
 33    /// (<see cref="FlowState.Attempts"/> is zero) — the shape a process crash between the ledger
 34    /// commit and the job publish leaves behind, and the shape an in-process re-drive queue lost
 35    /// with its process. Each such occurrence is re-driven (at most the 64 most recent in the
 36    /// window). A run that is merely queued behind a busy worker looks the same and is re-driven
 37    /// too, harmlessly: the duplicate wake-up is deduplicated by the execution lease. Default:
 38    /// 1 hour; zero disables the probe.
 39    /// </summary>
 40    public TimeSpan StartupRedriveWindow { get; set; } = TimeSpan.FromHours(1);
 41}
 42
 43/// <summary>
 44/// One registered cron schedule: the parsed-on-registration expression, its options, and the
 45/// statically-typed start route captured at registration (AOT-safe — no type names, no reflection).
 46/// </summary>
 47internal sealed class ScheduledFlowRegistration
 48{
 49    public required string Name { get; init; }
 50    public required string CronExpression { get; init; }
 51    public required ScheduledFlowOptions Options { get; init; }
 52    public required Func<IDurableFlows, string, DateTimeOffset, CancellationToken, Task> StartOccurrenceAsync { get; ini
 53}
 54
 55/// <summary>
 56/// Hosted scheduler for <c>WithScheduledFlow</c> registrations. One loop per schedule computes the
 57/// next occurrence in the schedule's time zone, sleeps on the engine clock, and starts the flow
 58/// with a <b>deterministic occurrence id</b> (<c>sched:{name}:{occurrenceUtc}</c>).
 59/// <para>
 60/// <b>Exactly-once per occurrence across replicas, with no coordinator:</b> every replica runs the
 61/// same loop and computes the same occurrence id; the flow store's atomic create accepts exactly
 62/// one, and the losers re-enqueue the same run (a duplicate wake-up the execution lease already
 63/// dedups). Occurrences missed while every replica was down are <em>skipped</em> — on restart the
 64/// loop resumes from "now", by design (an at-most-once schedule; the run history shows the gap).
 65/// A late timer fire (seconds) still starts its own occurrence — only occurrences whose successor
 66/// is already due are skipped.
 67/// </para>
 68/// <para>
 69/// <b>A due occurrence whose start could not be published is never abandoned.</b> Skipping
 70/// applies only to occurrences the loop never reached. An occurrence whose start job could not be
 71/// published (<see cref="DurableFlowNotDispatchedException"/>: the broker outage outlasted the
 72/// start's retry ladder — nothing was persisted, the publish is the start's commit point) is kept
 73/// in an in-process re-drive queue and its idempotent start repeated every
 74/// <see cref="ScheduledFlowOptions.RedriveInterval"/> until the job is published. Because that
 75/// queue dies with the process, each loop also probes
 76/// <see cref="ScheduledFlowOptions.StartupRedriveWindow"/> of recent occurrences at startup and
 77/// re-drives any whose ledger is Running with zero attempts — a run whose wake-up was lost in
 78/// transit (an early-ACK worker subscriber, a broker that dropped it) and that nothing else will
 79/// find.
 80/// </para>
 81/// </summary>
 2882internal sealed class ScheduledFlowService(
 2883    IDurableFlows _flows,
 2884    IEnumerable<ScheduledFlowRegistration> _registrations,
 2885    ILogger<ScheduledFlowService> _logger,
 2886    TimeProvider? _timeProvider = null) : BackgroundService
 87{
 88    /// <summary>
 89    /// Longest single sleep between checks. Chunking keeps every armed delay far under the BCL
 90    /// timer ceiling and re-reads the clock hourly, so a suspended laptop's clock jump is honored
 91    /// within an hour instead of after a season. Time-zone RULES are not re-read while waiting:
 92    /// occurrences convert through the <see cref="TimeZoneInfo"/> captured at parse, whose
 93    /// adjustment rules are immutable (and the BCL caches OS zone data for the process lifetime),
 94    /// so an OS tz-database update only takes effect after a process restart.
 95    /// </summary>
 296    private static readonly TimeSpan MaxSleepChunk = TimeSpan.FromHours(1);
 97
 98    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 99    {
 28100        var registrations = _registrations.ToArray();
 28101        if (registrations.Length == 0)
 0102            return;
 103
 104        // WithScheduledFlow rejects duplicate names at registration; this is the defensive backstop
 105        // for hand-registered ScheduledFlowRegistration instances. A BackgroundService fault would
 106        // surface only in logs, so log-and-drop the duplicates rather than half-starting.
 112107        var duplicate = registrations.GroupBy(r => r.Name, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
 28108        if (duplicate is not null)
 109        {
 0110            _logger.LogError(
 0111                "Two scheduled flows share the name '{Schedule}'. Schedule names key the deterministic occurrence ids; n
 0112                duplicate.Key);
 0113            return;
 114        }
 115
 28116        var loops = registrations
 28117            .Where(registration =>
 28118            {
 42119                if (registration.Options.Enabled)
 40120                    return true;
 28121
 2122                _logger.LogInformation("Scheduled flow '{Schedule}' is disabled; not scheduling occurrences.", registrat
 2123                return false;
 28124            })
 40125            .Select(registration => RunScheduleAsync(registration, stoppingToken))
 28126            .ToArray();
 127
 128        // Fail fast: Task.WhenAll would sit on one faulted loop until every other loop ended at
 129        // shutdown, degrading "fail the host" into a silently dead schedule while the app reports
 130        // healthy. Await completions one at a time so the first fault propagates immediately.
 28131        var pending = new List<Task>(loops);
 68132        while (pending.Count > 0)
 133        {
 40134            var finished = await Task.WhenAny(pending).ConfigureAwait(false);
 40135            pending.Remove(finished);
 40136            if (finished.IsFaulted)
 137            {
 138                // The first fault propagates and fails the host; the sibling loops keep running
 139                // until shutdown cancels them, no longer awaited by anyone. Observe their outcomes
 140                // so a second fault in the same outage window is logged with its exception instead
 141                // of dying as a finalizer-time unobserved-task event with no schedule context.
 0142                foreach (var sibling in pending)
 143                {
 0144                    _ = sibling.ContinueWith(
 0145                        static (task, state) => ((ILogger)state!).LogError(
 0146                            task.Exception?.GetBaseException(),
 0147                            "A scheduled-flow loop faulted while the scheduler was already failing."),
 0148                        _logger,
 0149                        CancellationToken.None,
 0150                        TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
 0151                        TaskScheduler.Default);
 152                }
 153            }
 154
 40155            await finished.ConfigureAwait(false);
 156        }
 28157    }
 158
 159    /// <summary>
 160    /// Upper bound on the in-process re-drive queue per schedule. An outage long enough to queue
 161    /// this many undispatched occurrences (a per-minute schedule down for four hours) is an
 162    /// operator incident; beyond it the OLDEST entries are dropped with an error log naming the
 163    /// flow id, so they stay re-drivable by hand.
 164    /// </summary>
 165    internal const int MaxUndispatchedOccurrences = 256;
 166
 167    /// <summary>Most recent occurrences inside <see cref="ScheduledFlowOptions.StartupRedriveWindow"/> the startup prob
 168    internal const int MaxStartupProbes = 64;
 169
 170    private sealed class UndispatchedOccurrence
 171    {
 36172        public required string FlowId { get; init; }
 16173        public required DateTimeOffset Occurrence { get; init; }
 28174        public required DateTimeOffset DueUtc { get; set; }
 175
 176        /// <summary>
 177        /// <c>true</c> when the occurrence's start job has never been published: the start's
 178        /// publish is its commit point, so NO ledger exists for it (the shape a
 179        /// <see cref="DurableFlowNotDispatchedException"/> leaves behind). A re-drive that finds no
 180        /// ledger must then start the occurrence again, not conclude that its run has expired.
 181        /// <c>false</c> for an entry the startup probe queued from an EXISTING never-executed
 182        /// ledger, where an absent ledger on re-drive really does mean expired or deleted.
 183        /// </summary>
 12184        public required bool AwaitingFirstPublish { get; init; }
 185    }
 186
 187    private enum RedriveOutcome
 188    {
 189        /// <summary>The wake-up is published, or the run no longer needs one; drop the entry.</summary>
 190        Settled,
 191
 192        /// <summary>Still undispatched; try again after <see cref="ScheduledFlowOptions.RedriveInterval"/>.</summary>
 193        Retry
 194    }
 195
 196    private async Task RunScheduleAsync(ScheduledFlowRegistration registration, CancellationToken stoppingToken)
 197    {
 40198        var timeProvider = _timeProvider ?? TimeProvider.System;
 199        CronSchedule schedule;
 200        try
 201        {
 40202            schedule = CronSchedule.Parse(registration.CronExpression, registration.Options.TimeZone);
 40203        }
 0204        catch (FormatException ex)
 205        {
 206            // Registration already validated the expression; reaching this means the expression
 207            // text was mutated afterwards. Fail the host rather than silently never firing.
 0208            throw new InvalidOperationException($"Scheduled flow '{registration.Name}' has an invalid cron expression.",
 209        }
 210
 40211        var next = schedule.GetNextOccurrence(timeProvider.GetUtcNow());
 40212        _logger.LogInformation(
 40213            "Scheduled flow '{Schedule}' ({Cron}, {TimeZone}): first occurrence at {NextOccurrence}.",
 40214            registration.Name, registration.CronExpression, registration.Options.TimeZone.Id, next);
 215
 40216        var undispatched = new List<UndispatchedOccurrence>();
 217        try
 218        {
 40219            await ProbeUndispatchedAtStartupAsync(registration, schedule, undispatched, timeProvider, stoppingToken).Con
 220
 155221            while (!stoppingToken.IsCancellationRequested)
 222            {
 155223                var now = timeProvider.GetUtcNow();
 155224                if (next is { } occurrence && occurrence <= now)
 225                {
 22226                    if (!await StartOccurrenceAsync(registration, occurrence, stoppingToken).ConfigureAwait(false))
 4227                        Enqueue(undispatched, registration, occurrence, timeProvider.GetUtcNow());
 228
 229                    // Strictly after the fired occurrence, then skip anything already due (missed
 230                    // occurrences are dropped by policy, not replayed in a burst).
 22231                    var resumeFrom = timeProvider.GetUtcNow();
 22232                    next = schedule.GetNextOccurrence(occurrence > resumeFrom ? occurrence : resumeFrom);
 22233                    continue;
 234                }
 235
 133236                await RedriveDueAsync(registration, undispatched, timeProvider, stoppingToken).ConfigureAwait(false);
 237
 133238                if (next is null && undispatched.Count == 0)
 239                {
 0240                    _logger.LogWarning(
 0241                        "Scheduled flow '{Schedule}' ({Cron}) has no future occurrence (unsatisfiable expression); stopp
 0242                        registration.Name, registration.CronExpression);
 0243                    return;
 244                }
 245
 246                // Wake for whichever comes first: the next occurrence or the earliest re-drive.
 133247                var wakeAt = next ?? DateTimeOffset.MaxValue;
 274248                foreach (var entry in undispatched)
 249                {
 4250                    if (entry.DueUtc < wakeAt)
 4251                        wakeAt = entry.DueUtc;
 252                }
 253
 133254                now = timeProvider.GetUtcNow();
 133255                if (wakeAt > now)
 256                {
 133257                    var sleep = wakeAt - now;
 133258                    await Task.Delay(sleep <= MaxSleepChunk ? sleep : MaxSleepChunk, timeProvider, stoppingToken).Config
 259                }
 260            }
 0261        }
 40262        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 263        {
 264            // Host shutdown.
 40265        }
 40266    }
 267
 268    /// <summary>
 269    /// Starts one occurrence. Returns <c>false</c> only when the occurrence's ledger is committed
 270    /// but its worker job was not published — the one outcome the loop must keep re-driving.
 271    /// </summary>
 272    private async Task<bool> StartOccurrenceAsync(
 273        ScheduledFlowRegistration registration,
 274        DateTimeOffset occurrence,
 275        CancellationToken stoppingToken)
 276    {
 22277        var flowId = OccurrenceFlowId(registration.Name, occurrence);
 278        try
 279        {
 22280            await registration.StartOccurrenceAsync(_flows, flowId, occurrence, stoppingToken).ConfigureAwait(false);
 14281            _logger.LogInformation("Scheduled flow '{Schedule}' started occurrence {FlowId}.", registration.Name, flowId
 14282        }
 0283        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 284        {
 0285            throw;
 286        }
 2287        catch (DurableFlowIdConflictException ex)
 288        {
 289            // The deterministic id already exists with a DIFFERENT input: another replica won the
 290            // create with a value this replica's input factory did not reproduce. The occurrence
 291            // ran (exactly once) — only the factory's determinism is at fault, so say exactly that.
 292            // ONLY the dedicated conflict type gets this benign reading: the delegate also runs
 293            // the user's input factory, and a plain InvalidOperationException from it (or from the
 294            // store) means nothing was started — that is the generic failure logged below.
 2295            _logger.LogWarning(
 2296                ex,
 2297                "Scheduled flow '{Schedule}' occurrence {FlowId} was already started with different input — the input fa
 2298                registration.Name, flowId);
 2299        }
 4300        catch (DurableFlowNotDispatchedException ex)
 301        {
 302            // The start's publish failed after retries, so the occurrence was NOT started (the
 303            // publish is the start's commit point; nothing was persisted). Unlike a plain failure
 304            // this one is worth re-driving on its own: the id is deterministic and the start
 305            // idempotent, so repeating it publishes the job once the broker is back — and if the
 306            // publish had landed ambiguously, the same id dedupes against the run it created.
 4307            _logger.LogError(
 4308                ex,
 4309                "Scheduled flow '{Schedule}' could not publish the start job for occurrence {FlowId}; the occurrence is 
 4310                registration.Name, flowId, registration.Options.RedriveInterval);
 4311            return false;
 312        }
 2313        catch (Exception ex)
 314        {
 315            // A failed start (store or transport outage) is this occurrence's loss only; the loop
 316            // lives on for the next one. Another replica may still have started it.
 2317            _logger.LogError(ex, "Scheduled flow '{Schedule}' failed to start occurrence {FlowId}.", registration.Name, 
 2318        }
 319
 18320        return true;
 22321    }
 322
 323    private void Enqueue(List<UndispatchedOccurrence> undispatched, ScheduledFlowRegistration registration, DateTimeOffs
 324    {
 4325        var flowId = OccurrenceFlowId(registration.Name, occurrence);
 8326        foreach (var existing in undispatched)
 327        {
 0328            if (string.Equals(existing.FlowId, flowId, StringComparison.Ordinal))
 0329                return;
 330        }
 331
 4332        while (undispatched.Count >= MaxUndispatchedOccurrences)
 333        {
 0334            var dropped = undispatched[0];
 0335            undispatched.RemoveAt(0);
 0336            _logger.LogError(
 0337                "Scheduled flow '{Schedule}' has {Count} undispatched occurrences queued for re-drive; dropping the olde
 0338                registration.Name, MaxUndispatchedOccurrences, dropped.FlowId);
 339        }
 340
 4341        undispatched.Add(new UndispatchedOccurrence
 4342        {
 4343            FlowId = flowId,
 4344            Occurrence = occurrence,
 4345            DueUtc = now + registration.Options.RedriveInterval,
 4346            AwaitingFirstPublish = true
 4347        });
 4348    }
 349
 350    private async Task RedriveDueAsync(
 351        ScheduledFlowRegistration registration,
 352        List<UndispatchedOccurrence> undispatched,
 353        TimeProvider timeProvider,
 354        CancellationToken stoppingToken)
 355    {
 278356        for (var i = 0; i < undispatched.Count;)
 357        {
 12358            var entry = undispatched[i];
 12359            if (entry.DueUtc > timeProvider.GetUtcNow())
 360            {
 4361                i++;
 4362                continue;
 363            }
 364
 8365            if (await RedriveAsync(registration, entry, stoppingToken).ConfigureAwait(false) == RedriveOutcome.Retry)
 366            {
 0367                entry.DueUtc = timeProvider.GetUtcNow() + registration.Options.RedriveInterval;
 0368                i++;
 369            }
 370            else
 371            {
 8372                undispatched.RemoveAt(i);
 373            }
 8374        }
 133375    }
 376
 377    private async Task<RedriveOutcome> RedriveAsync(
 378        ScheduledFlowRegistration registration,
 379        UndispatchedOccurrence entry,
 380        CancellationToken stoppingToken)
 381    {
 382        FlowState? state;
 383        try
 384        {
 8385            state = await _flows.GetStateAsync(entry.FlowId, stoppingToken).ConfigureAwait(false);
 8386        }
 0387        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 388        {
 0389            throw;
 390        }
 0391        catch (Exception ex)
 392        {
 0393            _logger.LogWarning(ex, "Scheduled flow '{Schedule}' could not load occurrence {FlowId} to re-drive it; retry
 0394            return RedriveOutcome.Retry;
 395        }
 396
 8397        if (state is null)
 398        {
 4399            if (!entry.AwaitingFirstPublish)
 400            {
 0401                _logger.LogWarning("Scheduled flow '{Schedule}' occurrence {FlowId} no longer has a ledger (expired or d
 0402                return RedriveOutcome.Settled;
 403            }
 404
 405            // Publish-first start: the failed publish persisted NOTHING, so "no ledger" is the
 406            // expected shape of an occurrence still waiting for its first successful publish — not
 407            // evidence that its run expired. Settling here (the pre-fix reading, written for the
 408            // old create-then-publish order) permanently lost every occurrence that fell due during
 409            // a broker outage: the queue held the id, the ledger it looked for had never existed,
 410            // and the startup probe cannot find a run that was never persisted either.
 4411            _logger.LogInformation("Scheduled flow '{Schedule}' occurrence {FlowId} has no ledger because its start job 
 412        }
 4413        else if (state.Status != FlowRunStatus.Running || state.Attempts > 0)
 414        {
 415            // Another replica re-drove it (or its own wake-up arrived after all) and the run
 416            // executed: nothing left to publish.
 0417            _logger.LogInformation("Scheduled flow '{Schedule}' occurrence {FlowId} has been picked up ({Status}, {Attem
 0418            return RedriveOutcome.Settled;
 419        }
 420
 421        try
 422        {
 8423            await registration.StartOccurrenceAsync(_flows, entry.FlowId, entry.Occurrence, stoppingToken).ConfigureAwai
 8424            _logger.LogInformation("Scheduled flow '{Schedule}' re-drove occurrence {FlowId}: its worker job is publishe
 8425            return RedriveOutcome.Settled;
 426        }
 0427        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 428        {
 0429            throw;
 430        }
 0431        catch (DurableFlowNotDispatchedException ex)
 432        {
 0433            _logger.LogWarning(ex, "Scheduled flow '{Schedule}' could not publish the worker job for occurrence {FlowId}
 0434            return RedriveOutcome.Retry;
 435        }
 0436        catch (DurableFlowIdConflictException ex)
 437        {
 0438            _logger.LogWarning(ex, "Scheduled flow '{Schedule}' occurrence {FlowId} cannot be re-driven: the input facto
 0439            return RedriveOutcome.Settled;
 440        }
 0441        catch (Exception ex)
 442        {
 0443            _logger.LogError(ex, "Scheduled flow '{Schedule}' failed to re-drive occurrence {FlowId}; retrying after {Re
 0444            return RedriveOutcome.Retry;
 445        }
 8446    }
 447
 448    /// <summary>
 449    /// Finds recent occurrences whose ledger is committed and Running with zero attempts — never
 450    /// executed — and queues them for an immediate re-drive: a start whose job was published and
 451    /// then lost in transit (an early-ACK worker subscriber, a broker that dropped it), which
 452    /// nothing else would ever look for, because the store has no enumeration. The in-process
 453    /// re-drive queue above does not survive a restart, and an occurrence whose publish was still
 454    /// failing when the process died left nothing persisted — this probe cannot find it either, so
 455    /// it is skipped like any other occurrence missed while no replica was up (documented; the run
 456    /// history shows the gap). Best-effort: a failed load ends the probe (the loop starts
 457    /// regardless).
 458    /// </summary>
 459    private async Task ProbeUndispatchedAtStartupAsync(
 460        ScheduledFlowRegistration registration,
 461        CronSchedule schedule,
 462        List<UndispatchedOccurrence> undispatched,
 463        TimeProvider timeProvider,
 464        CancellationToken stoppingToken)
 465    {
 40466        var window = registration.Options.StartupRedriveWindow;
 40467        if (window <= TimeSpan.Zero)
 0468            return;
 469
 40470        var now = timeProvider.GetUtcNow();
 40471        var candidates = RecentOccurrences(schedule, now, window, MaxStartupProbes);
 472
 2171473        foreach (var occurrence in candidates)
 474        {
 1046475            var flowId = OccurrenceFlowId(registration.Name, occurrence);
 476            FlowState? state;
 477            try
 478            {
 1046479                state = await _flows.GetStateAsync(flowId, stoppingToken).ConfigureAwait(false);
 1045480            }
 1481            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 482            {
 1483                throw;
 484            }
 0485            catch (Exception ex)
 486            {
 0487                _logger.LogWarning(ex, "Scheduled flow '{Schedule}' could not probe occurrence {FlowId} for an undispatc
 0488                return;
 489            }
 490
 1045491            if (state is not { Status: FlowRunStatus.Running, Attempts: 0 })
 492                continue;
 493
 4494            _logger.LogWarning(
 4495                "Scheduled flow '{Schedule}' found occurrence {FlowId} committed but never executed (Running, 0 attempts
 4496                registration.Name, flowId);
 4497            undispatched.Add(new UndispatchedOccurrence { FlowId = flowId, Occurrence = occurrence, DueUtc = now, Awaiti
 4498        }
 39499    }
 500
 501    /// <summary>First look-back <see cref="RecentOccurrences"/> tries; doubled until it holds enough occurrences.</summ
 2502    private static readonly TimeSpan InitialProbeLookback = TimeSpan.FromHours(1);
 503
 504    /// <summary>
 505    /// The <paramref name="max"/> most recent occurrences in <c>(now - window, now]</c>, oldest
 506    /// first. The schedule only enumerates FORWARD, and the probe used to walk the whole window
 507    /// from its far end to find the few occurrences at its near end: <paramref name="max"/>
 508    /// bounded the list, not the walk, and <c>StartupRedriveWindow</c> has no ceiling — a
 509    /// per-minute schedule with a one-year window cost 525,600 cron evaluations at every startup
 510    /// (29 million when the window reached the epoch clamp), all but 64 results discarded, in a
 511    /// synchronous stretch ahead of the schedule's first occurrence. The look-back now starts
 512    /// small and doubles until it holds <paramref name="max"/> occurrences or covers the window,
 513    /// so the walk is proportional to what the probe keeps: a dense schedule stops after the
 514    /// first hour or two, and a sparse one reaches the full window in about twenty doublings of
 515    /// near-empty walks. The most recent occurrences of a shorter look-back are, by construction,
 516    /// the most recent occurrences of the whole window.
 517    /// </summary>
 518    internal static List<DateTimeOffset> RecentOccurrences(CronSchedule schedule, DateTimeOffset now, TimeSpan window, i
 519    {
 56520        var sinceEpoch = now - DateTimeOffset.UnixEpoch;
 56521        var fullLookback = window >= sinceEpoch ? sinceEpoch : window;
 56522        var lookback = fullLookback < InitialProbeLookback ? fullLookback : InitialProbeLookback;
 523
 160524        while (true)
 525        {
 216526            var candidates = new List<DateTimeOffset>();
 216527            var cursor = now - lookback;
 7256528            while (schedule.GetNextOccurrence(cursor) is { } occurrence && occurrence <= now)
 529            {
 7040530                candidates.Add(occurrence);
 7040531                if (candidates.Count > max)
 3872532                    candidates.RemoveAt(0);
 7040533                cursor = occurrence;
 7040534            }
 535
 216536            if (candidates.Count >= max || lookback >= fullLookback)
 56537                return candidates;
 538
 160539            lookback = lookback > fullLookback - lookback ? fullLookback : lookback + lookback;
 540        }
 541    }
 542
 543    internal static string OccurrenceFlowId(string name, DateTimeOffset occurrence)
 544        // Invariant culture: interpolation formats with CurrentCulture, whose default calendar can
 545        // rewrite the digits (Buddhist 25730615, UmAlQura 14520214) — replicas with different
 546        // cultures would then mint different ids for the same occurrence and both would run.
 1108547        => string.Create(CultureInfo.InvariantCulture, $"sched:{name}:{occurrence.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}");
 548}