or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

configuration-options.mdcore-scheduler.mdindex.mdjob-management.mdjob-wrappers.mdlogging.mdschedule-parsing.mdschedule-types.md
tile.json

job-wrappers.mddocs/

Job Wrappers

Add cross-cutting behavior to jobs using wrappers and chains. Job wrappers allow you to decorate jobs with additional functionality like panic recovery, execution control, and logging.

Core Types

// JobWrapper decorates a Job with additional behavior
type JobWrapper func(Job) Job

// Chain is a sequence of JobWrappers that decorates submitted jobs
type Chain struct {
    // contains unexported fields
}

type Job interface {
    Run()
}

Creating Chains

// NewChain returns a Chain consisting of the given JobWrappers.
// Wrappers are applied in order, with the first wrapper being outermost.
func NewChain(c ...JobWrapper) Chain

Usage:

import "github.com/robfig/cron/v3"

logger := cron.DefaultLogger

// Create a chain of wrappers
chain := cron.NewChain(
    cron.Recover(logger),              // Outermost: recovers panics
    cron.SkipIfStillRunning(logger),   // Innermost: skips if running
)

Applying Chains

Global Application

Apply wrappers to all jobs via WithChain option:

// WithChain specifies Job wrappers to apply to all jobs
func WithChain(wrappers ...JobWrapper) Option

Usage:

logger := cron.DefaultLogger

c := cron.New(cron.WithChain(
    cron.Recover(logger),
    cron.SkipIfStillRunning(logger),
))

// All jobs added to this cron get wrapped automatically
c.AddFunc("@every 5s", func() {
    // This job has panic recovery and skip-if-running behavior
    doWork()
})

Per-Job Application

Apply wrappers to individual jobs:

// Then decorates a job with all JobWrappers in the chain
func (c Chain) Then(j Job) Job

Usage:

logger := cron.DefaultLogger

// Create cron without global chain
c := cron.New(cron.WithChain())

// Create wrapper chain
chain := cron.NewChain(
    cron.Recover(logger),
    cron.DelayIfStillRunning(logger),
)

// Wrap individual job
job := cron.FuncJob(func() {
    doWork()
})
wrappedJob := chain.Then(job)

// Schedule the wrapped job
c.Schedule(cron.Every(5*time.Minute), wrappedJob)

Built-in Wrappers

Recover

Recovers from panics in jobs and logs them.

// Recover panics in wrapped jobs and log them with the provided logger.
// Includes stack trace in error log.
func Recover(logger Logger) JobWrapper

Usage:

logger := cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))

c := cron.New(cron.WithChain(
    cron.Recover(logger),
))

c.AddFunc("@every 1m", func() {
    // If this panics, the panic is recovered and logged
    // The cron scheduler continues running
    riskyOperation()
})

Without Recover:

// No panic recovery - a panic will crash the scheduler
c := cron.New(cron.WithChain())

c.AddFunc("@every 1m", func() {
    panic("This will crash the entire scheduler!")
})

Default Behavior:

New() includes Recover by default. To disable:

// Explicitly create cron without panic recovery
c := cron.New(cron.WithChain())

DelayIfStillRunning

Delays job execution if the previous run hasn't completed.

// DelayIfStillRunning serializes jobs, delaying subsequent runs until the
// previous one is complete. Jobs delayed by more than a minute have the
// delay logged at Info level.
func DelayIfStillRunning(logger Logger) JobWrapper

Usage:

logger := cron.DefaultLogger

c := cron.New(cron.WithChain(
    cron.Recover(logger),
    cron.DelayIfStillRunning(logger),
))

c.AddFunc("@every 5s", func() {
    fmt.Println("Starting long job...")
    time.Sleep(10 * time.Second) // Takes 10 seconds
    fmt.Println("Finished")
})

// Timeline:
// 0s  - Job starts
// 5s  - Schedule fires, but job still running, so wait
// 10s - Job finishes
// 10s - Delayed job starts immediately
// 15s - Schedule fires again, job still running
// 20s - Job finishes
// 20s - Delayed job starts

