Retry a failed claim for its own period before later fire times
CI / ci (push) Successful in 7m29s
CI / ci (pull_request) Successful in 7m44s

A failed claim was retried by recomputing the most recent fire time from
the new now, so a daily claim that failed on 7 October and recovered after
the 8 October fire claimed only 8 October: the 7 October period was never
claimed nor recorded as missed. The scheduler now keeps each failed
claim's fire time and claims that same period every ClaimRetry until it
succeeds, applies the catch-up rule to it then, and only afterwards
handles later fire times and settings changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 19:02:09 -04:00
co-authored by Claude Opus 5.5
parent 6f7e1d2342
commit bcd875c11f
2 changed files with 100 additions and 38 deletions
+41 -7
View File
@@ -603,9 +603,9 @@ func TestSettingsChangeAppliedLateStillFires(t *testing.T) {
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// A claim that fails is retried, not skipped, and the period then runs once.
func TestClaimFailureIsRetried(t *testing.T) {
e := newEnv(t)
// failClaims makes every claim fail until recover is called.
func (e *env) failClaims() (recover func()) {
e.t.Helper()
for _, q := range []string{
`CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`,
`INSERT INTO fail_claims VALUES (1)`,
@@ -614,9 +614,21 @@ func TestClaimFailureIsRetried(t *testing.T) {
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`,
} {
if _, err := e.st.Writer.Exec(q); err != nil {
t.Fatal(err)
e.t.Fatal(err)
}
}
return func() {
e.t.Helper()
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
e.t.Fatal(err)
}
}
}
// A claim that fails is retried, not skipped, and the period then runs once.
func TestClaimFailureIsRetried(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
wantKeys(t, "boot with failing claims", rec.keys(), nil)
@@ -625,9 +637,7 @@ func TestClaimFailureIsRetried(t *testing.T) {
t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry)
}
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
t.Fatal(err)
}
recoverClaims()
r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry))
wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 7, 23, 0))
@@ -635,6 +645,30 @@ func TestClaimFailureIsRetried(t *testing.T) {
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// A claim that keeps failing past the next daily fire is retried for its
// own period, not the most recent one: that period, now more than
// CatchUpWindow old, is recorded as missed, and the later period runs once.
func TestClaimFailureRecoversAfterNextFire(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
// The 7 October 16:30 reminder is due and its claim fails, past the
// 8 October 16:30 fire.
r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec))
r.runUntil(at(2026, 10, 8, 17, 0))
wantKeys(t, "runs while failing", rec.keys(), nil)
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
// Claims recover 24h31m after the 7 October fire.
recoverClaims()
r.runUntil(at(2026, 10, 8, 17, 1))
wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"})
wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"})
r.runUntil(at(2026, 10, 9, 16, 0))
wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"})
}
func TestSettingsChangeTimezone(t *testing.T) {
e := newEnv(t)
var rec recorder