75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
407 lines
10 KiB
Go
407 lines
10 KiB
Go
package intervals
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
type Interval struct {
|
|
Type IntervalType `json:"type" gorm:"column:interval_type;type:text;not null"`
|
|
|
|
TimeOfDay *string `json:"timeOfDay" gorm:"column:time_of_day;type:text"`
|
|
Weekday *int `json:"weekday,omitempty" gorm:"column:weekday;type:int"`
|
|
DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"column:day_of_month;type:int"`
|
|
CronExpression *string `json:"cronExpression,omitempty" gorm:"column:cron_expression;type:text"`
|
|
}
|
|
|
|
func (i *Interval) Validate() error {
|
|
switch i.Type {
|
|
case IntervalHourly, IntervalDaily, IntervalWeekly, IntervalMonthly, IntervalCron:
|
|
default:
|
|
return fmt.Errorf("invalid interval type: %q", i.Type)
|
|
}
|
|
|
|
if i.Type == IntervalDaily || i.Type == IntervalWeekly || i.Type == IntervalMonthly {
|
|
if i.TimeOfDay == nil {
|
|
return errors.New("time of day is required for daily, weekly and monthly intervals")
|
|
}
|
|
|
|
if _, err := time.Parse("15:04", *i.TimeOfDay); err != nil {
|
|
return fmt.Errorf("invalid time of day: %w", err)
|
|
}
|
|
}
|
|
|
|
if i.Type == IntervalWeekly {
|
|
if i.Weekday == nil {
|
|
return errors.New("weekday is required for weekly intervals")
|
|
}
|
|
|
|
// 0 and 7 both mean Sunday — see shouldTriggerWeekly for the alias handling
|
|
if *i.Weekday < 0 || *i.Weekday > 7 {
|
|
return errors.New("weekday must be between 0 and 7")
|
|
}
|
|
}
|
|
|
|
if i.Type == IntervalMonthly {
|
|
if i.DayOfMonth == nil {
|
|
return errors.New("day of month is required for monthly intervals")
|
|
}
|
|
|
|
if *i.DayOfMonth < 1 || *i.DayOfMonth > 31 {
|
|
return errors.New("day of month must be between 1 and 31")
|
|
}
|
|
}
|
|
|
|
if i.Type == IntervalCron {
|
|
if i.CronExpression == nil || *i.CronExpression == "" {
|
|
return errors.New("cron expression is required for cron intervals")
|
|
}
|
|
|
|
if err := i.validateCronExpression(*i.CronExpression); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ShouldTriggerBackup checks if a backup should be triggered based on the interval and last backup time
|
|
func (i *Interval) ShouldTriggerBackup(now time.Time, lastBackupTime *time.Time) bool {
|
|
// If no backup has been made yet, trigger immediately
|
|
if lastBackupTime == nil {
|
|
return true
|
|
}
|
|
|
|
switch i.Type {
|
|
case IntervalHourly:
|
|
return now.Sub(*lastBackupTime) >= time.Hour
|
|
case IntervalDaily:
|
|
return i.shouldTriggerDaily(now, *lastBackupTime)
|
|
case IntervalWeekly:
|
|
return i.shouldTriggerWeekly(now, *lastBackupTime)
|
|
case IntervalMonthly:
|
|
return i.shouldTriggerMonthly(now, *lastBackupTime)
|
|
case IntervalCron:
|
|
return i.shouldTriggerCron(now, *lastBackupTime)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// NextTriggerTime computes the next time a backup should trigger based on the interval and last backup time.
|
|
// Returns nil when a backup is due immediately (no previous backup exists).
|
|
func (i *Interval) NextTriggerTime(now time.Time, lastBackupTime *time.Time) *time.Time {
|
|
if lastBackupTime == nil {
|
|
return nil
|
|
}
|
|
|
|
switch i.Type {
|
|
case IntervalHourly:
|
|
next := lastBackupTime.Add(time.Hour)
|
|
return &next
|
|
|
|
case IntervalDaily:
|
|
next := i.nextDailyTrigger(now)
|
|
return &next
|
|
|
|
case IntervalWeekly:
|
|
next := i.nextWeeklyTrigger(now)
|
|
return &next
|
|
|
|
case IntervalMonthly:
|
|
next := i.nextMonthlyTrigger(now)
|
|
return &next
|
|
|
|
case IntervalCron:
|
|
return i.nextCronTrigger(*lastBackupTime)
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ApproxPeriod returns a coarse cadence for the interval, used only where an
|
|
// exact next-trigger is unnecessary (e.g. sizing a bounded wait window as a
|
|
// fraction of the cadence). Cron and unknown types return 0 so callers fall
|
|
// back to their own cap rather than guessing a period from an arbitrary
|
|
// expression.
|
|
func (i *Interval) ApproxPeriod() time.Duration {
|
|
switch i.Type {
|
|
case IntervalHourly:
|
|
return time.Hour
|
|
case IntervalDaily:
|
|
return 24 * time.Hour
|
|
case IntervalWeekly:
|
|
return 7 * 24 * time.Hour
|
|
case IntervalMonthly:
|
|
return 30 * 24 * time.Hour
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (i *Interval) Copy() Interval {
|
|
return Interval{
|
|
Type: i.Type,
|
|
TimeOfDay: i.TimeOfDay,
|
|
Weekday: i.Weekday,
|
|
DayOfMonth: i.DayOfMonth,
|
|
CronExpression: i.CronExpression,
|
|
}
|
|
}
|
|
|
|
// daily trigger: honour the TimeOfDay slot and catch up the previous one
|
|
func (i *Interval) shouldTriggerDaily(now, lastBackup time.Time) bool {
|
|
if i.TimeOfDay == nil {
|
|
return !isSameDay(lastBackup, now)
|
|
}
|
|
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err != nil {
|
|
return false // malformed ⇒ play safe
|
|
}
|
|
|
|
// Today's scheduled slot (todayTgt)
|
|
todayTgt := time.Date(
|
|
now.Year(), now.Month(), now.Day(),
|
|
t.Hour(), t.Minute(), 0, 0, now.Location(),
|
|
)
|
|
|
|
// The last scheduled slot that should already have happened
|
|
var lastScheduled time.Time
|
|
if now.Before(todayTgt) {
|
|
lastScheduled = todayTgt.AddDate(0, 0, -1)
|
|
} else {
|
|
lastScheduled = todayTgt
|
|
}
|
|
|
|
// Fire when we are past that slot AND no backup has been taken since it
|
|
return (now.After(lastScheduled) || now.Equal(lastScheduled)) &&
|
|
lastBackup.Before(lastScheduled)
|
|
}
|
|
|
|
// weekly trigger: on specified weekday/calendar week, otherwise ≥7 days
|
|
func (i *Interval) shouldTriggerWeekly(now, lastBackup time.Time) bool {
|
|
if i.Weekday != nil {
|
|
targetWd := time.Weekday(*i.Weekday)
|
|
|
|
// Calculate the target datetime for this week
|
|
startOfWeek := getStartOfWeek(now)
|
|
|
|
// Convert Go weekday to days from Monday: Sunday=6, Monday=0, Tuesday=1, ..., Saturday=5
|
|
var daysFromMonday int
|
|
if targetWd == time.Sunday {
|
|
daysFromMonday = 6
|
|
} else {
|
|
daysFromMonday = int(targetWd) - 1
|
|
}
|
|
|
|
targetThisWeek := startOfWeek.AddDate(0, 0, daysFromMonday)
|
|
|
|
if i.TimeOfDay != nil {
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err == nil {
|
|
targetThisWeek = time.Date(
|
|
targetThisWeek.Year(),
|
|
targetThisWeek.Month(),
|
|
targetThisWeek.Day(),
|
|
t.Hour(),
|
|
t.Minute(),
|
|
0,
|
|
0,
|
|
targetThisWeek.Location(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// If current time is at or after the target time this week
|
|
// and no backup has been made at or after the target time, trigger
|
|
if now.After(targetThisWeek) || now.Equal(targetThisWeek) {
|
|
return lastBackup.Before(targetThisWeek)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// no Weekday: generic 7-day interval
|
|
return now.Sub(lastBackup) >= 7*24*time.Hour
|
|
}
|
|
|
|
// monthly trigger: on specified day/calendar month, otherwise next calendar month
|
|
func (i *Interval) shouldTriggerMonthly(now, lastBackup time.Time) bool {
|
|
if i.DayOfMonth != nil {
|
|
day := *i.DayOfMonth
|
|
|
|
// Calculate the target datetime for this month
|
|
targetThisMonth := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location())
|
|
|
|
if i.TimeOfDay != nil {
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err == nil {
|
|
targetThisMonth = time.Date(
|
|
targetThisMonth.Year(),
|
|
targetThisMonth.Month(),
|
|
targetThisMonth.Day(),
|
|
t.Hour(),
|
|
t.Minute(),
|
|
0,
|
|
0,
|
|
targetThisMonth.Location(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// If current time is at or after the target time this month
|
|
// and no backup has been made at or after the target time, trigger
|
|
if now.After(targetThisMonth) || now.Equal(targetThisMonth) {
|
|
return lastBackup.Before(targetThisMonth)
|
|
}
|
|
|
|
return false
|
|
}
|
|
// no DayOfMonth: if we're in a new calendar month
|
|
return lastBackup.Before(getStartOfMonth(now))
|
|
}
|
|
|
|
func isSameDay(a, b time.Time) bool {
|
|
y1, m1, d1 := a.Date()
|
|
y2, m2, d2 := b.Date()
|
|
return y1 == y2 && m1 == m2 && d1 == d2
|
|
}
|
|
|
|
func getStartOfWeek(t time.Time) time.Time {
|
|
wd := int(t.Weekday())
|
|
if wd == 0 {
|
|
wd = 7
|
|
}
|
|
return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
func getStartOfMonth(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
// cron trigger: check if we've passed a scheduled cron time since last backup
|
|
func (i *Interval) shouldTriggerCron(now, lastBackup time.Time) bool {
|
|
if i.CronExpression == nil || *i.CronExpression == "" {
|
|
return false
|
|
}
|
|
|
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
schedule, err := parser.Parse(*i.CronExpression)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Find the next scheduled time after the last backup
|
|
nextAfterLastBackup := schedule.Next(lastBackup)
|
|
|
|
// If we're at or past that next scheduled time, trigger
|
|
return now.After(nextAfterLastBackup) || now.Equal(nextAfterLastBackup)
|
|
}
|
|
|
|
func (i *Interval) nextDailyTrigger(now time.Time) time.Time {
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err != nil {
|
|
return now
|
|
}
|
|
|
|
todaySlot := time.Date(
|
|
now.Year(), now.Month(), now.Day(),
|
|
t.Hour(), t.Minute(), 0, 0, now.Location(),
|
|
)
|
|
|
|
if now.Before(todaySlot) {
|
|
return todaySlot
|
|
}
|
|
|
|
return todaySlot.AddDate(0, 0, 1)
|
|
}
|
|
|
|
func (i *Interval) nextWeeklyTrigger(now time.Time) time.Time {
|
|
targetWd := time.Weekday(0)
|
|
if i.Weekday != nil {
|
|
targetWd = time.Weekday(*i.Weekday)
|
|
}
|
|
|
|
startOfWeek := getStartOfWeek(now)
|
|
|
|
var daysFromMonday int
|
|
if targetWd == time.Sunday {
|
|
daysFromMonday = 6
|
|
} else {
|
|
daysFromMonday = int(targetWd) - 1
|
|
}
|
|
|
|
targetThisWeek := startOfWeek.AddDate(0, 0, daysFromMonday)
|
|
|
|
if i.TimeOfDay != nil {
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err == nil {
|
|
targetThisWeek = time.Date(
|
|
targetThisWeek.Year(), targetThisWeek.Month(), targetThisWeek.Day(),
|
|
t.Hour(), t.Minute(), 0, 0, targetThisWeek.Location(),
|
|
)
|
|
}
|
|
}
|
|
|
|
if now.Before(targetThisWeek) {
|
|
return targetThisWeek
|
|
}
|
|
|
|
return targetThisWeek.AddDate(0, 0, 7)
|
|
}
|
|
|
|
func (i *Interval) nextMonthlyTrigger(now time.Time) time.Time {
|
|
day := 1
|
|
if i.DayOfMonth != nil {
|
|
day = *i.DayOfMonth
|
|
}
|
|
|
|
targetThisMonth := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location())
|
|
|
|
if i.TimeOfDay != nil {
|
|
t, err := time.Parse("15:04", *i.TimeOfDay)
|
|
if err == nil {
|
|
targetThisMonth = time.Date(
|
|
targetThisMonth.Year(), targetThisMonth.Month(), targetThisMonth.Day(),
|
|
t.Hour(), t.Minute(), 0, 0, targetThisMonth.Location(),
|
|
)
|
|
}
|
|
}
|
|
|
|
if now.Before(targetThisMonth) {
|
|
return targetThisMonth
|
|
}
|
|
|
|
return targetThisMonth.AddDate(0, 1, 0)
|
|
}
|
|
|
|
func (i *Interval) nextCronTrigger(lastBackup time.Time) *time.Time {
|
|
if i.CronExpression == nil || *i.CronExpression == "" {
|
|
return nil
|
|
}
|
|
|
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
schedule, err := parser.Parse(*i.CronExpression)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
next := schedule.Next(lastBackup)
|
|
|
|
return &next
|
|
}
|
|
|
|
func (i *Interval) validateCronExpression(expr string) error {
|
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
_, err := parser.Parse(expr)
|
|
if err != nil {
|
|
return errors.New("invalid cron expression: " + err.Error())
|
|
}
|
|
return nil
|
|
}
|