Delay Logging:

If a job is delayed by more than 1 minute, the delay is logged:

// If job takes 90 seconds and runs every minute
c.AddFunc("@every 1m", func() {
    time.Sleep(90 * time.Second)
})

// Log output:
// cron: delay, duration=30s

SkipIfStillRunning

Skips job execution if the previous run hasn't completed.

// SkipIfStillRunning skips an invocation of the Job if a previous invocation
// is still running. It logs skips to the given logger at Info level.
func SkipIfStillRunning(logger Logger) JobWrapper

Usage:

logger := cron.DefaultLogger

c := cron.New(cron.WithChain(
    cron.Recover(logger),
    cron.SkipIfStillRunning(logger),
))

c.AddFunc("@every 5s", func() {
    fmt.Println("Starting long job...")
    time.Sleep(10 * time.Second)
    fmt.Println("Finished")
})

// Timeline:
// 0s  - Job starts
// 5s  - Schedule fires, but job still running, so skip (logged)
// 10s - Job finishes
// 15s - Schedule fires, job not running, starts
// 20s - Schedule fires, job still running, skip
// 25s - Job finishes

Skip Logging:

cron: skip

Choosing Between Wrappers

Recover vs No Recover

Use Recover when:

  • Running in production
  • Jobs might panic
  • You want the scheduler to continue despite errors
  • You want visibility into panics via logs

Don't use Recover when:

  • You want panics to crash the program (fail-fast)
  • You're in development and want stack traces
  • Jobs should never panic (use proper error handling)

DelayIfStillRunning vs SkipIfStillRunning

Use DelayIfStillRunning when:

  • Every execution must happen eventually
  • Jobs represent critical tasks
  • Delayed execution is acceptable
  • Example: Database backup, report generation

Use SkipIfStillRunning when:

  • Skipping executions is acceptable
  • Jobs represent periodic checks
  • Running multiple instances concurrently causes issues
  • Example: Health checks, metrics collection, sync operations

Neither (Default Concurrent Execution)

Use neither when:

  • Jobs are quick
  • Concurrent execution is safe and desired
  • Example: Sending notifications, logging metrics

Custom Wrappers

Implement custom job wrappers for your own cross-cutting concerns.

type JobWrapper func(Job) Job

Example: Timing Wrapper

// WithTiming logs execution time of jobs
func WithTiming(logger cron.Logger) cron.JobWrapper {
    return func(j cron.Job) cron.Job {
        return cron.FuncJob(func() {
            start := time.Now()
            j.Run()
            duration := time.Since(start)
            logger.Info("job completed", "duration", duration)
        })
    }
}

// Usage
c := cron.New(cron.WithChain(
    cron.Recover(logger),
    WithTiming(logger),
))

Example: Timeout Wrapper

// WithTimeout enforces a maximum execution time
func WithTimeout(timeout time.Duration, logger cron.Logger) cron.JobWrapper {
    return func(j cron.Job) cron.Job {
        return cron.FuncJob(func() {
            done := make(chan struct{})

            go func() {
                j.Run()
                close(done)
            }()

            select {
            case <-done:
                // Job completed
            case <-time.After(timeout):
                logger.Error(fmt.Errorf("timeout"), "job exceeded timeout",
                    "timeout", timeout)
            }
        })
    }
}

// Usage
c := cron.New(cron.WithChain(
    cron.Recover(logger),
    WithTimeout(5*time.Minute, logger),
))

Example: Context Wrapper

// WithContext provides context to jobs
type ContextJob interface {
    RunWithContext(context.Context)
}

func WithContext(ctx context.Context) cron.JobWrapper {
    return func(j cron.Job) cron.Job {
        return cron.FuncJob(func() {
            if ctxJob, ok := j.(ContextJob); ok {
                ctxJob.RunWithContext(ctx)
            } else {
                j.Run()
            }
        })
    }
}

// Define job with context
type MyJob struct {
    Name string
}

func (j MyJob) Run() {
    j.RunWithContext(context.Background())
}

