Skip to content

Schedules ​

How to create a durable schedule. Each fire follows a cron expression.

Prerequisites ​

The prerequisite is a started cyclone.Client. The schedule scanner starts inside Client.Start. You do not need a separate process.

Schedule a Step ​

go
schedID, err := cyclone.Schedule(ctx, client, "0 * * * *", ReportStep, ReportInput{Period: "hour"},
    cyclone.WithScheduleID("hourly-report"),
)
if err != nil {
    return fmt.Errorf("schedule: %w", err)
}
fmt.Println("schedule created:", schedID)
  • "0 * * * *" means one fire at minute 0 of every hour.
  • ReportStep is any *Step[I, O].
  • ReportInput{...} is the input for every run that a fire starts.
  • WithScheduleID("hourly-report") is a stable ID. Registration with the same ID is idempotent.

Schedule an Action ​

go
schedID, err := cyclone.ScheduleAction(ctx, client, "@daily", CleanupAction, CleanupInput{},
    cyclone.WithScheduleID("daily-cleanup"),
)

Schedule a Workflow ​

go
schedID, err := cyclone.ScheduleWorkflow(ctx, client, "@hourly", ReportWorkflow, ReportInput{Period: "hour"},
    cyclone.WithScheduleID("hourly-report-workflow"),
)

ScheduleWorkflow submits the full workflow DAG on each fire. Register the workflow on every worker at startup. Register it in the same way as a step handler or an action handler. When a worker starts without that workflow, the scanner records a schedule failure. The scanner does not create a broken one-node run.

Definition pinning and mixed-version deployments ​

Registration pins the workflow definition fingerprint (def_version) on the schedule row (migration 012). The process that claims a fire builds the DAG from its own registered builder. In a deployment with two versions, an old process and a new process can build different graphs. Those graphs would belong to adjacent fires. The pin stops that difference.

A process refuses the fire when its builder fingerprint differs from the pin. That process creates no run. Cyclone records the refusal in the last_error of the schedule. The slot stays claimable by a process that has the pinned definition. TriggerSchedule on a mismatched process fails with ErrScheduleDefinitionMismatch.

Re-registration pins the schedule to the local definition again. Every process does this at startup. The last registration wins. During a rolling deploy that changes the workflow, only a process that matches the current pin executes a fire. When the old workers are gone, the new definition owns every fire. When old processes and new processes keep restarting against each other, drain the old workers first. Or publish the changed workflow under a new schedule ID.

A schedule registered before migration 012 has no pin. A step schedule has no pin. An action schedule has no pin. The row stores the one-node shape of a step schedule or an action schedule. Any process can make a fire for these schedules until you register them again.

Cron Expression Syntax ​

Standard 5-field format:

DescriptorMeaning
@yearly / @annuallyOnce per year at midnight on Jan 1
@monthlyOnce per month at midnight on the 1st
@weeklyOnce per week at midnight on Sunday
@daily / @midnightOnce per day at midnight
@hourlyOnce per hour
@every <duration>Every duration (for example @every 5m, @every 1h30m)

The spec string does not support a CRON_TZ= prefix. Use WithScheduleLocation instead.

Timezones ​

go
loc, err := time.LoadLocation("America/New_York")
if err != nil {
    return err
}
cyclone.Schedule(ctx, client, "0 9 * * 1-5", BriefStep, BriefInput{},
    cyclone.WithScheduleID("daily-brief-nyc"),
    cyclone.WithScheduleLocation(loc),
)

WithScheduleLocation stores the IANA timezone name with the schedule. The scanner applies a DST transition correctly. The scanner uses the stored location. The scanner does not use the local timezone of the server.

Schedule IDs and WithScheduleID ​

Without WithScheduleID, the schedule ID is a hash of (kind, spec, timezone, input). The hash is deterministic. The hash shows none of those values.

