memos/plugin/scheduler
Johnny 5828f34aae feat: implement scheduler plugin
- 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.
2025-12-20 15:43:25 +08:00
..
README.md feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
doc.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
example_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
integration_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
job.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
job_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
middleware.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
middleware_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
parser.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
parser_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
scheduler.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00
scheduler_test.go feat: implement scheduler plugin 2025-12-20 15:43:25 +08:00

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:

  1. Recovery (outermost) catches panics from everything
  2. Logging logs the execution
  3. 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:

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 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.