func (j MyJob) RunWithContext(ctx context.Context) {
    fmt.Printf("Running %s with context\n", j.Name)
    // Use context for cancellation, deadlines, etc.
}

// Usage
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

c := cron.New(cron.WithChain(
    WithContext(ctx),
))
c.AddJob("@every 1m", MyJob{Name: "ContextJob"})

Example: Metrics Wrapper

// WithMetrics records job execution metrics
func WithMetrics(name string, recorder MetricsRecorder) cron.JobWrapper {
    return func(j cron.Job) cron.Job {
        return cron.FuncJob(func() {
            start := time.Now()
            recorder.JobStarted(name)

            defer func() {
                duration := time.Since(start)
                recorder.JobCompleted(name, duration)
            }()

            j.Run()
        })
    }
}

type MetricsRecorder interface {
    JobStarted(name string)
    JobCompleted(name string, duration time.Duration)
}

Wrapper Composition

Wrappers are composed in order, with earlier wrappers wrapping later ones:

chain := cron.NewChain(
    wrapper1, // Outermost
    wrapper2,
    wrapper3, // Innermost
)

wrapped := chain.Then(job)

// Equivalent to:
// wrapped = wrapper1(wrapper2(wrapper3(job)))

Execution Order:

chain := cron.NewChain(
    LogBefore,
    Recover,
    LogAfter,
)

// Execution:
// 1. LogBefore (before)
// 2. Recover (before)
// 3. LogAfter (before)
// 4. Job.Run()
// 5. LogAfter (after)
// 6. Recover (after, if panic)
// 7. LogBefore (after)

Complete Example

package main

import (
    "fmt"
    "log"
    "os"
    "time"
    "github.com/robfig/cron/v3"
)

func main() {
    logger := cron.VerbosePrintfLogger(
        log.New(os.Stdout, "cron: ", log.LstdFlags),
    )

    c := cron.New(cron.WithChain(
        cron.Recover(logger),              // Always recover panics
        cron.SkipIfStillRunning(logger),   // Skip overlapping executions
    ))

    // Job that might panic
    c.AddFunc("@every 10s", func() {
        fmt.Println("Risky job starting")
        if time.Now().Unix()%3 == 0 {
            panic("Random panic!")
        }
        fmt.Println("Risky job completed")
    })

    // Long-running job
    c.AddFunc("@every 15s", func() {
        fmt.Println("Long job starting")
        time.Sleep(20 * time.Second)
        fmt.Println("Long job completed")
    })

    c.Start()
    defer c.Stop()

    // Run for 2 minutes
    time.Sleep(2 * time.Minute)
}

Best Practices

Production Configuration

logger := cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))

c := cron.New(cron.WithChain(
    cron.Recover(logger),              // Always use in production
    cron.SkipIfStillRunning(logger),   // Prevent resource exhaustion
))

Wrapper Order

Place wrappers in logical order:

// Good: Recover should be outermost to catch all panics
cron.WithChain(
    cron.Recover(logger),              // Outermost
    WithMetrics("job"),
    cron.SkipIfStillRunning(logger),   // Innermost
)

// Bad: Inner wrappers' panics won't be caught
cron.WithChain(
    WithMetrics("job"),
    cron.SkipIfStillRunning(logger),
    cron.Recover(logger),              // Too deep
)

Per-Job vs Global

  • Use global chain (WithChain) for common behavior
  • Use per-job wrapping for job-specific behavior
// Global recovery and logging
c := cron.New(cron.WithChain(
    cron.Recover(logger),
))

// Per-job execution control
longJob := cron.NewChain(
    cron.DelayIfStillRunning(logger),
).Then(cron.FuncJob(func() {
    doLongWork()
}))

quickJob := cron.NewChain(
    cron.SkipIfStillRunning(logger),
).Then(cron.FuncJob(func() {
    doQuickWork()
}))

c.Schedule(cron.Every(5*time.Minute), longJob)
c.Schedule(cron.Every(10*time.Second), quickJob)