Background processing

Cron scheduler

The pkg/cron package provides an in-process cron scheduler. Jobs are registered with standard 5-field cron expressions and run in goroutines within the same process.

import "github.com/stanza-go/framework/pkg/cron"

Creating a scheduler

scheduler := cron.NewScheduler(
    cron.WithLocation(time.UTC),    // timezone for schedule evaluation (default: UTC)
    cron.WithLogger(logger),        // optional logger
)

Adding jobs

Register jobs before starting the scheduler. Each job has a unique name, a cron expression, and a function:

scheduler.Add("cleanup-sessions", "0 * * * *", func(ctx context.Context) error {
    // runs every hour at minute 0
    _, err := db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix())
    return err
})

scheduler.Add("daily-report", "0 9 * * *", func(ctx context.Context) error {
    // runs at 9:00 AM UTC every day
    return generateReport(ctx)
})

scheduler.Add("every-five-minutes", "*/5 * * * *", func(ctx context.Context) error {
    return checkHealth(ctx)
})

The context is cancelled when the scheduler stops, allowing jobs to clean up gracefully.


Cron expression syntax

Standard 5-field format: minute hour day-of-month month day-of-week

FieldRangeSpecial
Minute0-59*, */n, ,, -
Hour0-23*, */n, ,, -
Day of month1-31*, */n, ,, -
Month1-12*, */n, ,, -
Day of week0-6 (Sun=0)*, */n, ,, -

Examples:

ExpressionMeaning
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Every hour at :00
30 * * * *Every hour at :30
0 9 * * *Daily at 9:00 AM
0 3 * * *Daily at 3:00 AM
0 0 * * 1Every Monday at midnight
0 9 1 * *First day of every month at 9:00 AM

Lifecycle integration

Wire the scheduler into the Stanza lifecycle:

func provideCron(lc *lifecycle.Lifecycle, db *sqlite.DB, q *queue.Queue, logger *log.Logger) *cron.Scheduler {
    s := cron.NewScheduler(cron.WithLogger(logger))

    s.Add("purge-completed-jobs", "0 * * * *", func(ctx context.Context) error {
        _, err := q.Purge(24 * time.Hour)
        return err
    })

    s.Add("purge-expired-tokens", "30 * * * *", func(ctx context.Context) error {
        _, err := db.Exec("DELETE FROM refresh_tokens WHERE expires_at < ?", time.Now().Unix())
        return err
    })

    lc.Append(lifecycle.Hook{
        OnStart: s.Start,
        OnStop:  s.Stop,
    })

    return s
}

Runtime management

List jobs

entries := scheduler.Entries()
for _, e := range entries {
    fmt.Printf("%-25s %-15s enabled=%-5v running=%-5v next=%s\n",
        e.Name, e.Schedule, e.Enabled, e.Running, e.NextRun)
}

Each Entry is a snapshot containing:

FieldTypeDescription
NamestringJob name
SchedulestringCron expression
EnabledboolWhether job will run on schedule
RunningboolWhether job is currently executing
LastRuntime.TimeWhen the job last ran
NextRuntime.TimeWhen the job will next run
LastErrerrorError from the last execution

Scheduler stats

stats := scheduler.Stats()
fmt.Printf("completed=%d failed=%d skipped=%d\n",
    stats.Completed, stats.Failed, stats.Skipped)

SchedulerStats holds cumulative counters since the scheduler was created:

FieldTypeDescription
JobsintTotal registered jobs
Completedint64Successful executions
Failedint64Executions that returned an error or panicked
Skippedint64Times a due job was skipped because it was still running

Counters use sync/atomic internally — Stats() is safe to call from any goroutine.

Enable and disable jobs

scheduler.Disable("daily-report")  // skip scheduled runs
scheduler.Enable("daily-report")   // resume scheduled runs

Disabled jobs remain registered but are not executed on their schedule.

Trigger a job manually

scheduler.Trigger("cleanup-sessions")  // run now, regardless of schedule

The job runs in a new goroutine. Returns an error if the job is not found or is already running.

Previous
Logging