first commit
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled
This commit is contained in:
367
plugin/scheduler/README.md
Normal file
367
plugin/scheduler/README.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user