Files
felhom-controller/controller/internal/scheduler/scheduler_test.go
T
admin 82c67e32e1 v0.168.0: customer-configurable backup window (Mentési időablak)
ONE setting (window start W) drives every nightly leg at fixed, never-stored
offsets: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). Precedence
settings > controller.yaml db_dump_schedule > 02:30.

- scheduler.UpdateDaily: retime a daily job at runtime (no restart) via a per-job
  buffered resched chan + a select case in runDailyJob.
- new pure package internal/backupwindow (LegTimes/GateWindow/EffectiveWindow).
- quiesce disk-tier window gate: scheduled cycles run only inside [W+2h,W+6h) with a
  safety valve (age>cadence+24h runs regardless); manual TriggerNow never gated.
  Backend.Due now also returns the backup age (from the agent's own /backup/due).
- backup page: Mentési időablak card (time input + derived leg/gate rows); POST
  /backups/window validates -> saves -> UpdateDaily x3 -> flash.

Tests: 5 groups, all red-proofed. Agent/cadence//backup/due untouched.
2026-07-24 20:55:44 +02:00

82 lines
2.8 KiB
Go

package scheduler
import (
"context"
"io"
"log"
"testing"
"time"
)
func discardScheduler() *Scheduler { return New(log.New(io.Discard, "", 0)) }
func noopJob(context.Context) error { return nil }
// farFuture returns an HH:MM roughly n hours ahead in Budapest, so a registered daily job parks on a
// long timer (never fires during a sub-second test) yet is a valid, distinct schedule to switch to.
func farFuture(hoursAhead int) string {
return time.Now().In(getBudapestLocation()).Add(time.Duration(hoursAhead) * time.Hour).Format("15:04")
}
// Group C — UpdateDaily mutates the schedule and delivers the reschedule signal. Asserted on a
// NOT-started scheduler so no goroutine competes for the buffered signal (race-free).
func TestUpdateDaily_MutatesAndSignals(t *testing.T) {
s := discardScheduler()
s.Daily("db-dump", "02:30", noopJob)
if !s.UpdateDaily("db-dump", "22:00") {
t.Fatal("UpdateDaily returned false for a valid change")
}
if got := s.jobs[0].Schedule; got != "22:00" {
t.Errorf("Schedule = %q, want 22:00", got)
}
// The buffered reschedule signal must be present (no goroutine drained it).
select {
case <-s.jobs[0].resched:
default:
t.Error("no reschedule signal was sent by UpdateDaily")
}
}
// Group C — unknown or non-daily job name, and an invalid time, both return false without panicking
// and leave everything untouched.
func TestUpdateDaily_UnknownAndInvalid(t *testing.T) {
s := discardScheduler()
s.Daily("db-dump", "02:30", noopJob)
if s.UpdateDaily("does-not-exist", "03:00") {
t.Error("UpdateDaily on an unknown job returned true")
}
if s.UpdateDaily("db-dump", "25:99") {
t.Error("UpdateDaily with an invalid time returned true")
}
if got := s.jobs[0].Schedule; got != "02:30" {
t.Errorf("Schedule was mutated by a rejected update: %q", got)
}
}
// Group C — a running daily goroutine picks up the new time on the next pass: it CONSUMES the
// reschedule signal from the select and recomputes. Red-proof: remove the `case <-job.resched`
// from runDailyJob's select and this fails (the signal stays buffered — never consumed).
func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
s := discardScheduler()
s.Daily("db-dump", farFuture(6), noopJob) // parked on a ~6h timer
ctx, cancel := context.WithCancel(context.Background())
s.Start(ctx)
if !s.UpdateDaily("db-dump", farFuture(8)) {
t.Fatal("UpdateDaily returned false")
}
time.Sleep(150 * time.Millisecond) // let the goroutine wake on resched and recompute
cancel()
s.Stop() // waits for the goroutine to exit — after this, reading resched is race-free
select {
case <-s.jobs[0].resched:
t.Fatal("reschedule signal still buffered — runDailyJob never consumed it (no resched case)")
default:
// drained by the goroutine → immediacy works
}
}