internal/scheduler/README.md
A production-ready, GitHub Actions-inspired cron job scheduler for Go.
This package is included with Memos. No separate installation required.
package main
import (
"context"
"fmt"
"github.com/usememos/memos/internal/scheduler"
)
func main() {
s := scheduler.New()
s.Register(&scheduler.Job{
Name: "daily-cleanup",
Schedule: "0 2 * * *", // 2 AM daily
Handler: func(ctx context.Context) error {
fmt.Println("Running cleanup...")
return nil
},
})
s.Start()
defer s.Stop(context.Background())
// Keep running...
select {}
}
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7) (Sunday = 0 or 7)
│ │ │ │ │
* * * * *
┌───────────── second (0 - 59)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │ │
* * * * * *
* - Any value (every minute, every hour, etc.), - List of values: 1,15,30 (1st, 15th, and 30th)- - Range: 9-17 (9 AM through 5 PM)/ - Step: */15 (every 15 units)| Schedule | Description |
|---|---|
* * * * * | Every minute |
0 * * * * | Every hour |
0 0 * * * | Daily at midnight |
0 9 * * 1-5 | Weekdays at 9 AM |
*/15 * * * * | Every 15 minutes |
0 0 1 * * | First day of every month |
0 0 * * 0 | Every Sunday at midnight |
30 14 * * * | Every day at 2:30 PM |
// Global timezone for all jobs
s := scheduler.New(
scheduler.WithTimezone("America/New_York"),
)
// Per-job timezone (overrides global)
s.Register(&scheduler.Job{
Name: "tokyo-report",
Schedule: "0 9 * * *", // 9 AM Tokyo time
Timezone: "Asia/Tokyo",
Handler: func(ctx context.Context) error {
// Runs at 9 AM in Tokyo
return nil
},
})
Important: Always use IANA timezone names (America/New_York, not EST).
Middleware wraps job handlers to add cross-cutting behavior. Multiple middleware can be chained together.
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
log.Printf("Job %s panicked: %v", jobName, r)
}),
),
)
type Logger interface {
Info(msg string, args ...interface{})
Error(msg string, args ...interface{})
}
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Logging(myLogger),
),
)
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Timeout(5 * time.Minute),
),
)
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(panicHandler),
scheduler.Logging(logger),
scheduler.Timeout(10 * time.Minute),
),
)
Order matters: Middleware are applied left-to-right. In the example above:
func Metrics(recorder MetricsRecorder) scheduler.Middleware {
return func(next scheduler.JobHandler) scheduler.JobHandler {
return func(ctx context.Context) error {
start := time.Now()
err := next(ctx)
duration := time.Since(start)
jobName := scheduler.GetJobName(ctx)
recorder.Record(jobName, duration, err)
return err
}
}
}
Always use Stop() with a context to allow jobs to finish cleanly:
// Give jobs up to 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
}
Jobs should respect context cancellation:
Handler: func(ctx context.Context) error {
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return ctx.Err() // Canceled
default:
// Do work
}
}
return nil
}
Names are used for logging, metrics, and debugging:
Name: "user-cleanup-job" // Good
Name: "job1" // Bad
s.Register(&scheduler.Job{
Name: "stale-session-cleanup",
Description: "Removes user sessions older than 30 days",
Tags: []string{"maintenance", "security"},
Schedule: "0 3 * * *",
Handler: cleanupSessions,
})
Always include Recovery and Logging in production:
scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(logPanic),
scheduler.Logging(logger),
),
)
Many systems schedule jobs at :00, causing load spikes. Stagger your jobs:
"5 2 * * *" // 2:05 AM (good)
"0 2 * * *" // 2:00 AM (often overloaded)
Jobs may run multiple times (crash recovery, etc.). Design them to be safely re-runnable:
Handler: func(ctx context.Context) error {
// Use unique constraint or check-before-insert
db.Exec("INSERT IGNORE INTO processed_items ...")
return nil
}
Always specify timezone for business-hour jobs:
Timezone: "America/New_York" // Good
// Timezone: "" // Bad (defaults to UTC)
Use a cron expression calculator before deploying:
Test job handlers independently of the scheduler:
func TestCleanupJob(t *testing.T) {
ctx := context.Background()
err := cleanupHandler(ctx)
if err != nil {
t.Fatalf("cleanup failed: %v", err)
}
// Verify cleanup occurred
}
Test schedule parsing:
func TestScheduleParsing(t *testing.T) {
job := &scheduler.Job{
Name: "test",
Schedule: "0 2 * * *",
Handler: func(ctx context.Context) error { return nil },
}
if err := job.Validate(); err != nil {
t.Fatalf("invalid job: %v", err)
}
}
| Feature | scheduler | robfig/cron | github.com/go-co-op/gocron |
|---|---|---|---|
| Standard cron syntax | ✅ | ✅ | ✅ |
| Seconds support | ✅ | ✅ | ✅ |
| Timezone support | ✅ | ✅ | ✅ |
| Middleware pattern | ✅ | ⚠️ (basic) | ❌ |
| Graceful shutdown | ✅ | ⚠️ (basic) | ✅ |
| Zero dependencies | ✅ | ❌ | ❌ |
| Job metadata | ✅ | ❌ | ⚠️ (limited) |
See example_test.go for comprehensive examples.
Scheduler - Manages scheduled jobsJob - Job definition with schedule and handlerMiddleware - Function that wraps job handlersNew(opts ...Option) *Scheduler - Create new schedulerWithTimezone(tz string) Option - Set default timezoneWithMiddleware(mw ...Middleware) Option - Add middlewareRegister(job *Job) error - Add job to schedulerStart() error - Begin executing jobsStop(ctx context.Context) error - Graceful shutdownThis package is part of the Memos project and shares its license.