Use WithScheduleID("my-id") for these cases:

  • The same ID on restart updates the row. The same ID does not insert a second schedule.
  • Pause, resume, or trigger by ID with client.PauseSchedule, client.ResumeSchedule, and client.TriggerSchedule.
  • Find one schedule in cyclone_schedules.
go
// Re-registration on restart is safe — upserts the schedule row.
schedID, err := cyclone.Schedule(ctx, client, "@hourly", ReportStep, in,
    cyclone.WithScheduleID("hourly-report"),
)

Idempotent Registration on Startup ​

A call to Schedule with the same WithScheduleID updates the schedule row. The call does not insert a duplicate. Call Schedule on every startup.

go
func registerSchedules(ctx context.Context, client *cyclone.Client) error {
    if _, err := cyclone.Schedule(ctx, client, "@hourly", ReportStep, ReportInput{},
        cyclone.WithScheduleID("hourly-report"),
    ); err != nil {
        return err
    }

    if _, err := cyclone.ScheduleAction(ctx, client, "@daily", CleanupAction, CleanupInput{},
        cyclone.WithScheduleID("daily-cleanup"),
    ); err != nil {
        return err
    }

    return nil
}

Transactional Schedule Registration ​

Register a schedule inside a transaction that the caller owns.

go
tx, err := pool.Begin(ctx)
if err != nil {
    return err
}
defer tx.Rollback(ctx)

if _, err := tx.Exec(ctx, `INSERT INTO config (key, value) VALUES ('report_schedule', 'enabled')`); err != nil {
    return err
}
if _, err := cyclone.ScheduleTx(ctx, client, tx, "@daily", ReportStep, ReportInput{},
    cyclone.WithScheduleID("daily-report"),
); err != nil {
    return err
}

return tx.Commit(ctx)

ScheduleTx inserts or updates the schedule row inside the transaction of the caller. ScheduleActionTx does the same.

Pause, Resume, and Trigger ​

PauseSchedule sets enabled = false. The scanner then skips the schedule. A run that an earlier fire already started is not affected. Cyclone keeps the fire history.

Unschedule is deprecated. Unschedule does the same UPDATE as PauseSchedule. You can reverse Unschedule in the same way. Use pause and resume for this. Use trigger for one run outside the cron cadence.

go
// Stop firing without redefining the schedule.
if err := client.PauseSchedule(ctx, "hourly-report"); err != nil {
    return err
}

// Bring it back. next_run_at is reset to the next future tick, so a schedule paused
// across many missed ticks resumes cleanly instead of firing a backlog.
if err := client.ResumeSchedule(ctx, "hourly-report"); err != nil {
    return err
}

// Fire one run immediately, out of band from the cron cadence.
runID, err := client.TriggerSchedule(ctx, "hourly-report")
if err != nil {
    return err
}

All three return cyclone.ErrScheduleNotFound for an unknown id. PauseSchedule is idempotent. ResumeSchedule is idempotent.

TriggerSchedule records exactly one fire for the pending slot. It writes that fire in cyclone_schedule_fires. The scanner uses the same log. The write takes the row lock of the schedule. A manual trigger and a natural tick cannot both create a fire for the same slot. When that slot already has a fire, the trigger returns the recorded run id. The trigger does not create a duplicate run. The scanner can win that race. Trigger works when the schedule is paused. Trigger also works when the schedule is not paused. Trigger does not change enabled.

From the CLI, the same operations are flags on cyclone schedules:

sh
cyclone schedules --pause hourly-report
cyclone schedules --resume hourly-report
cyclone schedules --trigger hourly-report

Per-Fire Options ​

The options on the step apply to each run that a fire starts.

go
var ReportStep = cyclone.NewStep("report", reportFn,
    cyclone.WithQueue("bulk"),        // fires into the bulk queue
    cyclone.WithTimeout(10*time.Minute),
    cyclone.WithMaxAttempts(3),
)

Missed Runs and Catch-Up Semantics ​

When the scanner is down at a scheduled slot, Cyclone makes one fire on the next scanner wake. A deploy window is one example. Cyclone then sets next_run_at to the next future fire. Cyclone does not start one run for each missed slot.

