< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Scheduling.SchedulerService
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Scheduling/KestrunScheduler.cs
Tag: Kestrun/Kestrun@9d3a582b2d63930269564a7591aa77ef297cadeb
Line coverage
80%
Covered lines: 168
Uncovered lines: 40
Coverable lines: 208
Total lines: 673
Line coverage: 80.7%
Branch coverage
67%
Covered branches: 63
Total branches: 94
Branch coverage: 67%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
Schedule(...)100%11100%
Schedule(...)100%11100%
Schedule(...)100%11100%
Schedule(...)100%210%
Schedule(...)100%11100%
Schedule(...)100%11100%
Schedule(...)100%11100%
Schedule(...)100%11100%
ScheduleAsync()100%11100%
ScheduleAsync()100%11100%
Cancel(...)71.42%241462.5%
CancelAsync()0%110100%
CancelAll()100%22100%
GetReport(...)50%44100%
GetReportHashtable(...)100%11100%
GetSnapshot()100%11100%
GetSnapshot(...)50%141495.65%
Pause(...)100%11100%
Resume(...)100%11100%
Suspend(...)83.33%6685.71%
ScheduleCore(...)90%101092.85%
LoopAsync()93.75%161694.73%
NextCronDelay(...)50%4475%
SafeRun()75%131278.26%
Dispose()100%11100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Scheduling/KestrunScheduler.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Cronos;
 3using System.Management.Automation;
 4using System.Collections;
 5using Kestrun.Utilities;
 6using static Kestrun.Scheduling.JobFactory;
 7using Kestrun.Scripting;
 8
 9namespace Kestrun.Scheduling;
 10
 11/// <summary>
 12/// Represents a service for managing scheduled tasks.
 13/// Provides methods to schedule, cancel, pause, resume, and report on tasks.
 14/// This service is designed to run within a Kestrun application context.
 15/// It supports both C# and PowerShell jobs, allowing for flexible scheduling options.
 16/// </summary>
 17/// <remarks>
 18/// The service uses a runspace pool for PowerShell jobs and supports scheduling via cron expressions or intervals.
 19/// It also provides methods to retrieve task reports in various formats, including typed objects and PowerShell-friendl
 20/// </remarks>
 21/// <remarks>
 22/// Initializes a new instance of the <see cref="SchedulerService"/> class.
 23/// This constructor sets up the scheduler service with a specified runspace pool, logger, and optional time zone.
 24/// The runspace pool is used for executing PowerShell scripts, while the logger is used for logging events.
 25/// </remarks>
 26/// <param name="pool">The runspace pool manager for executing PowerShell scripts.</param>
 27/// <param name="log">The logger instance for logging events.</param>
 28/// <param name="tz">The optional time zone information.</param>
 1829public sealed class SchedulerService(KestrunRunspacePoolManager pool, Serilog.ILogger log, TimeZoneInfo? tz = null) : ID
 30{
 31    /// <summary>
 32    /// The collection of scheduled tasks.
 33    /// This dictionary maps task names to their corresponding <see cref="ScheduledTask"/> instances.
 34    /// It is used to manage the lifecycle of scheduled tasks, including scheduling, execution, and cancellation.
 35    /// It is thread-safe and allows for concurrent access, ensuring that tasks can be added, removed, and executed
 36    /// simultaneously without causing data corruption or inconsistencies.
 37    /// </summary>
 1838    private readonly ConcurrentDictionary<string, ScheduledTask> _tasks =
 1839        new(StringComparer.OrdinalIgnoreCase);
 40    /// <summary>
 41    /// The runspace pool manager used for executing PowerShell scripts.
 42    /// This manager is responsible for managing the lifecycle of PowerShell runspaces,
 43    /// allowing for efficient execution of PowerShell scripts within the scheduler.
 44    /// It is used to create and manage runspaces for executing scheduled PowerShell jobs.
 45    /// The pool can be configured with various settings such as maximum runspaces, idle timeout, etc.
 46    /// </summary>
 1847    private readonly KestrunRunspacePoolManager _pool = pool;
 48    /// <summary>
 49    /// The logger instance used for logging events within the scheduler service.
 50    /// This logger is used to log information, warnings, and errors related to scheduled tasks.
 51    /// </summary>
 1852    private readonly Serilog.ILogger _log = log;
 53    /// <summary>
 54    /// The time zone used for scheduling and reporting.
 55    /// This is used to convert scheduled times to the appropriate time zone for display and execution.
 56    /// </summary>
 1857    private readonly TimeZoneInfo _tz = tz ?? TimeZoneInfo.Local;
 58
 59    /*────────── C# JOBS ──────────*/
 60    /// <summary>
 61    /// Schedules a C# job to run at a specified interval.
 62    /// </summary>
 63    /// <param name="name">The name of the job.</param>
 64    /// <param name="interval">The interval between job executions.</param>
 65    /// <param name="job">The asynchronous job delegate to execute.</param>
 66    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 67    public void Schedule(string name, TimeSpan interval,
 68        Func<CancellationToken, Task> job, bool runImmediately = false)
 1869        => ScheduleCore(name, job, cron: null, interval: interval, runImmediately);
 70
 71    /// <summary>
 72    /// Schedules a C# job to run according to a cron expression.
 73    /// </summary>
 74    /// <param name="name">The name of the job.</param>
 75    /// <param name="cronExpr">The cron expression specifying the job schedule.</param>
 76    /// <param name="job">The asynchronous job delegate to execute.</param>
 77    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 78    public void Schedule(string name, string cronExpr,
 79        Func<CancellationToken, Task> job, bool runImmediately = false)
 80    {
 781        var cron = CronExpression.Parse(cronExpr, CronFormat.IncludeSeconds);
 782        ScheduleCore(name, job, cron, null, runImmediately);
 783    }
 84
 85    /*────────── PowerShell JOBS ──────────*/
 86    /// <summary>
 87    /// Schedules a PowerShell job to run according to a cron expression.
 88    /// </summary>
 89    /// <param name="name">The name of the job.</param>
 90    /// <param name="cron">The cron expression specifying the job schedule.</param>
 91    /// <param name="scriptblock">The PowerShell script block to execute.</param>
 92    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 93    public void Schedule(string name, string cron, ScriptBlock scriptblock, bool runImmediately = false)
 94    {
 195        JobConfig config = new(ScriptLanguage.PowerShell, scriptblock.ToString(), _log, _pool);
 196        var job = Create(config);
 197        Schedule(name, cron, job, runImmediately);
 198    }
 99    /// <summary>
 100    /// Schedules a PowerShell job to run at a specified interval.
 101    /// </summary>
 102    /// <param name="name">The name of the job.</param>
 103    /// <param name="interval">The interval between job executions.</param>
 104    /// <param name="scriptblock">The PowerShell script block to execute.</param>
 105    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 106    public void Schedule(string name, TimeSpan interval, ScriptBlock scriptblock, bool runImmediately = false)
 107    {
 0108        JobConfig config = new(ScriptLanguage.PowerShell, scriptblock.ToString(), _log, _pool);
 0109        var job = Create(config);
 0110        Schedule(name, interval, job, runImmediately);
 0111    }
 112    /// <summary>
 113    /// Schedules a script job to run at a specified interval.
 114    /// </summary>
 115    /// <param name="name">The name of the job.</param>
 116    /// <param name="interval">The interval between job executions.</param>
 117    /// <param name="code">The script code to execute.</param>
 118    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 119    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 120    public void Schedule(string name, TimeSpan interval, string code, ScriptLanguage lang, bool runImmediately = false)
 121    {
 2122        JobConfig config = new(lang, code, _log, _pool);
 2123        var job = Create(config);
 2124        Schedule(name, interval, job, runImmediately);
 2125    }
 126
 127    /// <summary>
 128    /// Schedules a script job to run according to a cron expression.
 129    /// </summary>
 130    /// <param name="name">The name of the job.</param>
 131    /// <param name="cron">The cron expression specifying the job schedule.</param>
 132    /// <param name="code">The script code to execute.</param>
 133    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 134    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 135    public void Schedule(string name, string cron, string code, ScriptLanguage lang, bool runImmediately = false)
 136    {
 1137        JobConfig config = new(lang, code, _log, _pool);
 1138        var job = Create(config);
 1139        Schedule(name, cron, job, runImmediately);
 1140    }
 141
 142    /// <summary>
 143    /// Schedules a script job from a file to run at a specified interval.
 144    /// </summary>
 145    /// <param name="name">The name of the job.</param>
 146    /// <param name="interval">The interval between job executions.</param>
 147    /// <param name="fileInfo">The file containing the script code to execute.</param>
 148    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 149    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 150    public void Schedule(string name, TimeSpan interval, FileInfo fileInfo, ScriptLanguage lang, bool runImmediately = f
 151    {
 1152        JobConfig config = new(lang, string.Empty, _log, _pool);
 1153        var job = Create(config, fileInfo);
 1154        Schedule(name, interval, job, runImmediately);
 1155    }
 156
 157    /// <summary>
 158    /// Schedules a script job from a file to run according to a cron expression.
 159    /// </summary>
 160    /// <param name="name">The name of the job.</param>
 161    /// <param name="cron">The cron expression specifying the job schedule.</param>
 162    /// <param name="fileInfo">The file containing the script code to execute.</param>
 163    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 164    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 165    public void Schedule(string name, string cron, FileInfo fileInfo, ScriptLanguage lang, bool runImmediately = false)
 166    {
 1167        JobConfig config = new(lang, string.Empty, _log, _pool);
 1168        var job = Create(config, fileInfo);
 1169        Schedule(name, cron, job, runImmediately);
 1170    }
 171
 172    /// <summary>
 173    /// Asynchronously schedules a script job from a file to run at a specified interval.
 174    /// </summary>
 175    /// <param name="name">The name of the job.</param>
 176    /// <param name="interval">The interval between job executions.</param>
 177    /// <param name="fileInfo">The file containing the script code to execute.</param>
 178    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 179    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 180    /// <param name="ct">The cancellation token to cancel the operation.</param>
 181    public async Task ScheduleAsync(string name, TimeSpan interval, FileInfo fileInfo, ScriptLanguage lang, bool runImme
 182    {
 1183        JobConfig config = new(lang, string.Empty, _log, _pool);
 1184        var job = await CreateAsync(config, fileInfo, ct);
 1185        Schedule(name, interval, job, runImmediately);
 1186    }
 187
 188    /// <summary>
 189    /// Asynchronously schedules a script job from a file to run according to a cron expression.
 190    /// </summary>
 191    /// <param name="name">The name of the job.</param>
 192    /// <param name="cron">The cron expression specifying the job schedule.</param>
 193    /// <param name="fileInfo">The file containing the script code to execute.</param>
 194    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 195    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 196    /// <param name="ct">The cancellation token to cancel the operation.</param>
 197    public async Task ScheduleAsync(string name, string cron, FileInfo fileInfo, ScriptLanguage lang, bool runImmediatel
 198    {
 1199        JobConfig config = new(lang, string.Empty, _log, _pool);
 1200        var job = await CreateAsync(config, fileInfo, ct);
 1201        Schedule(name, cron, job, runImmediately);
 1202    }
 203    /*────────── CONTROL ──────────*/
 204    /// <summary>
 205    /// Cancels a scheduled job by its name.
 206    /// </summary>
 207    /// <param name="name">The name of the job to cancel.</param>
 208    /// <returns>True if the job was found and cancelled; otherwise, false.</returns>
 209    public bool Cancel(string name)
 210    {
 25211        if (string.IsNullOrWhiteSpace(name))
 212        {
 1213            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 214        }
 215
 24216        _log.Information("Cancelling scheduler job {Name}", name);
 24217        if (_tasks.TryRemove(name, out var task))
 218        {
 24219            task.TokenSource.Cancel();
 220            // Wait briefly for the loop to observe cancellation to avoid a race
 221            // where a final run completes after Cancel() returns and causes test flakiness.
 222            try
 223            {
 24224                if (task.Runner is { } r && !r.IsCompleted)
 225                {
 226                    // First quick wait
 24227                    if (!r.Wait(TimeSpan.FromMilliseconds(250)))
 228                    {
 229                        // Allow additional time (slower net8 CI, PowerShell warm-up) up to ~1s total.
 0230                        var remaining = TimeSpan.FromMilliseconds(750);
 0231                        var sw = System.Diagnostics.Stopwatch.StartNew();
 0232                        while (!r.IsCompleted && sw.Elapsed < remaining)
 233                        {
 234                            // small sleep; runner work is CPU-light
 0235                            Thread.Sleep(25);
 236                        }
 237                    }
 238                }
 24239            }
 0240            catch (Exception) { /* swallow */ }
 24241            _log.Information("Scheduler job {Name} cancelled", name);
 24242            return true;
 243        }
 0244        return false;
 245    }
 246
 247    /// <summary>
 248    /// Asynchronously cancels a scheduled job and optionally waits for its runner to complete.
 249    /// </summary>
 250    /// <param name="name">Job name.</param>
 251    /// <param name="timeout">Optional timeout (default 2s) to wait for completion after signalling cancellation.</param
 252    public async Task<bool> CancelAsync(string name, TimeSpan? timeout = null)
 253    {
 0254        timeout ??= TimeSpan.FromSeconds(2);
 0255        if (string.IsNullOrWhiteSpace(name))
 256        {
 0257            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 258        }
 0259        if (!_tasks.TryRemove(name, out var task))
 260        {
 0261            return false;
 262        }
 0263        _log.Information("Cancelling scheduler job (async) {Name}", name);
 0264        task.TokenSource.Cancel();
 0265        var runner = task.Runner;
 0266        if (runner is null)
 267        {
 0268            return true;
 269        }
 270        try
 271        {
 0272            using var cts = new CancellationTokenSource(timeout.Value);
 0273            var completed = await Task.WhenAny(runner, Task.Delay(Timeout.InfiniteTimeSpan, cts.Token)) == runner;
 0274            if (!completed)
 275            {
 0276                _log.Warning("Timeout waiting for scheduler job {Name} to cancel", name);
 277            }
 0278        }
 0279        catch (Exception ex)
 280        {
 0281            _log.Debug(ex, "Error while awaiting cancellation for job {Name}", name);
 0282        }
 0283        return true;
 0284    }
 285
 286    /// <summary>
 287    /// Cancels all scheduled jobs.
 288    /// </summary>
 289    public void CancelAll()
 290    {
 82291        foreach (var kvp in _tasks.Keys)
 292        {
 23293            _ = Cancel(kvp);
 294        }
 18295    }
 296
 297    /// <summary>
 298    /// Generates a report of all scheduled jobs, including their last and next run times, and suspension status.
 299    /// </summary>
 300    /// <param name="displayTz">The time zone to display times in; defaults to UTC if not specified.</param>
 301    /// <returns>A <see cref="ScheduleReport"/> containing information about all scheduled jobs.</returns>
 302    public ScheduleReport GetReport(TimeZoneInfo? displayTz = null)
 303    {
 304        // default to Zulu
 2305        var timezone = displayTz ?? TimeZoneInfo.Utc;
 2306        var now = DateTimeOffset.UtcNow;
 307
 2308        var jobs = _tasks.Values
 2309            .Select(t =>
 2310            {
 2311                // store timestamps internally in UTC; convert only for the report
 4312                var last = t.LastRunAt?.ToOffset(timezone.GetUtcOffset(t.LastRunAt.Value));
 4313                var next = t.NextRunAt.ToOffset(timezone.GetUtcOffset(t.NextRunAt));
 2314
 4315                return new JobInfo(t.Name, last, next, t.IsSuspended);
 2316            })
 4317            .OrderBy(j => j.NextRunAt)
 2318            .ToArray();
 319
 2320        return new ScheduleReport(now, jobs);
 321    }
 322
 323    /// <summary>
 324    /// Generates a report of all scheduled jobs in a PowerShell-friendly hashtable format.
 325    /// </summary>
 326    /// <param name="displayTz">The time zone to display times in; defaults to UTC if not specified.</param>
 327    /// <returns>A <see cref="Hashtable"/> containing information about all scheduled jobs.</returns>
 328    public Hashtable GetReportHashtable(TimeZoneInfo? displayTz = null)
 329    {
 1330        var rpt = GetReport(displayTz);
 331
 1332        var jobsArray = rpt.Jobs
 2333            .Select(j => new Hashtable
 2334            {
 2335                ["Name"] = j.Name,
 2336                ["LastRunAt"] = j.LastRunAt,
 2337                ["NextRunAt"] = j.NextRunAt,
 2338                ["IsSuspended"] = j.IsSuspended,
 2339                ["IsCompleted"] = j.IsCompleted
 2340            })
 1341            .ToArray();                       // powershell likes [] not IList<>
 342
 1343        return new Hashtable
 1344        {
 1345            ["GeneratedAt"] = rpt.GeneratedAt,
 1346            ["Jobs"] = jobsArray
 1347        };
 348    }
 349
 350
 351    /// <summary>
 352    /// Gets a snapshot of all scheduled jobs with their current state.
 353    /// </summary>
 354    /// <returns>An <see cref="IReadOnlyCollection{JobInfo}"/> containing job information for all scheduled jobs.</retur
 355    public IReadOnlyCollection<JobInfo> GetSnapshot()
 54356        => [.. _tasks.Values.Select(t => new JobInfo(t.Name, t.LastRunAt, t.NextRunAt, t.IsSuspended, t.IsCompleted))];
 357
 358
 359    /// <summary>
 360    /// Gets a snapshot of all scheduled jobs with their current state, optionally filtered and formatted.
 361    /// </summary>
 362    /// <param name="tz">The time zone to display times in; defaults to UTC if not specified.</param>
 363    /// <param name="asHashtable">Whether to return the result as PowerShell-friendly hashtables.</param>
 364    /// <param name="nameFilter">Optional glob patterns to filter job names.</param>
 365    /// <returns>
 366    /// An <see cref="IReadOnlyCollection{T}"/> containing job information for all scheduled jobs,
 367    /// either as <see cref="JobInfo"/> objects or hashtables depending on <paramref name="asHashtable"/>.
 368    /// </returns>
 369    public IReadOnlyCollection<object> GetSnapshot(
 370       TimeZoneInfo? tz = null,
 371       bool asHashtable = false,
 372       params string[] nameFilter)
 373    {
 2374        tz ??= TimeZoneInfo.Utc;
 375
 376        bool Matches(string name)
 377        {
 378            if (nameFilter == null || nameFilter.Length == 0)
 379            {
 380                return true;
 381            }
 382
 383            foreach (var pat in nameFilter)
 384            {
 385                if (RegexUtils.IsGlobMatch(name, pat))
 386                {
 387                    return true;
 388                }
 389            }
 390
 391            return false;
 392        }
 393
 394        // fast path: no filter, utc, typed objects
 2395        if (nameFilter.Length == 0 && tz.Equals(TimeZoneInfo.Utc) && !asHashtable)
 396        {
 0397            return [.. _tasks.Values.Select(t => (object)new JobInfo(t.Name, t.LastRunAt, t.NextRunAt, t.IsSuspended, t.
 398        }
 399
 2400        var jobs = _tasks.Values
 6401                         .Where(t => Matches(t.Name))
 2402                         .Select(t =>
 2403                         {
 3404                             var last = t.LastRunAt?.ToOffset(tz.GetUtcOffset(t.LastRunAt ?? DateTimeOffset.UtcNow));
 3405                             var next = t.NextRunAt.ToOffset(tz.GetUtcOffset(t.NextRunAt));
 3406                             return new JobInfo(t.Name, last, next, t.IsSuspended, t.IsCompleted);
 2407                         })
 2408                         .OrderBy(j => j.NextRunAt)
 2409                         .ToArray();
 410
 2411        if (!asHashtable)
 412        {
 1413            return [.. jobs.Cast<object>()];
 414        }
 415
 416        // PowerShell-friendly shape
 2417        return [.. jobs.Select(j => (object)new Hashtable
 2418                {
 2419                    ["Name"]        = j.Name,
 2420                    ["LastRunAt"]   = j.LastRunAt,
 2421                    ["NextRunAt"]   = j.NextRunAt,
 2422                    ["IsSuspended"] = j.IsSuspended,
 2423                    ["IsCompleted"] = j.IsCompleted
 2424                })];
 425    }
 426
 427
 428    /// <summary>
 429    /// Pauses a scheduled job by its name.
 430    /// </summary>
 431    /// <param name="name">The name of the job to pause.</param>
 432    /// <returns>True if the job was found and paused; otherwise, false.</returns>
 2433    public bool Pause(string name) => Suspend(name);
 434    /// <summary>
 435    /// Resumes a scheduled job by its name.
 436    /// </summary>
 437    /// <param name="name">The name of the job to resume.</param>
 438    /// <returns>True if the job was found and resumed; otherwise, false.</returns>
 2439    public bool Resume(string name) => Suspend(name, false);
 440
 441    /*────────── INTERNALS ──────────*/
 442
 443    /// <summary>
 444    /// Suspends or resumes a scheduled job by its name.
 445    /// This method updates the suspension status of the job, allowing it to be paused or resumed.
 446    /// If the job is found, its IsSuspended property is updated accordingly.
 447    /// </summary>
 448    /// <param name="name">The name of the job to suspend or resume.</param>
 449    /// <param name="suspend">True to suspend the job; false to resume it.</param>
 450    /// <returns>True if the job was found and its status was updated; otherwise, false.</returns>
 451    /// <exception cref="ArgumentException"></exception>
 452    /// <remarks>
 453    /// This method is used internally to control the execution of scheduled jobs.
 454    /// It allows for dynamic control over job execution without needing to cancel and reschedule them.
 455    /// </remarks>
 456    private bool Suspend(string name, bool suspend = true)
 457    {
 4458        if (string.IsNullOrWhiteSpace(name))
 459        {
 2460            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 461        }
 462
 2463        if (_tasks.TryGetValue(name, out var task))
 464        {
 2465            task.IsSuspended = suspend;
 2466            _log.Information("Scheduler job {Name} {Action}", name, suspend ? "paused" : "resumed");
 2467            return true;
 468        }
 0469        return false;
 470    }
 471
 472    /// <summary>
 473    /// Schedules a new job.
 474    /// This method is the core implementation for scheduling jobs, allowing for both cron-based and interval-based sche
 475    /// It creates a new <see cref="ScheduledTask"/> instance and starts it in a background loop.
 476    /// The task is added to the internal collection of tasks, and its next run time is calculated based on the provided
 477    /// If both cron and interval are null, an exception is thrown.
 478    /// </summary>
 479    /// <param name="name">The name of the job.</param>
 480    /// <param name="job">The job to execute.</param>
 481    /// <param name="cron">The cron expression for scheduling.</param>
 482    /// <param name="interval">The interval for scheduling.</param>
 483    /// <param name="runImmediately">Whether to run the job immediately.</param>
 484    /// <exception cref="ArgumentException"></exception>
 485    /// <exception cref="InvalidOperationException"></exception>
 486    /// <remarks>
 487    /// This method is used internally to schedule jobs and should not be called directly.
 488    /// It handles the creation of the task, its scheduling, and the management of its execution loop.
 489    /// The task is run in a separate background thread to avoid blocking the main application flow.
 490    /// </remarks>
 491    private void ScheduleCore(
 492        string name,
 493        Func<CancellationToken, Task> job,
 494        CronExpression? cron,
 495        TimeSpan? interval,
 496        bool runImmediately)
 497    {
 25498        if (cron is null && interval == null)
 499        {
 0500            throw new ArgumentException("Either cron or interval must be supplied.");
 501        }
 502
 25503        var cts = new CancellationTokenSource();
 25504        var task = new ScheduledTask(name, job, cron, interval, runImmediately, cts)
 25505        {
 25506            NextRunAt = interval != null
 25507                ? DateTimeOffset.UtcNow + interval.Value
 25508                : (DateTimeOffset.UtcNow + NextCronDelay(cron!, _tz)).ToUniversalTime(),
 25509        };
 510
 25511        if (!_tasks.TryAdd(name, task))
 512        {
 1513            throw new InvalidOperationException($"A task named '{name}' already exists.");
 514        }
 515
 48516        task.Runner = Task.Run(() => LoopAsync(task), cts.Token);
 24517        _log.Debug("Scheduled job '{Name}' (cron: {Cron}, interval: {Interval})", name, cron?.ToString(), interval);
 24518    }
 519
 520    /// <summary>
 521    /// Runs the scheduled task in a loop.
 522    /// This method handles the execution of the task according to its schedule, either immediately or based on a cron e
 523    /// It checks if the task is suspended and delays accordingly, while also being responsive to cancellation requests.
 524    /// </summary>
 525    /// <param name="task">The scheduled task to run.</param>
 526    /// <returns>A task representing the asynchronous operation.</returns>
 527    /// <remarks>
 528    /// This method is called internally by the scheduler to manage the execution of scheduled tasks.
 529    /// It ensures that tasks are run at the correct times and handles any exceptions that may occur during execution.
 530    /// The loop continues until the task is cancelled or the cancellation token is triggered.
 531    /// </remarks>
 532    private async Task LoopAsync(ScheduledTask task)
 533    {
 24534        var ct = task.TokenSource.Token;
 535
 24536        if (task.RunImmediately && !task.IsSuspended)
 537        {
 11538            await SafeRun(task.Work, task, ct);
 539        }
 540
 35541        while (!ct.IsCancellationRequested)
 542        {
 35543            if (task.IsSuspended)
 544            {
 545                // sleep a bit while suspended, but stay responsive to Cancel()
 1546                await Task.Delay(TimeSpan.FromSeconds(1), ct);
 1547                continue;
 548            }
 549
 550            TimeSpan delay;
 34551            if (task.Interval is not null)
 552            {
 553                // Align to the intended NextRunAt rather than drifting by fixed interval;
 554                // this reduces flakiness when scheduling overhead is high.
 27555                var until = task.NextRunAt - DateTimeOffset.UtcNow;
 27556                delay = until > TimeSpan.Zero ? until : TimeSpan.Zero;
 557            }
 558            else
 559            {
 7560                delay = NextCronDelay(task.Cron!, _tz);
 7561                if (delay < TimeSpan.Zero)
 562                {
 0563                    delay = TimeSpan.Zero;
 564                }
 565            }
 566
 44567            try { await Task.Delay(delay, ct); }
 48568            catch (TaskCanceledException) { break; }
 569
 10570            if (!ct.IsCancellationRequested)
 571            {
 10572                await SafeRun(task.Work, task, ct);
 573            }
 574        }
 24575        task.IsCompleted = true;
 24576    }
 577
 578    /// <summary>
 579    /// Calculates the next delay for a cron expression.
 580    /// This method computes the time until the next occurrence of the cron expression based on the current UTC time.
 581    /// If there are no future occurrences, it logs a warning and returns a maximum value.
 582    /// </summary>
 583    /// <param name="expr">The cron expression to evaluate.</param>
 584    /// <param name="tz">The time zone to use for the evaluation.</param>
 585    /// <returns>The time span until the next occurrence of the cron expression.</returns>
 586    /// <remarks>
 587    /// This method is used internally to determine when the next scheduled run of a cron-based task should occur.
 588    /// It uses the Cronos library to calculate the next occurrence based on the current UTC time and the specified time
 589    /// If no future occurrence is found, it logs a warning and returns a maximum time span.
 590    /// </remarks>
 591    private TimeSpan NextCronDelay(CronExpression expr, TimeZoneInfo tz)
 592    {
 14593        var next = expr.GetNextOccurrence(DateTimeOffset.UtcNow, tz);
 14594        if (next is null)
 595        {
 0596            _log.Warning("Cron expression {Expr} has no future occurrence", expr);
 597        }
 598
 14599        return next.HasValue ? next.Value - DateTimeOffset.UtcNow : TimeSpan.MaxValue;
 600    }
 601
 602    /// <summary>
 603    /// Safely runs the scheduled task, handling exceptions and updating the task's state.
 604    /// This method executes the provided work function and updates the task's last run time and next run time according
 605    /// </summary>
 606    /// <param name="work">The work function to execute.</param>
 607    /// <param name="task">The scheduled task to run.</param>
 608    /// <param name="ct">The cancellation token.</param>
 609    /// <returns>A task representing the asynchronous operation.</returns>
 610    /// <remarks>
 611    /// This method is called internally by the scheduler to manage the execution of scheduled tasks.
 612    /// It ensures that tasks are run at the correct times and handles any exceptions that may occur during execution.
 613    /// </remarks>
 614    private async Task SafeRun(Func<CancellationToken, Task> work, ScheduledTask task, CancellationToken ct)
 615    {
 616        try
 617        {
 618            // If cancellation was requested after the loop's check and before entering here, bail out.
 21619            if (ct.IsCancellationRequested)
 620            {
 0621                return;
 622            }
 21623            var runStartedAt = DateTimeOffset.UtcNow; // capture start time
 21624            await work(ct);
 625
 626            // compute next run (only if still scheduled). We compute fully *before* publishing
 627            // any timestamp changes so snapshots never see LastRunAt > NextRunAt.
 628            DateTimeOffset nextRun;
 21629            if (task.Interval != null)
 630            {
 15631                task.RunIteration++; // increment completed count
 15632                var interval = task.Interval.Value;
 15633                var next = task.AnchorAt + ((task.RunIteration + 1) * interval);
 15634                var now = DateTimeOffset.UtcNow;
 23635                while (next - now <= TimeSpan.Zero)
 636                {
 8637                    task.RunIteration++;
 8638                    next = task.AnchorAt + ((task.RunIteration + 1) * interval);
 8639                    if (task.RunIteration > 10_000) { break; }
 640                }
 15641                nextRun = next;
 642            }
 643            else
 644            {
 6645                nextRun = task.Cron is not null ? task.Cron.GetNextOccurrence(runStartedAt, _tz) ?? DateTimeOffset.MaxVa
 646            }
 647
 648            // Publish timestamps together to avoid inconsistent snapshot (race seen in CI where
 649            // LastRunAt advanced but NextRunAt still pointed to prior slot).
 21650            task.LastRunAt = runStartedAt;
 21651            task.NextRunAt = nextRun;
 21652        }
 0653        catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* ignore */ }
 0654        catch (Exception ex)
 655        {
 0656            _log.Error(ex, "[Scheduler] Job '{Name}' failed", task.Name);
 0657        }
 21658    }
 659
 660    /// <summary>
 661    /// Disposes the scheduler and cancels all running tasks.
 662    /// </summary>
 663    /// <remarks>
 664    /// This method is called to clean up resources used by the scheduler service.
 665    /// It cancels all scheduled tasks and disposes of the runspace pool manager.
 666    /// </remarks>
 667    public void Dispose()
 668    {
 17669        CancelAll();
 17670        _pool.Dispose();
 17671        _log.Information("SchedulerService disposed");
 17672    }
 673}

Methods/Properties

.ctor(Kestrun.Scripting.KestrunRunspacePoolManager,Serilog.ILogger,System.TimeZoneInfo)
Schedule(System.String,System.TimeSpan,System.Func`2<System.Threading.CancellationToken,System.Threading.Tasks.Task>,System.Boolean)
Schedule(System.String,System.String,System.Func`2<System.Threading.CancellationToken,System.Threading.Tasks.Task>,System.Boolean)
Schedule(System.String,System.String,System.Management.Automation.ScriptBlock,System.Boolean)
Schedule(System.String,System.TimeSpan,System.Management.Automation.ScriptBlock,System.Boolean)
Schedule(System.String,System.TimeSpan,System.String,Kestrun.Scripting.ScriptLanguage,System.Boolean)
Schedule(System.String,System.String,System.String,Kestrun.Scripting.ScriptLanguage,System.Boolean)
Schedule(System.String,System.TimeSpan,System.IO.FileInfo,Kestrun.Scripting.ScriptLanguage,System.Boolean)
Schedule(System.String,System.String,System.IO.FileInfo,Kestrun.Scripting.ScriptLanguage,System.Boolean)
ScheduleAsync()
ScheduleAsync()
Cancel(System.String)
CancelAsync()
CancelAll()
GetReport(System.TimeZoneInfo)
GetReportHashtable(System.TimeZoneInfo)
GetSnapshot()
GetSnapshot(System.TimeZoneInfo,System.Boolean,System.String[])
Pause(System.String)
Resume(System.String)
Suspend(System.String,System.Boolean)
ScheduleCore(System.String,System.Func`2<System.Threading.CancellationToken,System.Threading.Tasks.Task>,Cronos.CronExpression,System.Nullable`1<System.TimeSpan>,System.Boolean)
LoopAsync()
NextCronDelay(Cronos.CronExpression,System.TimeZoneInfo)
SafeRun()
Dispose()