Skip to content

Commit 25645e8

Browse files
authored
Add daily between START and END fuzzy schedule syntax (#9479)
1 parent 4b97e5b commit 25645e8

4 files changed

Lines changed: 637 additions & 1 deletion

File tree

docs/src/content/docs/reference/triggers.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,23 @@ on:
110110
schedule: daily around 14:00 # Scatters within ±1 hour (13:00-15:00)
111111
```
112112

113+
For workflows that should only run during specific hours (like business hours), use the `between` constraint:
114+
115+
```yaml wrap
116+
on:
117+
schedule: daily between 9:00 and 17:00 # Scatters within 9am-5pm range
118+
```
119+
120+
```yaml wrap
121+
on:
122+
schedule: daily between 9am and 5pm utc-5 # Business hours in EST timezone
123+
```
124+
113125
The compiler deterministically assigns each workflow a unique execution time based on the workflow file path. This ensures:
114126
- **Load distribution**: Workflows run at different times, reducing server load spikes
115127
- **Consistency**: The same workflow always gets the same execution time across recompiles
116128
- **Simplicity**: No need to manually coordinate schedules across multiple workflows
117-
- **Flexibility with constraints**: Use `around` to hint preferred times while still distributing load
129+
- **Flexibility with constraints**: Use `around` to hint preferred times or `between` to restrict to time ranges
118130

119131
**Human-Friendly Format:**
120132

@@ -141,6 +153,8 @@ Using explicit times like `0 0 * * *` or `daily at midnight` causes all workflow
141153
| **Hourly (Fuzzy)** | `hourly` | `58 */1 * * *` | Compiler assigns scattered minute |
142154
| **Daily (Fuzzy)** | `daily` | `43 5 * * *` | Compiler assigns scattered time |
143155
| | `daily around 14:00` | `20 14 * * *` | Scattered within ±1 hour (13:00-15:00) |
156+
| | `daily between 9:00 and 17:00` | `37 13 * * *` | Scattered within range (9:00-17:00) |
157+
| | `daily between 9am and 5pm utc-5` | `12 18 * * *` | With UTC offset (9am-5pm EST → 2pm-10pm UTC) |
144158
| | `daily around 3pm utc-5` | `33 19 * * *` | With UTC offset (3 PM EST → 8 PM UTC) |
145159
| **Daily (Fixed)** | `daily at 02:00` | `0 2 * * *` | ⚠️ Creates load spikes |
146160
| **Weekly (Fuzzy)** | `weekly` or `weekly on monday` | `43 5 * * 1` | Compiler assigns scattered time |

pkg/parser/schedule_parser.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,73 @@ func ScatterSchedule(fuzzyCron, workflowIdentifier string) (string, error) {
240240
return fmt.Sprintf("%d %d * * *", minute, hour), nil
241241
}
242242

243+
// For FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M * * *, scatter within the time range
244+
if strings.HasPrefix(fuzzyCron, "FUZZY:DAILY_BETWEEN:") {
245+
// Extract the start and end times from FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
246+
parts := strings.Split(fuzzyCron, " ")
247+
if len(parts) < 1 {
248+
return "", fmt.Errorf("invalid fuzzy daily between pattern: %s", fuzzyCron)
249+
}
250+
251+
// Parse the times from FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
252+
timePart := strings.TrimPrefix(parts[0], "FUZZY:DAILY_BETWEEN:")
253+
timeParts := strings.Split(timePart, ":")
254+
if len(timeParts) != 4 {
255+
return "", fmt.Errorf("invalid time format in fuzzy daily between pattern: %s", fuzzyCron)
256+
}
257+
258+
startHour, err := strconv.Atoi(timeParts[0])
259+
if err != nil || startHour < 0 || startHour > 23 {
260+
return "", fmt.Errorf("invalid start hour in fuzzy daily between pattern: %s", fuzzyCron)
261+
}
262+
263+
startMinute, err := strconv.Atoi(timeParts[1])
264+
if err != nil || startMinute < 0 || startMinute > 59 {
265+
return "", fmt.Errorf("invalid start minute in fuzzy daily between pattern: %s", fuzzyCron)
266+
}
267+
268+
endHour, err := strconv.Atoi(timeParts[2])
269+
if err != nil || endHour < 0 || endHour > 23 {
270+
return "", fmt.Errorf("invalid end hour in fuzzy daily between pattern: %s", fuzzyCron)
271+
}
272+
273+
endMinute, err := strconv.Atoi(timeParts[3])
274+
if err != nil || endMinute < 0 || endMinute > 59 {
275+
return "", fmt.Errorf("invalid end minute in fuzzy daily between pattern: %s", fuzzyCron)
276+
}
277+
278+
// Calculate start and end times in minutes since midnight
279+
startMinutes := startHour*60 + startMinute
280+
endMinutes := endHour*60 + endMinute
281+
282+
// Calculate the range size, handling ranges that cross midnight
283+
var rangeSize int
284+
if endMinutes > startMinutes {
285+
// Normal case: range within a single day (e.g., 9:00 to 17:00)
286+
rangeSize = endMinutes - startMinutes
287+
} else {
288+
// Range crosses midnight (e.g., 22:00 to 02:00)
289+
rangeSize = (24*60 - startMinutes) + endMinutes
290+
}
291+
292+
// Use a stable hash to get a deterministic offset within the range
293+
hash := stableHash(workflowIdentifier, rangeSize)
294+
295+
// Calculate the scattered time by adding hash offset to start time
296+
scatteredMinutes := startMinutes + hash
297+
298+
// Handle wrap-around for ranges that cross midnight
299+
if scatteredMinutes >= 24*60 {
300+
scatteredMinutes -= 24 * 60
301+
}
302+
303+
hour := scatteredMinutes / 60
304+
minute := scatteredMinutes % 60
305+
306+
// Return scattered daily cron: minute hour * * *
307+
return fmt.Sprintf("%d %d * * *", minute, hour), nil
308+
}
309+
243310
// For FUZZY:DAILY * * *, we scatter across 24 hours
244311
if strings.HasPrefix(fuzzyCron, "FUZZY:DAILY") {
245312
// Use a stable hash of the workflow identifier to get a deterministic time
@@ -595,11 +662,59 @@ func (p *ScheduleParser) parseBase() (string, error) {
595662
// daily -> FUZZY:DAILY (fuzzy schedule, time will be scattered)
596663
// daily at HH:MM -> MM HH * * *
597664
// daily around HH:MM -> FUZZY:DAILY_AROUND:HH:MM (fuzzy schedule with target time)
665+
// daily between HH:MM and HH:MM -> FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M (fuzzy schedule within time range)
598666
if len(p.tokens) == 1 {
599667
// Just "daily" with no time - this is a fuzzy schedule
600668
return "FUZZY:DAILY * * *", nil
601669
}
602670
if len(p.tokens) > 1 {
671+
// Check if "between" keyword is used
672+
if p.tokens[1] == "between" {
673+
// Parse: "daily between START and END"
674+
// We need at least: daily between TIME and TIME (5 tokens minimum)
675+
if len(p.tokens) < 5 {
676+
return "", fmt.Errorf("invalid 'between' format, expected 'daily between START and END'")
677+
}
678+
679+
// Find the "and" keyword to split start and end times
680+
andIndex := -1
681+
for i := 2; i < len(p.tokens); i++ {
682+
if p.tokens[i] == "and" {
683+
andIndex = i
684+
break
685+
}
686+
}
687+
if andIndex == -1 {
688+
return "", fmt.Errorf("missing 'and' keyword in 'between' clause")
689+
}
690+
691+
// Extract start time (tokens between "between" and "and")
692+
startTimeStr, err := p.extractTimeBetween(2, andIndex)
693+
if err != nil {
694+
return "", fmt.Errorf("invalid start time in 'between' clause: %w", err)
695+
}
696+
startMinute, startHour := parseTime(startTimeStr)
697+
698+
// Extract end time (tokens after "and")
699+
endTimeStr, err := p.extractTimeAfter(andIndex + 1)
700+
if err != nil {
701+
return "", fmt.Errorf("invalid end time in 'between' clause: %w", err)
702+
}
703+
endMinute, endHour := parseTime(endTimeStr)
704+
705+
// Validate that start is before end (in minutes since midnight)
706+
startMinutes := parseTimeToMinutes(startHour, startMinute)
707+
endMinutes := parseTimeToMinutes(endHour, endMinute)
708+
709+
// Allow ranges that cross midnight (e.g., 22:00 to 02:00)
710+
// We'll handle this in the scattering logic
711+
if startMinutes == endMinutes {
712+
return "", fmt.Errorf("start and end times cannot be the same in 'between' clause")
713+
}
714+
715+
// Return fuzzy between format: FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
716+
return fmt.Sprintf("FUZZY:DAILY_BETWEEN:%s:%s:%s:%s * * *", startHour, startMinute, endHour, endMinute), nil
717+
}
603718
// Check if "around" keyword is used
604719
if p.tokens[1] == "around" {
605720
// Extract time after "around"
@@ -730,6 +845,61 @@ func (p *ScheduleParser) extractTime(startPos int) (string, error) {
730845
return timeStr, nil
731846
}
732847

848+
// extractTimeBetween extracts a time specification from tokens between startPos and endPos (exclusive)
849+
// Used for parsing the start time in "between START and END" clauses
850+
func (p *ScheduleParser) extractTimeBetween(startPos, endPos int) (string, error) {
851+
if startPos >= len(p.tokens) || startPos >= endPos {
852+
return "", fmt.Errorf("expected time specification")
853+
}
854+
855+
// The time is in the tokens between startPos and endPos
856+
// It might be a single token (e.g., "9am") or multiple tokens (e.g., "14:00 utc+9")
857+
timeTokens := []string{}
858+
for i := startPos; i < endPos && i < len(p.tokens); i++ {
859+
timeTokens = append(timeTokens, p.tokens[i])
860+
}
861+
862+
if len(timeTokens) == 0 {
863+
return "", fmt.Errorf("expected time specification")
864+
}
865+
866+
// Check if there's a UTC offset
867+
if len(timeTokens) >= 2 && strings.HasPrefix(strings.ToLower(timeTokens[1]), "utc") {
868+
return timeTokens[0] + " " + timeTokens[1], nil
869+
}
870+
871+
return timeTokens[0], nil
872+
}
873+
874+
// extractTimeAfter extracts a time specification from tokens starting at startPos until the end
875+
// Used for parsing the end time in "between START and END" clauses
876+
func (p *ScheduleParser) extractTimeAfter(startPos int) (string, error) {
877+
if startPos >= len(p.tokens) {
878+
return "", fmt.Errorf("expected time specification")
879+
}
880+
881+
// Collect remaining tokens (time and optional UTC offset)
882+
timeStr := p.tokens[startPos]
883+
884+
// Check if there's a UTC offset in the next token
885+
if startPos+1 < len(p.tokens) {
886+
nextToken := strings.ToLower(p.tokens[startPos+1])
887+
if strings.HasPrefix(nextToken, "utc") {
888+
// Combine time and UTC offset
889+
timeStr = timeStr + " " + p.tokens[startPos+1]
890+
}
891+
}
892+
893+
return timeStr, nil
894+
}
895+
896+
// parseTimeToMinutes converts hour and minute strings to total minutes since midnight
897+
func parseTimeToMinutes(hourStr, minuteStr string) int {
898+
hour, _ := strconv.Atoi(hourStr)
899+
minute, _ := strconv.Atoi(minuteStr)
900+
return hour*60 + minute
901+
}
902+
733903
// parseTime converts a time string to minute and hour, with optional UTC offset
734904
// Supports formats: HH:MM, midnight, noon, 3pm, 1am, HH:MM utc+N, HH:MM utc+HH:MM, HH:MM utc-N, 3pm utc+9
735905
func parseTime(timeStr string) (minute string, hour string) {

pkg/parser/schedule_parser_fuzz_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ func FuzzScheduleParser(f *testing.F) {
4747
f.Add("daily around 2pm utc+1")
4848
f.Add("daily around 11pm utc-3")
4949

50+
// Daily between schedules (fuzzy with time range)
51+
f.Add("daily between 9:00 and 17:00")
52+
f.Add("daily between 9am and 5pm")
53+
f.Add("daily between midnight and noon")
54+
f.Add("daily between noon and midnight")
55+
f.Add("daily between 22:00 and 02:00")
56+
f.Add("daily between 10pm and 2am")
57+
f.Add("daily between 8:30 and 18:45")
58+
f.Add("daily between 6am and 6pm")
59+
f.Add("daily between 1am and 11pm")
60+
f.Add("daily between 00:00 and 23:59")
61+
f.Add("daily between 12am and 11:59pm")
62+
f.Add("daily between 23:00 and 01:00")
63+
f.Add("daily between 11pm and 1am")
64+
f.Add("daily between 7:15 and 16:45")
65+
f.Add("daily between 3:30am and 8:30pm")
66+
f.Add("daily between 9am utc-5 and 5pm utc-5")
67+
f.Add("daily between 8:00 utc+9 and 17:00 utc+9")
68+
f.Add("daily between 10:00 utc+0 and 14:00 utc+0")
69+
f.Add("daily between 9am utc-8 and 5pm utc-8")
70+
f.Add("daily between 8:00 utc+05:30 and 18:00 utc+05:30")
71+
5072
// Weekly schedules
5173
f.Add("weekly on monday")
5274
f.Add("weekly on monday at 06:30")
@@ -189,6 +211,27 @@ func FuzzScheduleParser(f *testing.F) {
189211
f.Add("every 10 minutes around 12:00")
190212
f.Add("hourly around 12:00")
191213

214+
// Invalid between schedules
215+
f.Add("daily between")
216+
f.Add("daily between 9:00")
217+
f.Add("daily between and")
218+
f.Add("daily between 9:00 and")
219+
f.Add("daily between 9:00 17:00")
220+
f.Add("daily between 9:00 and 9:00")
221+
f.Add("daily between midnight and midnight")
222+
f.Add("daily between noon and noon")
223+
f.Add("daily between 3pm and 15:00")
224+
f.Add("daily between 25:00 and 17:00")
225+
f.Add("daily between 9:00 and 25:00")
226+
f.Add("daily between abc and def")
227+
f.Add("daily between 9am and")
228+
f.Add("daily between and 5pm")
229+
f.Add("daily between and and")
230+
f.Add("weekly between monday and friday")
231+
f.Add("monthly between 1 and 15")
232+
f.Add("every 10 minutes between 9:00 and 17:00")
233+
f.Add("hourly between 9:00 and 17:00")
234+
192235
// Invalid UTC offsets
193236
f.Add("daily at 12:00 utc+25")
194237
f.Add("daily at 12:00 utc-15")
@@ -311,6 +354,7 @@ func FuzzScheduleParser(f *testing.F) {
311354
// - "FUZZY:DAILY * * *" (4 fields)
312355
// - "FUZZY:HOURLY/N * * *" (4 fields)
313356
// - "FUZZY:DAILY_AROUND:HH:MM * * *" (4 fields but with colon-separated time in first field)
357+
// - "FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M * * *" (4 fields with 4 colon-separated values in first field)
314358
fields := strings.Fields(cron)
315359
if len(fields) != 4 {
316360
t.Errorf("ParseSchedule returned invalid fuzzy cron format with %d fields (expected 4): %q for input: %q", len(fields), cron, input)
@@ -326,6 +370,17 @@ func FuzzScheduleParser(f *testing.F) {
326370
t.Errorf("ParseSchedule returned invalid FUZZY:DAILY_AROUND format (expected HH:MM): %q for input: %q", cron, input)
327371
}
328372
}
373+
374+
// For FUZZY:DAILY_BETWEEN, validate the time range format
375+
if strings.HasPrefix(cron, "FUZZY:DAILY_BETWEEN:") {
376+
// Extract the time range from FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
377+
firstField := fields[0]
378+
timePart := strings.TrimPrefix(firstField, "FUZZY:DAILY_BETWEEN:")
379+
timeParts := strings.Split(timePart, ":")
380+
if len(timeParts) != 4 {
381+
t.Errorf("ParseSchedule returned invalid FUZZY:DAILY_BETWEEN format (expected START_H:START_M:END_H:END_M): %q for input: %q", cron, input)
382+
}
383+
}
329384
} else {
330385
// Regular cron should have 5 fields separated by spaces
331386
fields := strings.Fields(cron)

0 commit comments

Comments
 (0)