< 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@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
79%
Covered lines: 173
Uncovered lines: 45
Coverable lines: 218
Total lines: 688
Line coverage: 79.3%
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 08/26/2025 - 01:25:22 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a08/27/2025 - 16:39:45 Line coverage: 81.7% (170/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@4c6ca8f799d2de93b1edf9355074f3da4297dc6208/28/2025 - 13:31:25 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@c90b3fa538340336a4939f46ad7235e29212eb5908/29/2025 - 15:33:49 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@d302841c78e142037a1aae3165383d903a00b5f408/29/2025 - 16:01:24 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@ad4a22f390590bbcb102eb1a8fa13cdf7ebd5c5b08/29/2025 - 16:32:52 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@f8696a0ab4be2f099ac302a64da5cc7df073c59a08/29/2025 - 19:03:16 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@bbe80beb98bdf6ba9249a033d00f6e4748a4c06c09/07/2025 - 15:39:38 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@3fb018cb3fb12e5d6852c6847273ec977fcc81a409/07/2025 - 18:41:40 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@2192d4ccb46312ce89b7f7fda1aa8c915bfa228409/09/2025 - 21:56:59 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@739093f321f10605cc4d1029da7300e3bb4dcba909/10/2025 - 17:50:34 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@5f41b7385e6492ec5892c0c7887658952f6fb87e09/12/2025 - 16:20:13 Line coverage: 81.2% (169/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@bd014be0a15f3c9298922d2ff67068869adda2a009/12/2025 - 17:01:20 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@383c1560c6be4027d79f183f7ed839edb887efea09/16/2025 - 19:34:38 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@cd031acff6ee2a77514aa4ff9c66847f9e475e6009/18/2025 - 15:45:03 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@a5d43b6e0ad059b0f70e9558198acdf29b07fe1a10/13/2025 - 16:52:37 Line coverage: 79.7% (173/217) Branch coverage: 67% (63/94) Total lines: 682 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e10/15/2025 - 21:27:26 Line coverage: 79.3% (173/218) Branch coverage: 67% (63/94) Total lines: 688 Tag: Kestrun/Kestrun@c33ec02a85e4f8d6061aeaab5a5e8c3a8b665594 08/26/2025 - 01:25:22 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a08/27/2025 - 16:39:45 Line coverage: 81.7% (170/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@4c6ca8f799d2de93b1edf9355074f3da4297dc6208/28/2025 - 13:31:25 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@c90b3fa538340336a4939f46ad7235e29212eb5908/29/2025 - 15:33:49 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@d302841c78e142037a1aae3165383d903a00b5f408/29/2025 - 16:01:24 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@ad4a22f390590bbcb102eb1a8fa13cdf7ebd5c5b08/29/2025 - 16:32:52 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@f8696a0ab4be2f099ac302a64da5cc7df073c59a08/29/2025 - 19:03:16 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@bbe80beb98bdf6ba9249a033d00f6e4748a4c06c09/07/2025 - 15:39:38 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@3fb018cb3fb12e5d6852c6847273ec977fcc81a409/07/2025 - 18:41:40 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@2192d4ccb46312ce89b7f7fda1aa8c915bfa228409/09/2025 - 21:56:59 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@739093f321f10605cc4d1029da7300e3bb4dcba909/10/2025 - 17:50:34 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@5f41b7385e6492ec5892c0c7887658952f6fb87e09/12/2025 - 16:20:13 Line coverage: 81.2% (169/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@bd014be0a15f3c9298922d2ff67068869adda2a009/12/2025 - 17:01:20 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@383c1560c6be4027d79f183f7ed839edb887efea09/16/2025 - 19:34:38 Line coverage: 81.2% (169/208) Branch coverage: 68% (64/94) Total lines: 673 Tag: Kestrun/Kestrun@cd031acff6ee2a77514aa4ff9c66847f9e475e6009/18/2025 - 15:45:03 Line coverage: 80.7% (168/208) Branch coverage: 67% (63/94) Total lines: 673 Tag: Kestrun/Kestrun@a5d43b6e0ad059b0f70e9558198acdf29b07fe1a10/13/2025 - 16:52:37 Line coverage: 79.7% (173/217) Branch coverage: 67% (63/94) Total lines: 682 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e10/15/2025 - 21:27:26 Line coverage: 79.3% (173/218) Branch coverage: 67% (63/94) Total lines: 688 Tag: Kestrun/Kestrun@c33ec02a85e4f8d6061aeaab5a5e8c3a8b665594

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
get_Host()100%210%
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%151482.14%
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%141277.27%
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;
 8using Kestrun.Hosting;
 9
 10namespace Kestrun.Scheduling;
 11
 12/// <summary>
 13/// Represents a service for managing scheduled tasks.
 14/// Provides methods to schedule, cancel, pause, resume, and report on tasks.
 15/// This service is designed to run within a Kestrun application context.
 16/// It supports both C# and PowerShell jobs, allowing for flexible scheduling options.
 17/// </summary>
 18/// <remarks>
 19/// The service uses a runspace pool for PowerShell jobs and supports scheduling via cron expressions or intervals.
 20/// It also provides methods to retrieve task reports in various formats, including typed objects and PowerShell-friendl
 21/// </remarks>
 22/// <remarks>
 23/// Initializes a new instance of the <see cref="SchedulerService"/> class.
 24/// This constructor sets up the scheduler service with a specified runspace pool, logger, and optional time zone.
 25/// The runspace pool is used for executing PowerShell scripts, while the logger is used for logging events.
 26/// </remarks>
 27/// <param name="pool">The runspace pool manager for executing PowerShell scripts.</param>
 28/// <param name="log">The logger instance for logging events.</param>
 29/// <param name="tz">The optional time zone information.</param>
 1830public sealed class SchedulerService(KestrunRunspacePoolManager pool, Serilog.ILogger log, TimeZoneInfo? tz = null) : ID
 31{
 32    /// <summary>
 33    /// The collection of scheduled tasks.
 34    /// This dictionary maps task names to their corresponding <see cref="ScheduledTask"/> instances.
 35    /// It is used to manage the lifecycle of scheduled tasks, including scheduling, execution, and cancellation.
 36    /// It is thread-safe and allows for concurrent access, ensuring that tasks can be added, removed, and executed
 37    /// simultaneously without causing data corruption or inconsistencies.
 38    /// </summary>
 1839    private readonly ConcurrentDictionary<string, ScheduledTask> _tasks =
 1840        new(StringComparer.OrdinalIgnoreCase);
 41    /// <summary>
 42    /// The runspace pool manager used for executing PowerShell scripts.
 43    /// This manager is responsible for managing the lifecycle of PowerShell runspaces,
 44    /// allowing for efficient execution of PowerShell scripts within the scheduler.
 45    /// It is used to create and manage runspaces for executing scheduled PowerShell jobs.
 46    /// The pool can be configured with various settings such as maximum runspaces, idle timeout, etc.
 47    /// </summary>
 1848    private readonly KestrunRunspacePoolManager _pool = pool;
 49    /// <summary>
 50    /// The logger instance used for logging events within the scheduler service.
 51    /// This logger is used to log information, warnings, and errors related to scheduled tasks.
 52    /// </summary>
 1853    private readonly Serilog.ILogger _log = log;
 54    /// <summary>
 55    /// The time zone used for scheduling and reporting.
 56    /// This is used to convert scheduled times to the appropriate time zone for display and execution.
 57    /// </summary>
 1858    private readonly TimeZoneInfo _tz = tz ?? TimeZoneInfo.Local;
 59
 60    /// <summary>
 61    /// Gets the Kestrun host associated with the runspace pool.
 62    /// </summary>
 063    public KestrunHost Host => _pool.Host;
 64
 65    /*────────── C# JOBS ──────────*/
 66    /// <summary>
 67    /// Schedules a C# job to run at a specified interval.
 68    /// </summary>
 69    /// <param name="name">The name of the job.</param>
 70    /// <param name="interval">The interval between job executions.</param>
 71    /// <param name="job">The asynchronous job delegate to execute.</param>
 72    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 73    public void Schedule(string name, TimeSpan interval,
 74        Func<CancellationToken, Task> job, bool runImmediately = false)
 1875        => ScheduleCore(name, job, cron: null, interval: interval, runImmediately);
 76
 77    /// <summary>
 78    /// Schedules a C# job to run according to a cron expression.
 79    /// </summary>
 80    /// <param name="name">The name of the job.</param>
 81    /// <param name="cronExpr">The cron expression specifying the job schedule.</param>
 82    /// <param name="job">The asynchronous job delegate to execute.</param>
 83    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 84    public void Schedule(string name, string cronExpr,
 85        Func<CancellationToken, Task> job, bool runImmediately = false)
 86    {
 787        var cron = CronExpression.Parse(cronExpr, CronFormat.IncludeSeconds);
 788        ScheduleCore(name, job, cron, null, runImmediately);
 789    }
 90
 91    /*────────── PowerShell JOBS ──────────*/
 92    /// <summary>
 93    /// Schedules a PowerShell job to run according to a cron expression.
 94    /// </summary>
 95    /// <param name="name">The name of the job.</param>
 96    /// <param name="cron">The cron expression specifying the job schedule.</param>
 97    /// <param name="scriptblock">The PowerShell script block to execute.</param>
 98    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 99    public void Schedule(string name, string cron, ScriptBlock scriptblock, bool runImmediately = false)
 100    {
 1101        JobConfig config = new(ScriptLanguage.PowerShell, scriptblock.ToString(), _log, _pool);
 1102        var job = Create(config);
 1103        Schedule(name, cron, job, runImmediately);
 1104    }
 105    /// <summary>
 106    /// Schedules a PowerShell job to run at a specified interval.
 107    /// </summary>
 108    /// <param name="name">The name of the job.</param>
 109    /// <param name="interval">The interval between job executions.</param>
 110    /// <param name="scriptblock">The PowerShell script block to execute.</param>
 111    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 112    public void Schedule(string name, TimeSpan interval, ScriptBlock scriptblock, bool runImmediately = false)
 113    {
 0114        JobConfig config = new(ScriptLanguage.PowerShell, scriptblock.ToString(), _log, _pool);
 0115        var job = Create(config);
 0116        Schedule(name, interval, job, runImmediately);
 0117    }
 118    /// <summary>
 119    /// Schedules a script job to run at a specified interval.
 120    /// </summary>
 121    /// <param name="name">The name of the job.</param>
 122    /// <param name="interval">The interval between job executions.</param>
 123    /// <param name="code">The script code to execute.</param>
 124    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 125    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 126    public void Schedule(string name, TimeSpan interval, string code, ScriptLanguage lang, bool runImmediately = false)
 127    {
 2128        JobConfig config = new(lang, code, _log, _pool);
 2129        var job = Create(config);
 2130        Schedule(name, interval, job, runImmediately);
 2131    }
 132
 133    /// <summary>
 134    /// Schedules a script job to run according to a cron expression.
 135    /// </summary>
 136    /// <param name="name">The name of the job.</param>
 137    /// <param name="cron">The cron expression specifying the job schedule.</param>
 138    /// <param name="code">The script code to execute.</param>
 139    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 140    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 141    public void Schedule(string name, string cron, string code, ScriptLanguage lang, bool runImmediately = false)
 142    {
 1143        JobConfig config = new(lang, code, _log, _pool);
 1144        var job = Create(config);
 1145        Schedule(name, cron, job, runImmediately);
 1146    }
 147
 148    /// <summary>
 149    /// Schedules a script job from a file to run at a specified interval.
 150    /// </summary>
 151    /// <param name="name">The name of the job.</param>
 152    /// <param name="interval">The interval between job executions.</param>
 153    /// <param name="fileInfo">The file containing the script code to execute.</param>
 154    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 155    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 156    public void Schedule(string name, TimeSpan interval, FileInfo fileInfo, ScriptLanguage lang, bool runImmediately = f
 157    {
 1158        JobConfig config = new(lang, string.Empty, _log, _pool);
 1159        var job = Create(config, fileInfo);
 1160        Schedule(name, interval, job, runImmediately);
 1161    }
 162
 163    /// <summary>
 164    /// Schedules a script job from a file to run according to a cron expression.
 165    /// </summary>
 166    /// <param name="name">The name of the job.</param>
 167    /// <param name="cron">The cron expression specifying the job schedule.</param>
 168    /// <param name="fileInfo">The file containing the script code to execute.</param>
 169    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 170    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 171    public void Schedule(string name, string cron, FileInfo fileInfo, ScriptLanguage lang, bool runImmediately = false)
 172    {
 1173        JobConfig config = new(lang, string.Empty, _log, _pool);
 1174        var job = Create(config, fileInfo);
 1175        Schedule(name, cron, job, runImmediately);
 1176    }
 177
 178    /// <summary>
 179    /// Asynchronously schedules a script job from a file to run at a specified interval.
 180    /// </summary>
 181    /// <param name="name">The name of the job.</param>
 182    /// <param name="interval">The interval between job executions.</param>
 183    /// <param name="fileInfo">The file containing the script code to execute.</param>
 184    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 185    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 186    /// <param name="ct">The cancellation token to cancel the operation.</param>
 187    public async Task ScheduleAsync(string name, TimeSpan interval, FileInfo fileInfo, ScriptLanguage lang, bool runImme
 188    {
 1189        JobConfig config = new(lang, string.Empty, _log, _pool);
 1190        var job = await CreateAsync(config, fileInfo, ct);
 1191        Schedule(name, interval, job, runImmediately);
 1192    }
 193
 194    /// <summary>
 195    /// Asynchronously schedules a script job from a file to run according to a cron expression.
 196    /// </summary>
 197    /// <param name="name">The name of the job.</param>
 198    /// <param name="cron">The cron expression specifying the job schedule.</param>
 199    /// <param name="fileInfo">The file containing the script code to execute.</param>
 200    /// <param name="lang">The language of the script (e.g., PowerShell, CSharp).</param>
 201    /// <param name="runImmediately">Whether to run the job immediately upon scheduling.</param>
 202    /// <param name="ct">The cancellation token to cancel the operation.</param>
 203    public async Task ScheduleAsync(string name, string cron, FileInfo fileInfo, ScriptLanguage lang, bool runImmediatel
 204    {
 1205        JobConfig config = new(lang, string.Empty, _log, _pool);
 1206        var job = await CreateAsync(config, fileInfo, ct);
 1207        Schedule(name, cron, job, runImmediately);
 1208    }
 209    /*────────── CONTROL ──────────*/
 210    /// <summary>
 211    /// Cancels a scheduled job by its name.
 212    /// </summary>
 213    /// <param name="name">The name of the job to cancel.</param>
 214    /// <returns>True if the job was found and cancelled; otherwise, false.</returns>
 215    public bool Cancel(string name)
 216    {
 25217        if (string.IsNullOrWhiteSpace(name))
 218        {
 1219            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 220        }
 221
 24222        _log.Information("Cancelling scheduler job {Name}", name);
 24223        if (_tasks.TryRemove(name, out var task))
 224        {
 24225            task.TokenSource.Cancel();
 226            // Wait briefly for the loop to observe cancellation to avoid a race
 227            // where a final run completes after Cancel() returns and causes test flakiness.
 228            try
 229            {
 24230                if (task.Runner is { } r && !r.IsCompleted)
 231                {
 232                    // First quick wait
 24233                    if (!r.Wait(TimeSpan.FromMilliseconds(250)))
 234                    {
 235                        // Allow additional time (slower net8 CI, PowerShell warm-up) up to ~1s total.
 0236                        var remaining = TimeSpan.FromMilliseconds(750);
 0237                        var sw = System.Diagnostics.Stopwatch.StartNew();
 0238                        while (!r.IsCompleted && sw.Elapsed < remaining)
 239                        {
 240                            // small sleep; runner work is CPU-light
 0241                            Thread.Sleep(25);
 242                        }
 243                    }
 244                }
 24245            }
 0246            catch (Exception) { /* swallow */ }
 24247            _log.Information("Scheduler job {Name} cancelled", name);
 24248            return true;
 249        }
 0250        return false;
 251    }
 252
 253    /// <summary>
 254    /// Asynchronously cancels a scheduled job and optionally waits for its runner to complete.
 255    /// </summary>
 256    /// <param name="name">Job name.</param>
 257    /// <param name="timeout">Optional timeout (default 2s) to wait for completion after signalling cancellation.</param
 258    public async Task<bool> CancelAsync(string name, TimeSpan? timeout = null)
 259    {
 0260        timeout ??= TimeSpan.FromSeconds(2);
 0261        if (string.IsNullOrWhiteSpace(name))
 262        {
 0263            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 264        }
 0265        if (!_tasks.TryRemove(name, out var task))
 266        {
 0267            return false;
 268        }
 0269        _log.Information("Cancelling scheduler job (async) {Name}", name);
 0270        task.TokenSource.Cancel();
 0271        var runner = task.Runner;
 0272        if (runner is null)
 273        {
 0274            return true;
 275        }
 276        try
 277        {
 0278            using var cts = new CancellationTokenSource(timeout.Value);
 0279            var completed = await Task.WhenAny(runner, Task.Delay(Timeout.InfiniteTimeSpan, cts.Token)) == runner;
 0280            if (!completed)
 281            {
 0282                _log.Warning("Timeout waiting for scheduler job {Name} to cancel", name);
 283            }
 0284        }
 0285        catch (Exception ex)
 286        {
 0287            _log.Debug(ex, "Error while awaiting cancellation for job {Name}", name);
 0288        }
 0289        return true;
 0290    }
 291
 292    /// <summary>
 293    /// Cancels all scheduled jobs.
 294    /// </summary>
 295    public void CancelAll()
 296    {
 82297        foreach (var kvp in _tasks.Keys)
 298        {
 23299            _ = Cancel(kvp);
 300        }
 18301    }
 302
 303    /// <summary>
 304    /// Generates a report of all scheduled jobs, including their last and next run times, and suspension status.
 305    /// </summary>
 306    /// <param name="displayTz">The time zone to display times in; defaults to UTC if not specified.</param>
 307    /// <returns>A <see cref="ScheduleReport"/> containing information about all scheduled jobs.</returns>
 308    public ScheduleReport GetReport(TimeZoneInfo? displayTz = null)
 309    {
 310        // default to Zulu
 2311        var timezone = displayTz ?? TimeZoneInfo.Utc;
 2312        var now = DateTimeOffset.UtcNow;
 313
 2314        var jobs = _tasks.Values
 2315            .Select(t =>
 2316            {
 2317                // store timestamps internally in UTC; convert only for the report
 4318                var (lastRunAt, nextRunAt) = t.GetTimestamps();
 4319                var last = lastRunAt?.ToOffset(timezone.GetUtcOffset(lastRunAt.Value));
 4320                var next = nextRunAt.ToOffset(timezone.GetUtcOffset(nextRunAt));
 2321
 4322                return new JobInfo(t.Name, last, next, t.IsSuspended);
 2323            })
 4324            .OrderBy(j => j.NextRunAt)
 2325            .ToArray();
 326
 2327        return new ScheduleReport(now, jobs);
 328    }
 329
 330    /// <summary>
 331    /// Generates a report of all scheduled jobs in a PowerShell-friendly hashtable format.
 332    /// </summary>
 333    /// <param name="displayTz">The time zone to display times in; defaults to UTC if not specified.</param>
 334    /// <returns>A <see cref="Hashtable"/> containing information about all scheduled jobs.</returns>
 335    public Hashtable GetReportHashtable(TimeZoneInfo? displayTz = null)
 336    {
 1337        var rpt = GetReport(displayTz);
 338
 1339        var jobsArray = rpt.Jobs
 2340            .Select(j => new Hashtable
 2341            {
 2342                ["Name"] = j.Name,
 2343                ["LastRunAt"] = j.LastRunAt,
 2344                ["NextRunAt"] = j.NextRunAt,
 2345                ["IsSuspended"] = j.IsSuspended,
 2346                ["IsCompleted"] = j.IsCompleted
 2347            })
 1348            .ToArray();                       // powershell likes [] not IList<>
 349
 1350        return new Hashtable
 1351        {
 1352            ["GeneratedAt"] = rpt.GeneratedAt,
 1353            ["Jobs"] = jobsArray
 1354        };
 355    }
 356
 357
 358    /// <summary>
 359    /// Gets a snapshot of all scheduled jobs with their current state.
 360    /// </summary>
 361    /// <returns>An <see cref="IReadOnlyCollection{JobInfo}"/> containing job information for all scheduled jobs.</retur
 362    public IReadOnlyCollection<JobInfo> GetSnapshot()
 22363        => [.. _tasks.Values.Select(t =>
 22364        {
 32365            var (lastRunAt, nextRunAt) = t.GetTimestamps();
 32366            return new JobInfo(t.Name, lastRunAt, nextRunAt, t.IsSuspended, t.IsCompleted);
 22367        })];
 368
 369
 370    /// <summary>
 371    /// Gets a snapshot of all scheduled jobs with their current state, optionally filtered and formatted.
 372    /// </summary>
 373    /// <param name="tz">The time zone to display times in; defaults to UTC if not specified.</param>
 374    /// <param name="asHashtable">Whether to return the result as PowerShell-friendly hashtables.</param>
 375    /// <param name="nameFilter">Optional glob patterns to filter job names.</param>
 376    /// <returns>
 377    /// An <see cref="IReadOnlyCollection{T}"/> containing job information for all scheduled jobs,
 378    /// either as <see cref="JobInfo"/> objects or hashtables depending on <paramref name="asHashtable"/>.
 379    /// </returns>
 380    public IReadOnlyCollection<object> GetSnapshot(
 381       TimeZoneInfo? tz = null,
 382       bool asHashtable = false,
 383       params string[] nameFilter)
 384    {
 2385        tz ??= TimeZoneInfo.Utc;
 386
 387        bool Matches(string name)
 388        {
 389            if (nameFilter == null || nameFilter.Length == 0)
 390            {
 391                return true;
 392            }
 393
 394            foreach (var pat in nameFilter)
 395            {
 396                if (RegexUtils.IsGlobMatch(name, pat))
 397                {
 398                    return true;
 399                }
 400            }
 401
 402            return false;
 403        }
 404
 405        // fast path: no filter, utc, typed objects
 2406        if (nameFilter.Length == 0 && tz.Equals(TimeZoneInfo.Utc) && !asHashtable)
 407        {
 0408            return [.. _tasks.Values.Select(t =>
 0409            {
 0410                var (lastRunAt, nextRunAt) = t.GetTimestamps();
 0411                return (object)new JobInfo(t.Name, lastRunAt, nextRunAt, t.IsSuspended, t.IsCompleted);
 0412            })];
 413        }
 414
 2415        var jobs = _tasks.Values
 6416                         .Where(t => Matches(t.Name))
 2417                         .Select(t =>
 2418                         {
 3419                             var (lastRunAt, nextRunAt) = t.GetTimestamps();
 3420                             var last = lastRunAt?.ToOffset(tz.GetUtcOffset(lastRunAt ?? DateTimeOffset.UtcNow));
 3421                             var next = nextRunAt.ToOffset(tz.GetUtcOffset(nextRunAt));
 3422                             return new JobInfo(t.Name, last, next, t.IsSuspended, t.IsCompleted);
 2423                         })
 2424                         .OrderBy(j => j.NextRunAt)
 2425                         .ToArray();
 426
 2427        if (!asHashtable)
 428        {
 1429            return [.. jobs.Cast<object>()];
 430        }
 431
 432        // PowerShell-friendly shape
 2433        return [.. jobs.Select(j => (object)new Hashtable
 2434                {
 2435                    ["Name"]        = j.Name,
 2436                    ["LastRunAt"]   = j.LastRunAt,
 2437                    ["NextRunAt"]   = j.NextRunAt,
 2438                    ["IsSuspended"] = j.IsSuspended,
 2439                    ["IsCompleted"] = j.IsCompleted
 2440                })];
 441    }
 442
 443
 444    /// <summary>
 445    /// Pauses a scheduled job by its name.
 446    /// </summary>
 447    /// <param name="name">The name of the job to pause.</param>
 448    /// <returns>True if the job was found and paused; otherwise, false.</returns>
 2449    public bool Pause(string name) => Suspend(name);
 450    /// <summary>
 451    /// Resumes a scheduled job by its name.
 452    /// </summary>
 453    /// <param name="name">The name of the job to resume.</param>
 454    /// <returns>True if the job was found and resumed; otherwise, false.</returns>
 2455    public bool Resume(string name) => Suspend(name, false);
 456
 457    /*────────── INTERNALS ──────────*/
 458
 459    /// <summary>
 460    /// Suspends or resumes a scheduled job by its name.
 461    /// This method updates the suspension status of the job, allowing it to be paused or resumed.
 462    /// If the job is found, its IsSuspended property is updated accordingly.
 463    /// </summary>
 464    /// <param name="name">The name of the job to suspend or resume.</param>
 465    /// <param name="suspend">True to suspend the job; false to resume it.</param>
 466    /// <returns>True if the job was found and its status was updated; otherwise, false.</returns>
 467    /// <exception cref="ArgumentException"></exception>
 468    /// <remarks>
 469    /// This method is used internally to control the execution of scheduled jobs.
 470    /// It allows for dynamic control over job execution without needing to cancel and reschedule them.
 471    /// </remarks>
 472    private bool Suspend(string name, bool suspend = true)
 473    {
 4474        if (string.IsNullOrWhiteSpace(name))
 475        {
 2476            throw new ArgumentException("Task name cannot be null or empty.", nameof(name));
 477        }
 478
 2479        if (_tasks.TryGetValue(name, out var task))
 480        {
 2481            task.IsSuspended = suspend;
 2482            _log.Information("Scheduler job {Name} {Action}", name, suspend ? "paused" : "resumed");
 2483            return true;
 484        }
 0485        return false;
 486    }
 487
 488    /// <summary>
 489    /// Schedules a new job.
 490    /// This method is the core implementation for scheduling jobs, allowing for both cron-based and interval-based sche
 491    /// It creates a new <see cref="ScheduledTask"/> instance and starts it in a background loop.
 492    /// The task is added to the internal collection of tasks, and its next run time is calculated based on the provided
 493    /// If both cron and interval are null, an exception is thrown.
 494    /// </summary>
 495    /// <param name="name">The name of the job.</param>
 496    /// <param name="job">The job to execute.</param>
 497    /// <param name="cron">The cron expression for scheduling.</param>
 498    /// <param name="interval">The interval for scheduling.</param>
 499    /// <param name="runImmediately">Whether to run the job immediately.</param>
 500    /// <exception cref="ArgumentException"></exception>
 501    /// <exception cref="InvalidOperationException"></exception>
 502    /// <remarks>
 503    /// This method is used internally to schedule jobs and should not be called directly.
 504    /// It handles the creation of the task, its scheduling, and the management of its execution loop.
 505    /// The task is run in a separate background thread to avoid blocking the main application flow.
 506    /// </remarks>
 507    private void ScheduleCore(
 508        string name,
 509        Func<CancellationToken, Task> job,
 510        CronExpression? cron,
 511        TimeSpan? interval,
 512        bool runImmediately)
 513    {
 25514        if (cron is null && interval == null)
 515        {
 0516            throw new ArgumentException("Either cron or interval must be supplied.");
 517        }
 518
 25519        var cts = new CancellationTokenSource();
 25520        var task = new ScheduledTask(name, job, cron, interval, runImmediately, cts)
 25521        {
 25522            NextRunAt = interval != null
 25523                ? DateTimeOffset.UtcNow + interval.Value
 25524                : (DateTimeOffset.UtcNow + NextCronDelay(cron!, _tz)).ToUniversalTime(),
 25525        };
 526
 25527        if (!_tasks.TryAdd(name, task))
 528        {
 1529            throw new InvalidOperationException($"A task named '{name}' already exists.");
 530        }
 531
 48532        task.Runner = Task.Run(() => LoopAsync(task), cts.Token);
 24533        _log.Debug("Scheduled job '{Name}' (cron: {Cron}, interval: {Interval})", name, cron?.ToString(), interval);
 24534    }
 535
 536    /// <summary>
 537    /// Runs the scheduled task in a loop.
 538    /// This method handles the execution of the task according to its schedule, either immediately or based on a cron e
 539    /// It checks if the task is suspended and delays accordingly, while also being responsive to cancellation requests.
 540    /// </summary>
 541    /// <param name="task">The scheduled task to run.</param>
 542    /// <returns>A task representing the asynchronous operation.</returns>
 543    /// <remarks>
 544    /// This method is called internally by the scheduler to manage the execution of scheduled tasks.
 545    /// It ensures that tasks are run at the correct times and handles any exceptions that may occur during execution.
 546    /// The loop continues until the task is cancelled or the cancellation token is triggered.
 547    /// </remarks>
 548    private async Task LoopAsync(ScheduledTask task)
 549    {
 24550        var ct = task.TokenSource.Token;
 551
 24552        if (task.RunImmediately && !task.IsSuspended)
 553        {
 11554            await SafeRun(task.Work, task, ct);
 555        }
 556
 35557        while (!ct.IsCancellationRequested)
 558        {
 35559            if (task.IsSuspended)
 560            {
 561                // sleep a bit while suspended, but stay responsive to Cancel()
 1562                await Task.Delay(TimeSpan.FromSeconds(1), ct);
 1563                continue;
 564            }
 565
 566            TimeSpan delay;
 34567            if (task.Interval is not null)
 568            {
 569                // Align to the intended NextRunAt rather than drifting by fixed interval;
 570                // this reduces flakiness when scheduling overhead is high.
 27571                var until = task.NextRunAt - DateTimeOffset.UtcNow;
 27572                delay = until > TimeSpan.Zero ? until : TimeSpan.Zero;
 573            }
 574            else
 575            {
 7576                delay = NextCronDelay(task.Cron!, _tz);
 7577                if (delay < TimeSpan.Zero)
 578                {
 0579                    delay = TimeSpan.Zero;
 580                }
 581            }
 582
 44583            try { await Task.Delay(delay, ct); }
 48584            catch (TaskCanceledException) { break; }
 585
 10586            if (!ct.IsCancellationRequested)
 587            {
 10588                await SafeRun(task.Work, task, ct);
 589            }
 590        }
 24591        task.IsCompleted = true;
 24592    }
 593
 594    /// <summary>
 595    /// Calculates the next delay for a cron expression.
 596    /// This method computes the time until the next occurrence of the cron expression based on the current UTC time.
 597    /// If there are no future occurrences, it logs a warning and returns a maximum value.
 598    /// </summary>
 599    /// <param name="expr">The cron expression to evaluate.</param>
 600    /// <param name="tz">The time zone to use for the evaluation.</param>
 601    /// <returns>The time span until the next occurrence of the cron expression.</returns>
 602    /// <remarks>
 603    /// This method is used internally to determine when the next scheduled run of a cron-based task should occur.
 604    /// It uses the Cronos library to calculate the next occurrence based on the current UTC time and the specified time
 605    /// If no future occurrence is found, it logs a warning and returns a maximum time span.
 606    /// </remarks>
 607    private TimeSpan NextCronDelay(CronExpression expr, TimeZoneInfo tz)
 608    {
 14609        var next = expr.GetNextOccurrence(DateTimeOffset.UtcNow, tz);
 14610        if (next is null)
 611        {
 0612            _log.Warning("Cron expression {Expr} has no future occurrence", expr);
 613        }
 614
 14615        return next.HasValue ? next.Value - DateTimeOffset.UtcNow : TimeSpan.MaxValue;
 616    }
 617
 618    /// <summary>
 619    /// Safely runs the scheduled task, handling exceptions and updating the task's state.
 620    /// This method executes the provided work function and updates the task's last run time and next run time according
 621    /// </summary>
 622    /// <param name="work">The work function to execute.</param>
 623    /// <param name="task">The scheduled task to run.</param>
 624    /// <param name="ct">The cancellation token.</param>
 625    /// <returns>A task representing the asynchronous operation.</returns>
 626    /// <remarks>
 627    /// This method is called internally by the scheduler to manage the execution of scheduled tasks.
 628    /// It ensures that tasks are run at the correct times and handles any exceptions that may occur during execution.
 629    /// </remarks>
 630    private async Task SafeRun(Func<CancellationToken, Task> work, ScheduledTask task, CancellationToken ct)
 631    {
 632        try
 633        {
 634            // If cancellation was requested after the loop's check and before entering here, bail out.
 21635            if (ct.IsCancellationRequested)
 636            {
 0637                return;
 638            }
 21639            var runStartedAt = DateTimeOffset.UtcNow; // capture start time
 21640            await work(ct);
 641
 642            // compute next run (only if still scheduled). We compute fully *before* publishing
 643            // any timestamp changes so snapshots never see LastRunAt > NextRunAt.
 644            DateTimeOffset nextRun;
 21645            if (task.Interval != null)
 646            {
 15647                task.RunIteration++; // increment completed count
 15648                var interval = task.Interval.Value;
 15649                var next = task.AnchorAt + ((task.RunIteration + 1) * interval);
 15650                var now = DateTimeOffset.UtcNow;
 23651                while (next - now <= TimeSpan.Zero)
 652                {
 8653                    task.RunIteration++;
 8654                    next = task.AnchorAt + ((task.RunIteration + 1) * interval);
 8655                    if (task.RunIteration > 10_000) { break; }
 656                }
 15657                nextRun = next;
 658            }
 659            else
 660            {
 6661                nextRun = task.Cron is not null ? task.Cron.GetNextOccurrence(runStartedAt, _tz) ?? DateTimeOffset.MaxVa
 662            }
 663
 664            // Publish timestamps atomically to avoid inconsistent snapshot (race seen in CI where
 665            // LastRunAt advanced but NextRunAt still pointed to prior slot).
 21666            task.SetTimestamps(runStartedAt, nextRun);
 21667        }
 0668        catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* ignore */ }
 0669        catch (Exception ex)
 670        {
 0671            _log.Error(ex, "[Scheduler] Job '{Name}' failed", task.Name);
 0672        }
 21673    }
 674
 675    /// <summary>
 676    /// Disposes the scheduler and cancels all running tasks.
 677    /// </summary>
 678    /// <remarks>
 679    /// This method is called to clean up resources used by the scheduler service.
 680    /// It cancels all scheduled tasks and disposes of the runspace pool manager.
 681    /// </remarks>
 682    public void Dispose()
 683    {
 17684        CancelAll();
 17685        _pool.Dispose();
 17686        _log.Information("SchedulerService disposed");
 17687    }
 688}

Methods/Properties

.ctor(Kestrun.Scripting.KestrunRunspacePoolManager,Serilog.ILogger,System.TimeZoneInfo)
get_Host()
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()