Cron: Bereich mit Schritt (7-22/3) lesen, ungueltige Ausdruecke im Scanner melden (#597)
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>
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||||
-->
|
-->
|
||||||
<Version>0.1.4</Version>
|
<Version>0.1.5</Version>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ClawdDotNet.Core.Scheduling;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Einfaches Cron-Parsing für 5-Felder-Ausdrücke: Minute Stunde Tag Monat Wochentag
|
/// Einfaches Cron-Parsing für 5-Felder-Ausdrücke: Minute Stunde Tag Monat Wochentag
|
||||||
/// Unterstützt: Zahlen, Wildcards (*), Bereiche (1-5), Listen (1,3,5), Schritte (*/5)
|
/// Unterstützt: Zahlen, Wildcards (*), Bereiche (1-5), Listen (1,3,5), Schritte (*/5, 7-22/3, 10/15)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CronExpression
|
public sealed class CronExpression
|
||||||
{
|
{
|
||||||
@@ -103,21 +103,19 @@ public sealed class CronExpression
|
|||||||
if (split.Length != 2)
|
if (split.Length != 2)
|
||||||
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
||||||
|
|
||||||
var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
|
// Basis: "*" (ganzer Bereich), "a-b" (Bereich, #597) oder "a" (ab a bis Feldende).
|
||||||
|
// "0 7-22/3 * * *" fiel vorher durch, und der Scanner verschwieg es.
|
||||||
|
var (start, end) = split[0] == "*"
|
||||||
|
? (min, max)
|
||||||
|
: split[0].Contains('-')
|
||||||
|
? ParseRange(field, split[0], min, max)
|
||||||
|
: (ParseNumber(field, split[0], min, max), max);
|
||||||
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
||||||
for (var i = start; i <= max; i += step) result.Add(i);
|
for (var i = start; i <= end; i += step) result.Add(i);
|
||||||
}
|
}
|
||||||
else if (part.Contains('-'))
|
else if (part.Contains('-'))
|
||||||
{
|
{
|
||||||
var split = part.Split('-');
|
var (from, to) = ParseRange(field, part, min, max);
|
||||||
if (split.Length != 2)
|
|
||||||
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
|
||||||
|
|
||||||
var from = ParseNumber(field, split[0], min, max);
|
|
||||||
var to = ParseNumber(field, split[1], min, max);
|
|
||||||
if (from > to)
|
|
||||||
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
|
||||||
|
|
||||||
for (var i = from; i <= to; i++) result.Add(i);
|
for (var i = from; i <= to; i++) result.Add(i);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -129,6 +127,20 @@ public sealed class CronExpression
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (int From, int To) ParseRange(string field, string value, int min, int max)
|
||||||
|
{
|
||||||
|
var split = value.Split('-');
|
||||||
|
if (split.Length != 2)
|
||||||
|
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
||||||
|
|
||||||
|
var from = ParseNumber(field, split[0], min, max);
|
||||||
|
var to = ParseNumber(field, split[1], min, max);
|
||||||
|
if (from > to)
|
||||||
|
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
||||||
|
|
||||||
|
return (from, to);
|
||||||
|
}
|
||||||
|
|
||||||
private static int ParseNumber(string field, string value, int min, int max)
|
private static int ParseNumber(string field, string value, int min, int max)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(value, out var n))
|
if (!int.TryParse(value, out var n))
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public sealed class TaskScanner : IAsyncDisposable
|
|||||||
/// läuft jede Minute, die Meldung soll nicht mitlaufen.</summary>
|
/// läuft jede Minute, die Meldung soll nicht mitlaufen.</summary>
|
||||||
private readonly HashSet<string> _warnedTimeZones = [];
|
private readonly HashSet<string> _warnedTimeZones = [];
|
||||||
|
|
||||||
|
/// <summary>Aufgaben, deren ungültiger Cron-Ausdruck bereits gemeldet wurde.</summary>
|
||||||
|
private readonly HashSet<string> _warnedCron = [];
|
||||||
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private Task? _loop;
|
private Task? _loop;
|
||||||
|
|
||||||
@@ -173,6 +176,16 @@ public sealed class TaskScanner : IAsyncDisposable
|
|||||||
task.Id, task.Title, badZone);
|
task.Id, task.Title, badZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dasselbe für einen Cron-Ausdruck, den der Parser nicht versteht (#597).
|
||||||
|
if (TaskSchedule.InvalidCron(task) is { } cronError
|
||||||
|
&& _warnedCron.Add(task.Id))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Aufgabe {TaskId} ({Title}) hat den Termin cron \"{Cron}\", der nicht lesbar ist "
|
||||||
|
+ "— sie wird nicht ausgeführt. {Fehler}",
|
||||||
|
task.Id, task.Title, task.When!.Value, cronError);
|
||||||
|
}
|
||||||
|
|
||||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||||
if (occ is null)
|
if (occ is null)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ public static class TaskSchedule
|
|||||||
? when.TimeZone
|
? when.TimeZone
|
||||||
: null;
|
: 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)
|
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
||||||
{
|
{
|
||||||
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ public sealed class CronExpressionTests
|
|||||||
cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0));
|
cron.GetNextOccurrence(Local(2026, 3, 1, 0, 0)).ShouldBe(Local(2028, 2, 29, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bereich_mit_Schritt_alle_drei_Stunden_tagsueber()
|
||||||
|
{
|
||||||
|
// #597: "0 7-22/3 * * *" warf vorher eine FormatException, die Aufgabe lief nie.
|
||||||
|
var cron = CronExpression.Parse("0 7-22/3 * * *"); // 7, 10, 13, 16, 19, 22
|
||||||
|
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 0)).ShouldBe(Local(2026, 9, 14, 13, 0));
|
||||||
|
cron.GetNextOccurrence(Local(2026, 9, 14, 22, 0)).ShouldBe(Local(2026, 9, 15, 7, 0));
|
||||||
|
cron.Matches(Local(2026, 9, 14, 8, 0)).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zahl_mit_Schritt_laeuft_bis_zum_Feldende()
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse("10/20 * * * *"); // 10, 30, 50
|
||||||
|
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 31)).ShouldBe(Local(2026, 9, 14, 12, 50));
|
||||||
|
cron.GetNextOccurrence(Local(2026, 9, 14, 12, 50)).ShouldBe(Local(2026, 9, 14, 13, 10));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── R2: Invariante ───
|
// ─── R2: Invariante ───
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -77,6 +95,9 @@ public sealed class CronExpressionTests
|
|||||||
[InlineData("a b c d e")] // keine Zahlen
|
[InlineData("a b c d e")] // keine Zahlen
|
||||||
[InlineData("*/0 * * * *")] // Schritt 0 (wäre Endlosschleife)
|
[InlineData("*/0 * * * *")] // Schritt 0 (wäre Endlosschleife)
|
||||||
[InlineData("5-2 * * * *")] // rückwärtiger Bereich
|
[InlineData("5-2 * * * *")] // rückwärtiger Bereich
|
||||||
|
[InlineData("0 22-7/3 * * *")] // rückwärtiger Bereich mit Schritt
|
||||||
|
[InlineData("0 7-25/3 * * *")] // Bereichsende außerhalb 0-23
|
||||||
|
[InlineData("0 7-/3 * * *")] // halber Bereich
|
||||||
[InlineData("* * * * ")] // ein leeres Feld nach Trim → 4 Felder
|
[InlineData("* * * * ")] // ein leeres Feld nach Trim → 4 Felder
|
||||||
public void Ungueltige_Ausdruecke_werfen_eine_saubere_FormatException(string expr)
|
public void Ungueltige_Ausdruecke_werfen_eine_saubere_FormatException(string expr)
|
||||||
=> Should.Throw<FormatException>(() => CronExpression.Parse(expr));
|
=> Should.Throw<FormatException>(() => CronExpression.Parse(expr));
|
||||||
|
|||||||
@@ -13,6 +13,42 @@ public sealed class TaskScheduleTests
|
|||||||
private static DateTime Utc(int y, int mo, int d, int h, int mi)
|
private static DateTime Utc(int y, int mo, int d, int h, int mi)
|
||||||
=> new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
|
=> new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ─── #597: ungültiger Cron-Ausdruck ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ungueltiger_Cron_Ausdruck_wird_benannt_statt_verschwiegen()
|
||||||
|
{
|
||||||
|
var task = new TaskItem
|
||||||
|
{
|
||||||
|
Id = "t-1", CreatedAt = Utc(2026, 9, 14, 6, 0),
|
||||||
|
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7-22/x * * *", TimeZone = "Europe/Berlin" }
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskSchedule.DueOccurrence(task, Utc(2026, 9, 14, 11, 0)).ShouldBeNull();
|
||||||
|
TaskSchedule.InvalidCron(task).ShouldNotBeNull().ShouldContain("'x' ist keine Zahl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nachrichten_Digest_aus_dem_Testbetrieb_ist_um_13_Uhr_faellig()
|
||||||
|
{
|
||||||
|
var task = new TaskItem
|
||||||
|
{
|
||||||
|
Id = "t-7e0bbe", CreatedAt = Utc(2026, 9, 14, 10, 30),
|
||||||
|
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7-22/3 * * *", TimeZone = "Europe/Berlin" }
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskSchedule.InvalidCron(task).ShouldBeNull();
|
||||||
|
// 13:00 Berlin (MESZ) = 11:00 UTC
|
||||||
|
TaskSchedule.DueOccurrence(task, Utc(2026, 9, 14, 11, 0)).ShouldBe("2026-09-14T11:00:00.0000000Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Andere_Terminarten_haben_keinen_Cron_Fehler()
|
||||||
|
=> TaskSchedule.InvalidCron(new TaskItem
|
||||||
|
{
|
||||||
|
Id = "t-1", When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "kaputt" }
|
||||||
|
}).ShouldBeNull();
|
||||||
|
|
||||||
// ─── Einmalig, ohne Termin ───
|
// ─── Einmalig, ohne Termin ───
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user