Cron engine: DOM/DOW OR, DST, and unsatisfiable detection
apps/backend/internal/office/shared/cron.go ↗The engine now matches crontab(5) OR semantics and enforces at-most-once DST firing for every IANA zone.
NextCronTime core loop
func NextCronTime(expression, timezone string, after time.Time) (time.Time, error) {
loc, err := resolveLocation(timezone)
if err != nil {
return time.Time{}, err
}
trimmed := strings.TrimSpace(expression)
if len(strings.Fields(trimmed)) != 5 {
return time.Time{}, fmt.Errorf("parse cron expression: %q: must be exactly 5 whitespace-separated fields", expression)
}
schedule, err := cronParser.Parse(trimmed)
if err != nil {
return time.Time{}, fmt.Errorf("parse cron expression: %w", err)
}
specSchedule, ok := schedule.(*cron.SpecSchedule)
if !ok {
return time.Time{}, fmt.Errorf("internal: unexpected schedule type %T", schedule)
}
start := after.In(loc)
candidate := schedule.Next(start)
if candidate.IsZero() {
return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
}
for isAmbiguousFallBack(candidate) || !matchesWallClock(specSchedule, candidate) {
candidate = schedule.Next(candidate)
if candidate.IsZero() {
return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
}
}
if earlier, ok := findEarlierMatchAcrossSubHourTransition(specSchedule, loc, start, candidate); ok {
candidate = earlier
}
return candidate.UTC(), nil
}
DOM/DOW OR logic
func dayMatches(spec *cron.SpecSchedule, t time.Time) bool {
const starBit = 1 << 63
domMatch := 1<<uint(t.Day())&spec.Dom > 0
dowMatch := 1<<uint(t.Weekday())&spec.Dow > 0
if spec.Dom&starBit > 0 || spec.Dow&starBit > 0 {
return domMatch && dowMatch
}
return domMatch || dowMatch
}
Fall-back suppression
func isAmbiguousFallBack(candidate time.Time) bool {
start, _ := candidate.ZoneBounds()
if start.IsZero() {
return false
}
_, currentOffset := candidate.Zone()
_, priorOffset := start.Add(-time.Second).Zone()
if priorOffset <= currentOffset {
return false
}
repeatedWindow := time.Duration(priorOffset-currentOffset) * time.Second
return candidate.Before(start.Add(repeatedWindow))
}
Trigger create validates expression and defaults timezone
apps/backend/internal/office/routines/service.go ↗Empty or impossible cron expressions are rejected at create time instead of becoming silent no-ops.
CreateRoutineTrigger
func (s *RoutineService) CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error {
if t.Timezone == "" {
t.Timezone = "UTC"
}
if t.Kind == "cron" {
if t.CronExpression == "" {
return fmt.Errorf("%w: cron trigger requires a cron_expression", ErrInvalidTrigger)
}
next, err := shared.NextCronTime(t.CronExpression, t.Timezone, time.Now().UTC())
if err != nil {
return fmt.Errorf("%w: invalid cron expression: %v", ErrInvalidTrigger, err)
}
t.NextRunAt = &next
}
return s.repo.CreateRoutineTrigger(ctx, t)
}
Sentinel error
var ErrInvalidTrigger = errors.New("invalid routine trigger")
Tick loop disarms unsatisfiable triggers, re-arms recoverable ones
apps/backend/internal/office/routines/service.go ↗An impossible expression no longer re-arms to now and loops forever; transient failures retry next tick.
processCronTrigger error branch
runCount, advanceTo, err := computeRoutineMissed(trigger, routine, now)
if err != nil {
if errors.Is(err, shared.ErrUnsatisfiableCron) {
s.logger.Error("cron expression unsatisfiable; trigger permanently disarmed",
zap.String("trigger_id", trigger.ID), zap.Error(err))
return err
}
if rearmErr := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, trigger.NextRunAt); rearmErr != nil {
s.logger.Warn("re-arm trigger after recoverable catch-up failure failed",
zap.String("trigger_id", trigger.ID), zap.Error(rearmErr))
}
s.logger.Warn("compute routine catch-up failed; will retry next tick",
zap.String("trigger_id", trigger.ID), zap.Error(err))
return err
}
Handler maps validation errors to 400
apps/backend/internal/office/routines/handler.go ↗Clients receive a 400 for bad cron input instead of a 500 that hides a user error.
createTrigger status mapping
func (h *Handler) createTrigger(c *gin.Context) {
var req CreateTriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
trigger := &RoutineTrigger{
RoutineID: c.Param("id"),
Kind: req.Kind,
CronExpression: req.CronExpression,
Timezone: req.Timezone,
PublicID: req.PublicID,
SigningMode: req.SigningMode,
Secret: req.Secret,
Enabled: true,
}
if err := h.svc.CreateRoutineTrigger(c.Request.Context(), trigger); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, ErrInvalidTrigger) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
trigger.Secret = ""
c.JSON(http.StatusCreated, TriggerResponse{Trigger: trigger})
}
Storage defaults timezone to UTC and backfills legacy rows
apps/backend/internal/office/repository/sqlite/base.go ↗New triggers store UTC explicitly and old empty timezones are migrated so reads never see an empty value.
Schema default
CREATE TABLE IF NOT EXISTS office_routine_triggers (
id TEXT PRIMARY KEY,
routine_id TEXT NOT NULL,
kind TEXT NOT NULL,
cron_expression TEXT DEFAULT '',
timezone TEXT DEFAULT 'UTC',
public_id TEXT DEFAULT '',
signing_mode TEXT DEFAULT '',
secret TEXT DEFAULT '',
next_run_at TIMESTAMP,
last_fired_at TIMESTAMP,
enabled INTEGER DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
);
Backfill migration
func (r *Repository) backfillRoutineTriggerTimezones() {
if _, err := r.db.Exec(
`UPDATE office_routine_triggers SET timezone = 'UTC' WHERE kind = 'cron' AND timezone = ''`,
); err != nil && r.log != nil {
r.log.Warn("routine trigger timezone backfill failed", zap.Error(err))
}
}