Matrix logo

Schedule Engine

Package chronos/internal/schedule computes alarm fire times for the two kinds: once (a relative delay or an absolute instant) and cron (a standard 5-field expression, @descripto...

Package chronos/internal/schedule computes alarm fire times for the two kinds: once (a relative delay or an absolute instant) and cron (a standard 5-field expression, @descriptor, or @every Nm, evaluated in the alarm's IANA timezone).

Source files: internal/schedule/schedule.go, internal/schedule/schedule_test.go.


The two kinds

The three user cases collapse onto two kinds:

User saysKindMechanism
"in 10 minutes"oncedelay_seconds relative to now
"at 3pm tomorrow"oncefire_at absolute RFC3339 timestamp
"every day at 9am"croncron_expr parsed by robfig/cron/v3

Once alarms

NextOnce(delaySeconds int64, fireAt string, now time.Time) (time.Time, error)

Resolves the single fire instant from exactly one of:

  • delay_seconds — relative to now. The resolved time is now + delay_seconds, in UTC.
  • fire_at — an absolute RFC3339 timestamp. Must be strictly in the future.

Validation rules:

ConditionError
Both delay_seconds and fire_at set"once alarm takes either delay_seconds or fire_at, not both"
Neither set"once alarm requires delay_seconds or fire_at"
fire_at is not valid RFC3339"invalid fire_at … (want RFC3339)"
fire_at is in the past"fire_at … is not in the future"
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)

// Relative: 10 minutes from now
t, _ := schedule.NextOnce(600, "", now)
// t = 2026-01-01T12:10:00Z

// Absolute: specific time
t, _ := schedule.NextOnce(0, "2026-01-01T18:30:00Z", now)
// t = 2026-01-01T18:30:00Z

Cron alarms

NextCron(expr, tz string, after time.Time) (time.Time, error)

Parses the expression with robfig/cron/v3, evaluates the next fire time strictly after after in the given IANA timezone, and returns it in UTC.

Supported expression syntax

The parser accepts the standard 5-field syntax plus @descriptors:

cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor
ExpressionMeaning
0 9 * * *Every day at 09:00
*/15 * * * *Every 15 minutes
0 9 * * 1-5Weekdays at 09:00
@hourlyAt the start of every hour
@dailyAt midnight every day
@every 10mEvery 10 minutes

Timezone handling

Cron expressions evaluate in the alarm's IANA timezone. Once alarms are absolute instants (timezone-independent).

now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

// 09:00 New York on Jan 1 = 14:00 UTC (EST, UTC-5)
t, _ := schedule.NextCron("0 9 * * *", "America/New_York", now)
// t = 2026-01-01T14:00:00Z

LoadLocation(tz string) resolves the IANA timezone, defaulting to time.UTC when empty. Invalid timezone names return an error.

Edge cases

  • Empty expression → error: "empty cron expression"
  • Invalid expression → error: "invalid cron expression …"
  • No future time (e.g. a cron that only matches dates in the past) → error: "cron … yields no future time". The dispatch worker treats this as terminal and retires the alarm.

Validation at create time

Both NextOnce and NextCron are called at alarm creation time in server.handleCreateAlarm. The computed next_fire_at is stored in the row. The dispatch worker never re-parses expressions — it only compares next_fire_at <= now().


Rescheduling after fire

For cron alarms, after a successful fire the dispatch worker calls NextCron again with now to compute the next occurrence:

next, err := schedule.NextCron(a.CronExpr, a.Timezone, now)
store.Reschedule(ctx, a.ID, next)

This means the cron schedule is re-evaluated fresh after every fire. If the expression becomes invalid (e.g. a timezone is removed from the IANA database), the alarm is retired rather than wedged.


Dependencies

  • github.com/robfig/cron/v3 — the standard Go cron library. Pinned at v3.0.1 in go.mod.
  • No other external dependencies. Timezone resolution uses the Go standard library's time.LoadLocation.