Schedule Engine
The schedule package computes alarm fire times for the two kinds: once (relative delay or absolute instant) and cron (5-field expression evaluated in the alarm's IANA timezone).
The schedule package computes alarm fire times for the two alarm kinds. It is a pure computation layer with no database or network dependencies.
Source file: internal/schedule/schedule.go.
Overview
The schedule engine handles two distinct time-resolution tasks:
- Once alarms: resolve a single fire instant from either a relative delay (
delay_seconds) or an absolute RFC3339 timestamp (fire_at). - Cron alarms: compute the next fire time strictly after a given instant from a standard cron expression, evaluated in the alarm's IANA timezone.
The engine uses robfig/cron/v3 for cron parsing and evaluation.
Cron parser
The parser accepts the standard 5-field syntax plus @ descriptors and @every:
var cronParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
Supported cron surfaces:
| Surface | Example | Meaning |
|---|---|---|
| 5-field | 0 9 * * 1-5 | At 09:00 Monday through Friday |
@yearly / @annually | @yearly | Once a year, midnight Jan 1 |
@monthly | @monthly | Once a month, midnight 1st |
@weekly | @weekly | Once a week, midnight Sunday |
@daily / @midnight | @daily | Once a day, midnight |
@hourly | @hourly | Once an hour, at :00 |
@every Nm | @every 45m | Every N minutes/hours/etc. |
NextCron
NextCron returns the next fire time strictly after after, evaluated in the alarm's timezone:
func NextCron(expr, tz string, after time.Time) (time.Time, error)
The timezone defaults to UTC when empty. The function:
- Parses the cron expression via the
cronParser. - Loads the IANA timezone via
LoadLocation(defaults to UTC when empty). - Converts
afterto the alarm's timezone. - Calls
sched.Next(after.In(loc))to get the next fire time. - Returns the result in UTC.
If the cron expression yields no future time, an error is returned.
NextOnce
NextOnce resolves the single fire instant for a once alarm from exactly one of delaySeconds (relative to now) or fireAt (RFC3339 absolute):
func NextOnce(delaySeconds int64, fireAt string, now time.Time) (time.Time, error)
Rules:
| Input | Behavior |
|---|---|
Both delaySeconds > 0 and fireAt != "" | Error: "once alarm takes either delay_seconds or fire_at, not both" |
Only delaySeconds > 0 | Returns now + delaySeconds in UTC |
Only fireAt != "" | Parses as RFC3339; errors if not in the future; returns in UTC |
| Neither | Error: "once alarm requires delay_seconds or fire_at" |
The resolved time must be in the future relative to now.
Timezone handling
LoadLocation resolves an IANA timezone string, defaulting to UTC when empty:
func LoadLocation(tz string) (*time.Location, error)
For once alarms, the timezone is always UTC (the server sets alarm.Timezone = "UTC" explicitly). For cron alarms, the timezone comes from the CreateAlarmRequest.Timezone field and defaults to UTC when omitted.
All fire times are stored and returned in UTC. The timezone is only used during cron evaluation to ensure expressions like "every day at 9am" fire at 9am in the user's local time.
Cron alarm rescheduling
After a successful cron fire, the dispatch worker calls NextCron to compute the next fire time and stores it via store.Reschedule. The next_fire_at column is the dispatch worker's claim key: the next tick finds it, claims it, and fires again.
When NextCron yields no future time (the cron has expired), the alarm is retired by marking it fired.
When a cron fire fails and retries are exhausted, the dispatch worker calls store.RescheduleAfterFailure to advance next_fire_at to the next occurrence, so one bad fire does not wedge the entire series.
