scheduler

package
v0.101.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 20, 2026 License: MIT Imports: 6 Imported by: 0

README

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.

Documentation

Overview

Package scheduler provides a GitHub Actions-inspired cron job scheduler.

Features:

  • Standard cron expression syntax (5-field and 6-field formats)
  • Timezone-aware scheduling
  • Middleware pattern for cross-cutting concerns (logging, metrics, recovery)
  • Graceful shutdown with context cancellation
  • Zero external dependencies

Basic usage:

s := scheduler.New()

s.Register(&scheduler.Job{
	Name:     "daily-cleanup",
	Schedule: "0 2 * * *", // 2 AM daily
	Handler: func(ctx context.Context) error {
		// Your cleanup logic here
		return nil
	},
})

s.Start()
defer s.Stop(context.Background())

With middleware:

s := scheduler.New(
	scheduler.WithTimezone("America/New_York"),
	scheduler.WithMiddleware(
		scheduler.Recovery(),
		scheduler.Logging(),
	),
)
Example (Basic)

Example demonstrates basic scheduler usage.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/hrygo/divinesense/plugin/scheduler"
)

func main() {
	s := scheduler.New()

	s.Register(&scheduler.Job{
		Name:        "hello",
		Schedule:    "*/5 * * * *", // Every 5 minutes
		Description: "Say hello",
		Handler: func(_ context.Context) error {
			fmt.Println("Hello from scheduler!")
			return nil
		},
	})

	s.Start()
	defer s.Stop(context.Background())

	// Scheduler runs in background
	time.Sleep(100 * time.Millisecond)
}
Example (GracefulShutdown)

Example demonstrates graceful shutdown with timeout.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/hrygo/divinesense/plugin/scheduler"
)

func main() {
	s := scheduler.New()

	s.Register(&scheduler.Job{
		Name:     "long-running",
		Schedule: "* * * * *",
		Handler: func(ctx context.Context) error {
			select {
			case <-time.After(30 * time.Second):
				fmt.Println("Job completed")
			case <-ctx.Done():
				fmt.Println("Job canceled, cleaning up...")
				return ctx.Err()
			}
			return nil
		},
	})

	s.Start()

	// Simulate shutdown signal
	time.Sleep(5 * time.Second)

	// Give jobs 10 seconds to finish
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := s.Stop(shutdownCtx); err != nil {
		fmt.Printf("Shutdown error: %v\n", err)
	}
}
Example (Middleware)

Example demonstrates middleware usage.

package main

import (
	"context"
	"log/slog"
	"os"
	"time"

	"github.com/hrygo/divinesense/plugin/scheduler"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	s := scheduler.New(
		scheduler.WithMiddleware(
			scheduler.Recovery(func(jobName string, r interface{}) {
				logger.Error("Job panicked", "job", jobName, "panic", r)
			}),
			scheduler.Logging(&slogAdapter{logger}),
			scheduler.Timeout(5*time.Minute),
		),
	)

	s.Register(&scheduler.Job{
		Name:     "data-sync",
		Schedule: "0 */2 * * *", // Every 2 hours
		Handler: func(_ context.Context) error {
			// Your sync logic here
			return nil
		},
	})

	s.Start()
	defer s.Stop(context.Background())
}

// slogAdapter adapts slog.Logger to scheduler.Logger interface.
type slogAdapter struct {
	logger *slog.Logger
}

func (a *slogAdapter) Info(msg string, args ...interface{}) {
	a.logger.Info(msg, args...)
}

func (a *slogAdapter) Error(msg string, args ...interface{}) {
	a.logger.Error(msg, args...)
}
Example (MultipleJobs)

Example demonstrates multiple jobs with different schedules.

package main

import (
	"context"
	"fmt"

	"github.com/hrygo/divinesense/plugin/scheduler"
)

func main() {
	s := scheduler.New()

	// Cleanup old data every night at 2 AM
	s.Register(&scheduler.Job{
		Name:     "cleanup",
		Schedule: "0 2 * * *",
		Tags:     []string{"maintenance"},
		Handler: func(_ context.Context) error {
			fmt.Println("Cleaning up old data...")
			return nil
		},
	})

	// Health check every 5 minutes
	s.Register(&scheduler.Job{
		Name:     "health-check",
		Schedule: "*/5 * * * *",
		Tags:     []string{"monitoring"},
		Handler: func(_ context.Context) error {
			fmt.Println("Running health check...")
			return nil
		},
	})

	// Weekly backup on Sundays at 1 AM
	s.Register(&scheduler.Job{
		Name:     "weekly-backup",
		Schedule: "0 1 * * 0",
		Tags:     []string{"backup"},
		Handler: func(_ context.Context) error {
			fmt.Println("Creating weekly backup...")
			return nil
		},
	})

	s.Start()
	defer s.Stop(context.Background())
}
Example (Timezone)

