Der Nachrichten-Digest im Dauerbetrieb der Test-VM lief nie: CronExpression verstand 'von-bis/schritt' nicht, TaskSchedule verschluckte den Fehler. Version 0.1.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
11 KiB
C#
270 lines
11 KiB
C#
using System.Globalization;
|
|
using ClawdDotNet.Core.Scheduling;
|
|
|
|
namespace ClawdDotNet.Core.Tasks;
|
|
|
|
/// <summary>
|
|
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
|
|
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
|
|
///
|
|
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
|
|
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
|
|
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
|
|
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
|
|
///
|
|
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
|
|
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
|
|
/// eine Welle aus.
|
|
/// </summary>
|
|
public static class TaskSchedule
|
|
{
|
|
/// <summary>
|
|
/// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
|
|
/// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
|
|
/// (<see cref="TaskItem.LastOccurrence"/>) liegt.
|
|
/// </summary>
|
|
public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
|
|
{
|
|
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
|
|
|
if (task.When is null)
|
|
{
|
|
// Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
|
|
if (task.LastOccurrence is not null) return null;
|
|
var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
|
|
return Iso(basis);
|
|
}
|
|
|
|
return task.When.Kind switch
|
|
{
|
|
TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
|
|
TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
|
|
TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
|
|
/// <c>null</c>.
|
|
///
|
|
/// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
|
|
/// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
|
|
/// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
|
|
/// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
|
|
/// </summary>
|
|
public static string? UnresolvableTimeZone(TaskItem task)
|
|
=> task.When is { } when
|
|
&& !string.IsNullOrWhiteSpace(when.TimeZone)
|
|
&& !TimeZones.IsKnown(when.TimeZone)
|
|
? when.TimeZone
|
|
: null;
|
|
|
|
/// <summary>
|
|
/// Der Parserfehler eines Cron-Termins, wenn der Ausdruck ungültig ist — sonst
|
|
/// <c>null</c>.
|
|
///
|
|
/// Ein solcher Termin feuert nie (<c>DueCron</c> fängt den Fehler ab, damit ein
|
|
/// kaputter Ausdruck nicht den ganzen Takt kippt). Wie bei der Zeitzone meldet der
|
|
/// Scanner das einmal je Aufgabe — vorher stand die Aufgabe als <c>todo</c> mit Termin
|
|
/// im Taskboard und lief schlicht nie (#597).
|
|
/// </summary>
|
|
public static string? InvalidCron(TaskItem task)
|
|
{
|
|
if (task.When is not { Kind: TaskWhenKind.Cron } when) return null;
|
|
try
|
|
{
|
|
CronExpression.Parse(when.Value);
|
|
return null;
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
return ex.Message;
|
|
}
|
|
}
|
|
|
|
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
|
{
|
|
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
|
if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
|
|
if (nowUtc < targetUtc) return null;
|
|
return Iso(targetUtc);
|
|
}
|
|
|
|
private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
|
|
{
|
|
if (!TryParseInterval(task.When!.Value, out var interval)) return null;
|
|
|
|
var anchor = marker is not null
|
|
? ParseIso(marker)
|
|
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
|
|
|
|
return nowUtc < anchor + interval ? null : Iso(nowUtc);
|
|
}
|
|
|
|
private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
|
|
{
|
|
var when = task.When!;
|
|
CronExpression cron;
|
|
try { cron = CronExpression.Parse(when.Value); }
|
|
catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
|
|
|
|
var tz = ResolveTimeZone(when.TimeZone);
|
|
if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
|
|
|
|
var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
|
|
|
|
// Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
|
|
// ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
|
|
// nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
|
|
var lowerUtc = marker is not null ? ParseIso(marker)
|
|
: task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
|
|
: nowUtc;
|
|
var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
|
|
|
|
var limit = lowerLocal;
|
|
DateTime? matchLocal = null;
|
|
for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
|
|
{
|
|
if (cron.Matches(candidate)) { matchLocal = candidate; break; }
|
|
}
|
|
|
|
if (matchLocal is null) return null;
|
|
|
|
if (!TryToUtc(matchLocal.Value, tz, out var occUtc)) return null;
|
|
var occ = Iso(occUtc);
|
|
|
|
// Nur fällig, wenn der Termin echt über dem Marker liegt.
|
|
return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
|
|
}
|
|
|
|
// ─── Hilfsfunktionen ───
|
|
|
|
/// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
|
|
/// angegebenen Zeitzone.</summary>
|
|
private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
|
|
{
|
|
utc = default;
|
|
var v = value.Trim();
|
|
if (v.Length == 0) return false;
|
|
|
|
if (v.EndsWith('Z') || v.EndsWith('z'))
|
|
{
|
|
if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal, out var dto))
|
|
{
|
|
utc = dto.UtcDateTime;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
|
|
{
|
|
var tz = ResolveTimeZone(timeZone);
|
|
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
|
|
|
|
return TryToUtc(local, tz, out utc);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rechnet eine Wanduhrzeit in ihren UTC-Zeitpunkt um — auch dann, wenn es diese
|
|
/// Uhrzeit an diesem Tag gar nicht gibt.
|
|
///
|
|
/// <para><b>Der Frühjahrssprung.</b> In der Nacht der Umstellung springt die Uhr von
|
|
/// 02:00 auf 03:00; „02:30" existiert an diesem Tag nicht. Vorher fiel ein Termin
|
|
/// darin schlicht aus — einmal im Jahr lief eine tägliche Aufgabe nicht, und weil
|
|
/// nichts scheiterte, fiel es niemandem auf. Ein ausgefallener Lauf ist kein
|
|
/// harmloseres Ergebnis als ein verschobener, nur ein leiseres.</para>
|
|
///
|
|
/// <para>Der Termin wandert deshalb auf den ersten Zeitpunkt, den es nach der Lücke
|
|
/// gibt — aus 02:30 wird 03:00, nicht 03:30. So spät wie nötig, so früh wie möglich;
|
|
/// die Reihenfolge mehrerer Termine in derselben Lücke bleibt dabei erhalten, weil
|
|
/// sie alle auf denselben Zeitpunkt fallen und der Marker den zweiten abfängt.</para>
|
|
///
|
|
/// <para><b>Der Herbstsprung</b> braucht nichts Eigenes: Eine doppelt vorkommende
|
|
/// Uhrzeit rechnet <c>ConvertTimeToUtc</c> auf die Normalzeit-Fassung um, also auf
|
|
/// den zweiten Durchlauf. Beide Durchläufe ergeben denselben Occurrence-Key, und der
|
|
/// Marker sorgt dafür, dass nur einer davon feuert.</para>
|
|
/// </summary>
|
|
private static bool TryToUtc(DateTime local, TimeZoneInfo tz, out DateTime utc)
|
|
{
|
|
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
|
|
|
|
if (!tz.IsInvalidTime(unspecified))
|
|
{
|
|
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
|
return true;
|
|
}
|
|
|
|
// Minutenweise vorrücken, bis die Uhr wieder eine Zeit zeigt, die es gibt.
|
|
// Die Obergrenze ist Vorsicht, kein Fachwissen: Die größte gebräuchliche Lücke
|
|
// ist eine Stunde (Lord Howe Island: 30 Minuten). Bräuchte es je mehr als drei,
|
|
// stimmt etwas anderes nicht, und dann ist „gar nicht" das ehrlichere Ergebnis.
|
|
for (var probe = unspecified.AddMinutes(1);
|
|
probe <= unspecified.AddHours(3);
|
|
probe = probe.AddMinutes(1))
|
|
{
|
|
if (tz.IsInvalidTime(probe)) continue;
|
|
|
|
utc = TimeZoneInfo.ConvertTimeToUtc(probe, tz);
|
|
return true;
|
|
}
|
|
|
|
utc = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
|
|
private static bool TryParseInterval(string value, out TimeSpan interval)
|
|
{
|
|
interval = default;
|
|
var v = value.Trim().ToLowerInvariant();
|
|
if (v.Length < 2) return false;
|
|
|
|
var unit = v[^1];
|
|
if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
|
|
return false;
|
|
|
|
interval = unit switch
|
|
{
|
|
's' => TimeSpan.FromSeconds(n),
|
|
'm' => TimeSpan.FromMinutes(n),
|
|
'h' => TimeSpan.FromHours(n),
|
|
'd' => TimeSpan.FromDays(n),
|
|
_ => TimeSpan.Zero
|
|
};
|
|
return interval > TimeSpan.Zero;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
|
|
///
|
|
/// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
|
|
/// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
|
|
/// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
|
|
/// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
|
|
///
|
|
/// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
|
|
/// mit unbekannter Zone mit Meldung ab.
|
|
/// </summary>
|
|
private static TimeZoneInfo? ResolveTimeZone(string id)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
|
|
return TimeZones.TryResolve(id);
|
|
}
|
|
|
|
private static DateTime Truncate(DateTime value)
|
|
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
|
|
|
|
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
|
|
|
private static DateTime ParseIso(string value)
|
|
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
|
? dt.ToUniversalTime()
|
|
: DateTime.MinValue;
|
|
}
|