mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
376 lines
9.7 KiB
Go
376 lines
9.7 KiB
Go
package intervals
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/robfig/cron/v3"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Interval struct {
|
|
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
|
Interval IntervalType `json:"interval" gorm:"type:text;not null"`
|
|
|
|
TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"`
|
|
// only for WEEKLY
|
|
Weekday *int `json:"weekday,omitempty" gorm:"type:int"`
|
|
// only for MONTHLY
|
|
DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"`
|
|
// only for CRON
|
|
CronExpression *string `json:"cronExpression,omitempty" gorm:"type:text"`
|
|
}
|
|
|
|
func (i *Interval) BeforeSave(tx *gorm.DB) error {
|
|
return i.Validate()
|
|
}
|
|
|
|
func (i *Interval) Validate() error {
|
|
// for daily, weekly and monthly intervals time of day is required
|
|
if (i.Interval == IntervalDaily || i.Interval == IntervalWeekly || i.Interval == IntervalMonthly) &&
|
|
i.TimeOfDay == nil {
|
|
return errors.New("time of day is required for daily, weekly and monthly intervals")
|
|
}
|
|
|
|
// for weekly interval weekday is required
|
|
if i.Interval == IntervalWeekly && i.Weekday == nil {
|
|
return errors.New("weekday is required for weekly intervals")
|
|
}
|
|
|
|
// for monthly interval day of month is required
|
|
if i.Interval == IntervalMonthly && i.DayOfMonth == nil {
|
|
return errors.New("day of month is required for monthly intervals")
|
|
}
|
|
|
|
// for cron interval cron expression is required and must be valid
|
|
if i.Interval == 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.Interval {
|
|
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.Interval {
|
|
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
|
|
}
|
|
}
|
|
|
|
func (i *Interval) Copy() *Interval {
|
|
return &Interval{
|
|
ID: uuid.Nil,
|
|
Interval: i.Interval,
|
|
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
|
|
}
|