That one fire is for the oldest missed slot. The slot is the next_run_at value that the row still held. Take an hourly schedule whose last fire was at 13:00. The scanner is down from 14:00 to 16:30. The one fire records scheduled_for = 14:00. next_run_at then moves to 17:00. Read cyclone_schedule_fires.scheduled_for when the handler must know that slot.

This behavior is by design. Most scheduled work is idempotent. That work needs the latest execution. It does not need one execution for each missed slot.

Poison Schedules ​

consecutive_failures counts a failure in the scanner. It counts a failure in enqueue. It counts a failure in workflow submit. It does not count a handler failure after the fire has created a run. Monitor those runs separately for a handler error.

When a scanner failure, an enqueue failure, or a submit failure continues, Cyclone records last_error. Cyclone increments consecutive_failures. Cyclone writes a WARN log. The log contains the schedule id, the slot, and the current failure count. The schedule stays enabled.

After 10 consecutive failures, Cyclone skips that slot. next_run_at moves to the next future fire. The counter resets to 0. That skipped fire does not happen. This stop keeps one bad slot from blocking the schedule with no end. Alert on this counter. When the counter resets, that fire is already lost. Cyclone logs the skip at ERROR. The log names the dropped scheduled_for.

sql
SELECT id, kind, consecutive_failures, last_error, next_run_at
FROM cyclone_schedules
WHERE consecutive_failures > 0;

Client.Stats reports the same count as Health.FailingSchedules. cyclone stats prints that count.

What does not count toward the limit ​

Two failures mean that this process is the wrong process for this slot. They do not mean that the schedule is broken.

  • The schedule pins a workflow definition that this build does not produce (ErrScheduleDefinitionMismatch).
  • The workflow is not registered in this process (ErrScheduleWorkflowNotRegistered).

Both leave next_run_at unchanged. Both leave consecutive_failures unchanged. The scanner refuses the slot before it claims a lease. A peer process that has the pinned definition can claim the same slot on its next pass.

The durable signal is last_error together with consecutive_failures = 0. cyclone schedules shows last_error in the LAST_ERROR column. Cyclone writes last_error once. Cyclone then leaves that row alone. The slot stays due. That is by design. The scanner tries the slot on every pass. A write on every pass would update cyclone_schedules at the poll rate. That write would hit a schedule that no deployed build can make a fire for. The WARN log is limited in the same way. Cyclone writes one line for each schedule, slot, and reason. Cyclone writes that line again every 10 minutes while the refusal continues.

Health.FailingSchedules does not count these schedules. consecutive_failures stays 0. This is deliberate. For a stuck rollout, watch last_error.

A count of these refusals was a way to lose a fire. During a rollout with two versions, one process refused the same slot. A faster process did the same. After 10 refusals, the slot was skipped. The build that matched the pin did not get that slot.

Inspect Schedules ​

sql
SELECT id, kind, cron_spec, location, next_run_at, last_run_id, consecutive_failures, enabled
FROM cyclone_schedules
ORDER BY next_run_at;

Testing Schedules ​

Use env.ForceDue(schedID) to set next_run_at to now(). The scanner then makes the fire on the next tick.

go
func TestScheduleFires(t *testing.T) {
    env := cyclonetest.New(t)
    env.Mux().Register(ReportStep)

    schedID, err := cyclone.Schedule(context.Background(), env.Client(), "@daily", ReportStep, ReportInput{},
        cyclone.WithScheduleID("test-daily-report"),
    )
    require.NoError(t, err)

    // Force the schedule to be due now.
    env.ForceDue(schedID)

    // Wait for the scanner to fire it and the step to complete.
    env.RunUntilIdle()

    // Verify a run was created and completed.
    // (Query cyclone_runs or cyclone_schedule_fires to find the run ID.)
}

env.RunUntilIdle() waits until no step is available or running. It also waits until no due schedule is pending.

Cyclone stores each run in Postgres.