Example demonstrates timezone-aware scheduling.

package main

import (
	"context"
	"fmt"

	"github.com/hrygo/divinesense/plugin/scheduler"
)

func main() {
	s := scheduler.New(
		scheduler.WithTimezone("America/New_York"),
	)

	s.Register(&scheduler.Job{
		Name:     "daily-report",
		Schedule: "0 9 * * *", // 9 AM in New York
		Handler: func(_ context.Context) error {
			fmt.Println("Generating daily report...")
			return nil
		},
	})

	s.Start()
	defer s.Stop(context.Background())
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func GetJobName

func GetJobName(ctx context.Context) string

GetJobName retrieves the job name from the context (public API). Returns empty string if not found.

Types

type Job

type Job struct {
	// Name is a unique identifier for this job (required).
	// Used for logging and metrics.
	Name string

	// Schedule is a cron expression defining when this job runs (required).
	// Supports standard 5-field format: "minute hour day month weekday"
	// Examples: "0 * * * *" (hourly), "0 0 * * *" (daily at midnight)
	Schedule string

	// Timezone for schedule evaluation (optional, defaults to UTC).
	// Use IANA timezone names: "America/New_York", "Europe/London", etc.
	Timezone string

	// Handler is the function to execute when the job triggers (required).
	Handler JobHandler

	// Description provides human-readable context about what this job does (optional).
	Description string

	// Tags allow categorizing jobs for filtering/monitoring (optional).
	Tags []string
}

Job represents a scheduled task.

func (*Job) Validate

func (j *Job) Validate() error

Validate checks if the job definition is valid.

type JobHandler

type JobHandler func(ctx context.Context) error

JobHandler is the function signature for scheduled job handlers. The context passed to the handler will be canceled if the scheduler is shutting down.

type Logger

type Logger interface {
	Info(msg string, args ...interface{})
	Error(msg string, args ...interface{})
}

Logger is a minimal logging interface.

type Middleware

type Middleware func(JobHandler) JobHandler

Middleware wraps a JobHandler to add cross-cutting behavior.

func Chain

func Chain(middlewares ...Middleware) Middleware

Chain combines multiple middleware into a single middleware. Middleware are applied in the order they're provided (left to right).

func Logging

func Logging(logger Logger) Middleware

Logging adds execution logging to jobs.

func Recovery

func Recovery(onPanic func(jobName string, recovered interface{})) Middleware

Recovery recovers from panics in job handlers and converts them to errors.

func Timeout

func Timeout(duration time.Duration) Middleware

Timeout wraps a job handler with a timeout.

type Option

type Option func(*Scheduler)

Option configures a Scheduler.

func WithMiddleware

func WithMiddleware(mw ...Middleware) Option

WithMiddleware sets middleware to wrap all job handlers.

func WithTimezone

func WithTimezone(tz string) Option

WithTimezone sets the default timezone for all jobs.

type Schedule

type Schedule struct {
	// contains filtered or unexported fields
}

Schedule represents a parsed cron expression.

func ParseCronExpression

func ParseCronExpression(expr string) (*Schedule, error)

ParseCronExpression parses a cron expression and returns a Schedule. Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats.

func (*Schedule) Next

func (s *Schedule) Next(from time.Time) time.Time

Next returns the next time the schedule should run after the given time.

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler manages scheduled jobs.

func New

func New(opts ...Option) *Scheduler

New creates a new Scheduler with optional configuration.

func (*Scheduler) Register

func (s *Scheduler) Register(job *Job) error

Register adds a job to the scheduler. Jobs must be registered before calling Start().

func (*Scheduler) Start

func (s *Scheduler) Start() error

Start begins executing scheduled jobs.

func (*Scheduler) Stop

func (s *Scheduler) Stop(ctx context.Context) error

Stop gracefully shuts down the scheduler. It waits for all running jobs to complete or until the context is canceled.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL