- Added `scheduler` package with core functionalities for job scheduling. - Implemented job registration, execution, and graceful shutdown mechanisms. - Introduced middleware support for logging, recovery from panics, and timeout handling. - Developed cron expression parser to support both 5-field and 6-field formats. - Created comprehensive integration and unit tests for scheduler, job definitions, middleware, and parser functionalities. - Enhanced logging capabilities to track job execution and errors. |
||
|---|---|---|
| .. | ||
| README.md | ||
| doc.go | ||
| example_test.go | ||
| integration_test.go | ||
| job.go | ||
| job_test.go | ||
| middleware.go | ||
| middleware_test.go | ||
| parser.go | ||
| parser_test.go | ||
| scheduler.go | ||
| scheduler_test.go | ||
README.md
Scheduler Plugin
A production-ready, GitHub Actions-inspired cron job scheduler for Go.
Features
- Standard Cron Syntax: Supports both 5-field and 6-field (with seconds) cron expressions
- Timezone-Aware: Explicit timezone handling to avoid DST surprises
- Middleware Pattern: Composable job wrappers for logging, metrics, panic recovery, timeouts
- Graceful Shutdown: Jobs complete cleanly or cancel when context expires
- Zero Dependencies: Core functionality uses only the standard library
- Type-Safe: Strong typing with clear error messages
- Well-Tested: Comprehensive test coverage
Installation
This package is included with Memos. No separate installation required.
Quick Start
package main
import (
"context"
"fmt"
"github.com/usememos/memos/plugin/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 {}
}
Cron Expression Format
5-Field Format (Standard)
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7) (Sunday = 0 or 7)
│ │ │ │ │
* * * * *
6-Field Format (With Seconds)
┌───────────── second (0 - 59)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │ │
* * * * * *
Special Characters
*- 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)
Common Examples
| 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 |
Timezone Support
// 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
Middleware wraps job handlers to add cross-cutting behavior. Multiple middleware can be chained together.
Built-in Middleware
Recovery (Panic Handling)
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
log.Printf("Job %s panicked: %v", jobName, r)
}),
),
)
Logging
type Logger interface {
Info(msg string, args ...interface{})
Error(msg string, args ...interface{})
}
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Logging(myLogger),
),
)
Timeout
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Timeout(5 * time.Minute),
),
)
Combining Middleware
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:
- Recovery (outermost) catches panics from everything
- Logging logs the execution
- Timeout (innermost) wraps the actual handler
Custom Middleware
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
}
}
}
Graceful Shutdown
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
}
Best Practices
1. Always Name Your Jobs
Names are used for logging, metrics, and debugging:
Name: "user-cleanup-job" // Good
Name: "job1" // Bad
2. Add Descriptions and Tags
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,
})
3. Use Appropriate Middleware
Always include Recovery and Logging in production:
scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(logPanic),
scheduler.Logging(logger),
),
)
4. Avoid Scheduling Exactly on the Hour
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)
5. Make Jobs Idempotent
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
}
6. Handle Timezones Explicitly
Always specify timezone for business-hour jobs:
Timezone: "America/New_York" // Good
// Timezone: "" // Bad (defaults to UTC)
7. Test Your Cron Expressions
Use a cron expression calculator before deploying:
- crontab.guru
- Write unit tests with the parser
Testing Jobs
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)
}
}
Comparison to Other Solutions
| 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) |
API Reference
See example_test.go for comprehensive examples.
Core Types
Scheduler- Manages scheduled jobsJob- Job definition with schedule and handlerMiddleware- Function that wraps job handlers
Functions
New(opts ...Option) *Scheduler- Create new schedulerWithTimezone(tz string) Option- Set default timezoneWithMiddleware(mw ...Middleware) Option- Add middleware
Methods
Register(job *Job) error- Add job to schedulerStart() error- Begin executing jobsStop(ctx context.Context) error- Graceful shutdown
License
This package is part of the Memos project and shares its license.