mirror of https://github.com/usememos/memos
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
8.6 KiB
Markdown
368 lines
8.6 KiB
Markdown
# 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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)
|
|
|
|
```go
|
|
s := scheduler.New(
|
|
scheduler.WithMiddleware(
|
|
scheduler.Recovery(func(jobName string, r interface{}) {
|
|
log.Printf("Job %s panicked: %v", jobName, r)
|
|
}),
|
|
),
|
|
)
|
|
```
|
|
|
|
#### Logging
|
|
|
|
```go
|
|
type Logger interface {
|
|
Info(msg string, args ...interface{})
|
|
Error(msg string, args ...interface{})
|
|
}
|
|
|
|
s := scheduler.New(
|
|
scheduler.WithMiddleware(
|
|
scheduler.Logging(myLogger),
|
|
),
|
|
)
|
|
```
|
|
|
|
#### Timeout
|
|
|
|
```go
|
|
s := scheduler.New(
|
|
scheduler.WithMiddleware(
|
|
scheduler.Timeout(5 * time.Minute),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Combining Middleware
|
|
|
|
```go
|
|
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:
|
|
1. Recovery (outermost) catches panics from everything
|
|
2. Logging logs the execution
|
|
3. Timeout (innermost) wraps the actual handler
|
|
|
|
### Custom Middleware
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
Name: "user-cleanup-job" // Good
|
|
Name: "job1" // Bad
|
|
```
|
|
|
|
### 2. Add Descriptions and Tags
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
"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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
Timezone: "America/New_York" // Good
|
|
// Timezone: "" // Bad (defaults to UTC)
|
|
```
|
|
|
|
### 7. Test Your Cron Expressions
|
|
|
|
Use a cron expression calculator before deploying:
|
|
- [crontab.guru](https://crontab.guru/)
|
|
- Write unit tests with the parser
|
|
|
|
## Testing Jobs
|
|
|
|
Test job handlers independently of the scheduler:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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](./example_test.go) for comprehensive examples.
|
|
|
|
### Core Types
|
|
|
|
- `Scheduler` - Manages scheduled jobs
|
|
- `Job` - Job definition with schedule and handler
|
|
- `Middleware` - Function that wraps job handlers
|
|
|
|
### Functions
|
|
|
|
- `New(opts ...Option) *Scheduler` - Create new scheduler
|
|
- `WithTimezone(tz string) Option` - Set default timezone
|
|
- `WithMiddleware(mw ...Middleware) Option` - Add middleware
|
|
|
|
### Methods
|
|
|
|
- `Register(job *Job) error` - Add job to scheduler
|
|
- `Start() error` - Begin executing jobs
|
|
- `Stop(ctx context.Context) error` - Graceful shutdown
|
|
|
|
## License
|
|
|
|
This package is part of the Memos project and shares its license.
|