Skip to content

Commit 7853727

Browse files
fix: improve schedule validation
1 parent d61894d commit 7853727

File tree

4 files changed

+831
-0
lines changed

4 files changed

+831
-0
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package helpers
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
"golang.org/x/xerrors"
8+
)
9+
10+
// ValidateSchedules checks if any schedules overlap
11+
func ValidateSchedules(schedules []string) error {
12+
for i := 0; i < len(schedules); i++ {
13+
for j := i + 1; j < len(schedules); j++ {
14+
overlap, err := SchedulesOverlap(schedules[i], schedules[j])
15+
if err != nil {
16+
return xerrors.Errorf("invalid schedule: %w", err)
17+
}
18+
if overlap {
19+
return xerrors.Errorf("schedules overlap: %s and %s",
20+
schedules[i], schedules[j])
21+
}
22+
}
23+
}
24+
return nil
25+
}
26+
27+
// SchedulesOverlap checks if two schedules overlap by checking
28+
// days, months, and hours separately
29+
func SchedulesOverlap(schedule1, schedule2 string) (bool, error) {
30+
// Get cron fields
31+
fields1 := strings.Fields(schedule1)
32+
fields2 := strings.Fields(schedule2)
33+
34+
if len(fields1) != 5 {
35+
return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule1, len(fields1))
36+
}
37+
if len(fields2) != 5 {
38+
return false, xerrors.Errorf("schedule %q has %d fields, expected 5 fields (minute hour day-of-month month day-of-week)", schedule2, len(fields2))
39+
}
40+
41+
// Check if months overlap
42+
monthsOverlap, err := MonthsOverlap(fields1[3], fields2[3])
43+
if err != nil {
44+
return false, xerrors.Errorf("invalid month range: %w", err)
45+
}
46+
if !monthsOverlap {
47+
return false, nil
48+
}
49+
50+
// Check if days overlap (DOM OR DOW)
51+
daysOverlap, err := DaysOverlap(fields1[2], fields1[4], fields2[2], fields2[4])
52+
if err != nil {
53+
return false, xerrors.Errorf("invalid day range: %w", err)
54+
}
55+
if !daysOverlap {
56+
return false, nil
57+
}
58+
59+
// Check if hours overlap
60+
hoursOverlap, err := HoursOverlap(fields1[1], fields2[1])
61+
if err != nil {
62+
return false, xerrors.Errorf("invalid hour range: %w", err)
63+
}
64+
65+
return hoursOverlap, nil
66+
}
67+
68+
// MonthsOverlap checks if two month ranges overlap
69+
func MonthsOverlap(months1, months2 string) (bool, error) {
70+
return CheckOverlap(months1, months2, 12)
71+
}
72+
73+
// HoursOverlap checks if two hour ranges overlap
74+
func HoursOverlap(hours1, hours2 string) (bool, error) {
75+
return CheckOverlap(hours1, hours2, 23)
76+
}
77+
78+
// DomOverlap checks if two day-of-month ranges overlap
79+
func DomOverlap(dom1, dom2 string) (bool, error) {
80+
return CheckOverlap(dom1, dom2, 31)
81+
}
82+
83+
// DowOverlap checks if two day-of-week ranges overlap
84+
func DowOverlap(dow1, dow2 string) (bool, error) {
85+
return CheckOverlap(dow1, dow2, 6)
86+
}
87+
88+
// DaysOverlap checks if two day ranges overlap, considering both DOM and DOW.
89+
// Returns true if both DOM and DOW overlap, or if one is * and the other overlaps.
90+
func DaysOverlap(dom1, dow1, dom2, dow2 string) (bool, error) {
91+
// If either DOM is *, we only need to check DOW overlap
92+
if dom1 == "*" || dom2 == "*" {
93+
return DowOverlap(dow1, dow2)
94+
}
95+
96+
// If either DOW is *, we only need to check DOM overlap
97+
if dow1 == "*" || dow2 == "*" {
98+
return DomOverlap(dom1, dom2)
99+
}
100+
101+
// If both DOM and DOW are specified, we need to check both
102+
// because the schedule runs when either matches
103+
domOverlap, err := DomOverlap(dom1, dom2)
104+
if err != nil {
105+
return false, err
106+
}
107+
dowOverlap, err := DowOverlap(dow1, dow2)
108+
if err != nil {
109+
return false, err
110+
}
111+
112+
// If either DOM or DOW overlaps, the schedules overlap
113+
return domOverlap || dowOverlap, nil
114+
}
115+
116+
// CheckOverlap is a generic function to check if two ranges overlap
117+
func CheckOverlap(range1, range2 string, maxValue int) (bool, error) {
118+
set1, err := ParseRange(range1, maxValue)
119+
if err != nil {
120+
return false, err
121+
}
122+
set2, err := ParseRange(range2, maxValue)
123+
if err != nil {
124+
return false, err
125+
}
126+
127+
for value := range set1 {
128+
if set2[value] {
129+
return true, nil
130+
}
131+
}
132+
return false, nil
133+
}
134+
135+
// ParseRange converts a cron range to a set of integers
136+
// maxValue is the maximum allowed value (e.g., 23 for hours, 6 for DOW, 12 for months, 31 for DOM)
137+
func ParseRange(input string, maxValue int) (map[int]bool, error) {
138+
result := make(map[int]bool)
139+
140+
// Handle "*" case
141+
if input == "*" {
142+
for i := 0; i <= maxValue; i++ {
143+
result[i] = true
144+
}
145+
return result, nil
146+
}
147+
148+
// Parse ranges like "1-3,5,7-9"
149+
parts := strings.Split(input, ",")
150+
for _, part := range parts {
151+
if strings.Contains(part, "-") {
152+
// Handle range like "1-3"
153+
rangeParts := strings.Split(part, "-")
154+
start, err := strconv.Atoi(rangeParts[0])
155+
if err != nil {
156+
return nil, xerrors.Errorf("invalid start value in range: %w", err)
157+
}
158+
end, err := strconv.Atoi(rangeParts[1])
159+
if err != nil {
160+
return nil, xerrors.Errorf("invalid end value in range: %w", err)
161+
}
162+
163+
// Validate range
164+
if start < 0 || end > maxValue || start > end {
165+
return nil, xerrors.Errorf("invalid range %d-%d: values must be between 0 and %d", start, end, maxValue)
166+
}
167+
168+
for i := start; i <= end; i++ {
169+
result[i] = true
170+
}
171+
} else {
172+
// Handle single value
173+
value, err := strconv.Atoi(part)
174+
if err != nil {
175+
return nil, xerrors.Errorf("invalid value: %w", err)
176+
}
177+
178+
// Validate value
179+
if value < 0 || value > maxValue {
180+
return nil, xerrors.Errorf("invalid value %d: must be between 0 and %d", value, maxValue)
181+
}
182+
183+
result[value] = true
184+
}
185+
}
186+
return result, nil
187+
}

0 commit comments

Comments
 (0)