Files
navidrome/scheduler/crontab_schedule_test.go
T
Deluan Quintão 5cd1fcb492 feat(scheduler): add crontab(5) random ~ syntax support (#5233)
* feat(scheduler): add CrontabSchedule with crontab(5) random ~ syntax

Implement ParseCrontab() that extends robfig/cron with support for
the crontab(5) random ~ operator (e.g., 0~30 * * * *). Random values
are resolved fresh on each Next() call for load spreading.

Supports A~B, ~B, A~, and bare ~ forms in all 6 fields (including
seconds). Expressions without ~ delegate to robfig's standard parser
with zero overhead.

Integrates into scheduler.Add() and conf.validateSchedule() so that
scanner.schedule and backup.schedule config values accept ~ syntax.

* refactor(scheduler): resolve random ~ values once at parse time

Change from per-Next() randomization to per-parse randomization,
matching crontab(5) semantics. This prevents double-firing within
the same period when random values land after the current time.

ParseCrontab now resolves ~ fields to concrete values, substitutes
them into the spec string, and delegates to robfig's parser. This
eliminates CrontabSchedule, randomField, and resolveField entirely.

* test(scheduler): replace WaitGroup with channel for job execution synchronization

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-20 08:57:13 -04:00

195 lines
5.8 KiB
Go

package scheduler
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/robfig/cron/v3"
)
var _ = Describe("ParseCrontab", func() {
Describe("standard expressions", func() {
It("parses a 5-field expression", func() {
sched, err := ParseCrontab("5 * * * *")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
})
It("parses a 6-field expression with seconds", func() {
sched, err := ParseCrontab("30 5 * * * *")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
})
It("converts duration string to @every", func() {
sched, err := ParseCrontab("5m")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(cron.ConstantDelaySchedule{}))
})
It("returns error for empty string", func() {
_, err := ParseCrontab("")
Expect(err).To(HaveOccurred())
})
})
Describe("random ~ syntax", func() {
It("resolves A~B to a value within range", func() {
sched, err := ParseCrontab("0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 30))
})
It("resolves ~ alone to full field range", func() {
sched, err := ParseCrontab("~ * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 59))
})
It("resolves ~B as min~B", func() {
sched, err := ParseCrontab("~15 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 15))
})
It("resolves A~ as A~max", func() {
sched, err := ParseCrontab("15~ * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 15))
Expect(minute).To(BeNumerically("<=", 59))
})
It("resolves multiple random fields independently", func() {
sched, err := ParseCrontab("0~30 0~12 * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
Expect(findSetBit(spec.Minute)).To(BeNumerically("<=", 30))
Expect(findSetBit(spec.Hour)).To(BeNumerically("<=", 12))
})
It("resolves ~ in DOM field with correct bounds", func() {
sched, err := ParseCrontab("0 0 ~ * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
dom := findSetBit(spec.Dom)
Expect(dom).To(BeNumerically(">=", 1))
Expect(dom).To(BeNumerically("<=", 31))
})
It("resolves ~ in month field with correct bounds", func() {
sched, err := ParseCrontab("0 0 1 ~ *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
month := findSetBit(spec.Month)
Expect(month).To(BeNumerically(">=", 1))
Expect(month).To(BeNumerically("<=", 12))
})
It("resolves ~ in DOW field with correct bounds", func() {
sched, err := ParseCrontab("0 0 * * ~")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
dow := findSetBit(spec.Dow)
Expect(dow).To(BeNumerically(">=", 0))
Expect(dow).To(BeNumerically("<=", 6))
})
It("preserves TZ= prefix through resolution", func() {
sched, err := ParseCrontab("TZ=America/New_York 0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
nyc, _ := time.LoadLocation("America/New_York")
Expect(spec.Location).To(Equal(nyc))
})
It("preserves non-random fields", func() {
sched, err := ParseCrontab("0~30 10 * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
Expect(spec.Hour & (1 << 10)).ToNot(BeZero())
})
It("resolves to a stable value across repeated Next calls", func() {
sched, err := ParseCrontab("0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
ref := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
first := sched.Next(ref)
for range 50 {
Expect(sched.Next(ref)).To(Equal(first))
}
})
})
Describe("error cases", func() {
It("rejects min > max", func() {
_, err := ParseCrontab("30~0 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("beyond end"))
})
It("rejects value above field maximum", func() {
_, err := ParseCrontab("0~60 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("above maximum"))
})
It("rejects value below field minimum", func() {
_, err := ParseCrontab("0 0 0~15 * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("below minimum"))
})
It("rejects ~ mixed with comma (list)", func() {
_, err := ParseCrontab("0~30,45 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
})
It("rejects ~ mixed with slash (step)", func() {
_, err := ParseCrontab("0~30/5 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
})
It("rejects @ descriptor with ~", func() {
_, err := ParseCrontab("@every 0~30m")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("descriptor"))
})
It("rejects wrong number of fields", func() {
_, err := ParseCrontab("0~30 * *")
Expect(err).To(HaveOccurred())
})
It("rejects non-numeric range values", func() {
_, err := ParseCrontab("a~b * * * *")
Expect(err).To(HaveOccurred())
})
})
})
// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63).
func findSetBit(v uint64) int {
v &^= 1 << 63 // clear starBit
for i := 0; i < 63; i++ {
if v&(1<<uint(i)) != 0 {
return i
}
}
return -1
}