using System.Text.RegularExpressions; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// Auto-backup driven by config.BackupSchedule. Accepted formats: /// - "HH:mm" single daily slot (e.g. "04:00") /// - "HH:mm,HH:mm,..." multiple daily slots /// - "every Nh" every N hours (>= 15 minutes) /// - "every Nm" every N minutes /// Wakes once a minute and fires backups when the clock matches the spec. /// Doesn't catch up if the server was off when a slot passed -- daily/interval /// backups don't need replay logic. /// public sealed class BackupScheduler : IDisposable { private readonly ServerConfig _config; private readonly BackupService _backup; private readonly Action _log; private CancellationTokenSource? _cts; private Task? _loop; // Tracking for "fired" state. For interval: just the last fire time. For // daily-times: which times have fired today, reset at day rollover. private DateTimeOffset? _lastIntervalFire; private DateOnly _lastFireDay = DateOnly.MinValue; private readonly HashSet _firedToday = new(); public BackupScheduler(ServerConfig config, BackupService backup, Action log) { _config = config; _backup = backup; _log = log; } public void Start() { if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return; if (Parse(_config.BackupSchedule) == default) { _log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled."); return; } _cts?.Cancel(); _cts = new CancellationTokenSource(); _loop = Task.Run(() => RunAsync(_cts.Token)); _log($"[backup-scheduler] Schedule active: {Describe()}"); } /// Stop the current loop and re-Start with the latest config values. public void Reload() { try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } _cts?.Dispose(); _cts = null; _loop = null; Start(); } /// Compute the next future scheduled fire time. Null if no schedule. public DateTimeOffset? NextRun() { var (interval, times) = Parse(_config.BackupSchedule); if (interval.HasValue) { var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1); var next = baseTime + interval.Value; // If we've never fired and we're past the implied first slot, "next" might be // in the past -- clamp to "imminent" by using now + small buffer. if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1); return next.ToLocalTime(); } if (times is not null) { var now = DateTime.Now; var nowTime = TimeOnly.FromDateTime(now); // Use Cast().FirstOrDefault() so "no pending" is null rather than 00:00. var pendingToday = times .Where(t => t > nowTime && !_firedToday.Contains(t)) .OrderBy(t => t) .Cast() .FirstOrDefault(); if (pendingToday.HasValue) return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan())); // None left today -- first slot tomorrow. var firstTomorrow = times.Min(); return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan())); } return null; } private async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromMinutes(1), ct); } catch (OperationCanceledException) { break; } var (interval, times) = Parse(_config.BackupSchedule); if (interval is null && times is null) continue; var nowUtc = DateTimeOffset.UtcNow; var nowLocal = DateTime.Now; var today = DateOnly.FromDateTime(nowLocal); var nowTime = TimeOnly.FromDateTime(nowLocal); bool shouldFire = false; if (interval.HasValue) { shouldFire = !_lastIntervalFire.HasValue || (nowUtc - _lastIntervalFire.Value) >= interval.Value; } else if (times is not null) { if (today != _lastFireDay) { _firedToday.Clear(); _lastFireDay = today; } foreach (var t in times) { if (t <= nowTime && !_firedToday.Contains(t)) { shouldFire = true; _firedToday.Add(t); break; } } } if (!shouldFire) continue; _log("[backup-scheduler] Triggering scheduled backup."); try { var result = await _backup.CreateAsync("scheduled", ct: ct); if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB)."); else _log($"[backup-scheduler] Failed: {result.Error}"); if (interval.HasValue) _lastIntervalFire = nowUtc; } catch (OperationCanceledException) { break; } catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); } } } /// Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty. private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input) { if (string.IsNullOrWhiteSpace(input)) return (null, null); var s = input.Trim().ToLowerInvariant(); // every Nh / every Nm var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$"); if (m.Success) { var n = int.Parse(m.Groups[1].Value); var unit = m.Groups[2].Value; var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n); // Sanity floor -- anything below 15 min creates more save-lag than backups are worth. if (span < TimeSpan.FromMinutes(15)) return (null, null); if (span > TimeSpan.FromDays(7)) return (null, null); return (span, null); } // Comma-separated HH:mm var list = new List(); foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries)) { if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null); list.Add(t); } return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray()); } public string Describe() { var (interval, times) = Parse(_config.BackupSchedule); if (interval.HasValue) { var totalMin = (int)interval.Value.TotalMinutes; if (totalMin >= 60 && totalMin % 60 == 0) { var h = totalMin / 60; return h == 1 ? "Every hour" : $"Every {h} hours"; } return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes"; } if (times is not null) { if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}"; return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm"))); } return "Disabled"; } public void Dispose() { try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } _cts?.Dispose(); } }