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.
This commit is contained in:
2026-07-24 20:55:44 +02:00
parent e33c1aeabc
commit 82c67e32e1
19 changed files with 831 additions and 31 deletions
+61 -1
View File
@@ -37,6 +37,10 @@ type Job struct {
LastRun time.Time
LastErr error
Running bool
// resched (daily jobs only, buffered cap 1) is the runtime-reschedule signal: UpdateDaily mutates
// Schedule under the mutex and non-blocking-sends here so runDailyJob wakes and recomputes its next
// run immediately, instead of waiting out the old timer. Immutable after job creation.
resched chan struct{}
}
// Scheduler manages periodic and daily jobs.
@@ -120,6 +124,7 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
Name: name,
Fn: fn,
Schedule: timeStr,
resched: make(chan struct{}, 1),
}
s.jobs = append(s.jobs, job)
@@ -133,6 +138,50 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
}
}
// UpdateDaily changes the schedule of an already-registered daily job at runtime (no restart) and
// wakes its goroutine so the new time takes effect at the next scheduling pass. Returns false (and
// logs WARN) on an invalid time or an unknown/non-daily job name — the job is left untouched.
func (s *Scheduler) UpdateDaily(name, timeStr string) bool {
if _, _, err := parseDailyTime(timeStr); err != nil {
s.logger.Printf("[WARN] [scheduler] UpdateDaily %s: invalid schedule %q: %v — ignored", name, timeStr, err)
return false
}
s.mu.Lock()
var job *Job
for _, j := range s.jobs {
if j.Name == name && j.Schedule != "" {
job = j
break
}
}
if job == nil {
s.mu.Unlock()
s.logger.Printf("[WARN] [scheduler] UpdateDaily: no daily job named %q — ignored", name)
return false
}
old := job.Schedule
if old == timeStr {
s.mu.Unlock()
return true // no-op: already at this time
}
job.Schedule = timeStr
ch := job.resched
s.mu.Unlock()
s.logger.Printf("[INFO] [scheduler] Daily job %s rescheduled %s → %s (next run %s)",
name, old, timeStr, nextDailyRun(timeStr).Format("2006-01-02 15:04 MST"))
// Non-blocking wake: the buffered slot coalesces bursts (three legs updated back-to-back each
// signal their own job); if the goroutine hasn't drained yet the recompute already covers this.
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
return true
}
// Start begins running all registered jobs. Safe to call only once.
func (s *Scheduler) Start(ctx context.Context) {
s.mu.Lock()
@@ -234,7 +283,13 @@ func (s *Scheduler) runDailyJob(job *Job) {
defer s.wg.Done()
for {
nextRun := nextDailyRun(job.Schedule)
// Read Schedule under the mutex — UpdateDaily mutates it concurrently. resched is immutable
// after creation, so it is safe to read once here.
s.mu.Lock()
schedule := job.Schedule
s.mu.Unlock()
nextRun := nextDailyRun(schedule)
waitDuration := time.Until(nextRun)
if waitDuration < 0 {
@@ -249,6 +304,11 @@ func (s *Scheduler) runDailyJob(job *Job) {
timer.Stop()
s.dbg("daily job %s: context cancelled, stopping", job.Name)
return
case <-job.resched:
// Runtime reschedule: abandon the current timer and recompute against the new Schedule.
timer.Stop()
s.dbg("daily job %s: rescheduled — recomputing next run", job.Name)
continue
case <-timer.C:
s.executeJob(job, false)
}