go-cron Cookbook

February 23, 2026 · View on GitHub

Practical recipes for common scheduling patterns. Each recipe is self-contained and production-ready.

Table of Contents

  1. Basic Setup
  2. Retry Pattern
  3. Circuit Breaker
  4. Metrics Integration
  5. Graceful Shutdown
  6. Testing Jobs
  7. Dynamic Scheduling
  8. Multi-Timezone
  9. Production-Ready Template
  10. Pause/Resume
  11. Triggered Jobs

1. Basic Setup

Problem: You need a simple scheduler with logging to get started.

Solution:

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

    cron "github.com/netresearch/go-cron"
)

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

    // Create scheduler with logging and panic recovery
    c := cron.New(
        cron.WithLogger(logger),
        cron.WithChain(cron.Recover(logger)),
    )

    // Add jobs
    c.AddFunc("@every 1m", func() {
        log.Println("Running every minute")
    })

    c.AddFunc("0 * * * *", func() {
        log.Println("Running at the start of every hour")
    })

    // Start scheduler
    c.Start()
    log.Println("Scheduler started")

    // Wait for shutdown signal
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    <-sig

    // Graceful shutdown
    log.Println("Shutting down...")
    c.Stop()
}

Key Points:

  • Always use cron.Recover(logger) to prevent panics from crashing the scheduler
  • VerbosePrintfLogger logs job executions for debugging
  • Handle OS signals for clean shutdown

2. Retry Pattern

Problem: You need to call an external API that occasionally fails due to transient errors.

Solution:

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"

    cron "github.com/netresearch/go-cron"
)

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

    c := cron.New(
        cron.WithChain(
            cron.Recover(logger),                        // Outermost: catch final panics
            cron.RetryWithBackoff(logger,
                3,              // maxRetries: try up to 3 times
                time.Second,    // initialDelay: 1s before first retry
                time.Minute,    // maxDelay: cap backoff at 1 minute
                2.0,            // multiplier: double delay each retry
            ),
        ),
    )

    c.AddFunc("@every 5m", func() {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            panic(fmt.Sprintf("API call failed: %v", err)) // Triggers retry
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            panic(fmt.Sprintf("Server error: %d", resp.StatusCode)) // Triggers retry
        }

        body, _ := io.ReadAll(resp.Body)
        log.Printf("Got data: %s", body[:min(len(body), 100)])
    })

    c.Start()
    select {} // Run forever
}

Retry Timeline (with 2.0 multiplier):

  1. Initial run fails → wait 1s
  2. Retry 1 fails → wait 2s
  3. Retry 2 fails → wait 4s
  4. Retry 3 fails → panic propagates to Recover

Key Points:

  • Jobs signal failure by panicking
  • RetryWithBackoff catches panics and retries with exponential backoff
  • Recover catches the final panic if all retries are exhausted
  • Set maxRetries=-1 for unlimited retries (use with caution)

3. Circuit Breaker

Problem: An external service is down, and you want to stop hammering it.

Solution:

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    cron "github.com/netresearch/go-cron"
)

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

    c := cron.New(
        cron.WithChain(
            cron.Recover(logger),                    // Catch all panics
            cron.CircuitBreaker(logger,
                5,              // threshold: open after 5 consecutive failures
                5*time.Minute,  // cooldown: stay open for 5 minutes
            ),
            cron.RetryWithBackoff(logger, 2, time.Second, 30*time.Second, 2.0),
        ),
    )

    c.AddFunc("@every 30s", func() {
        resp, err := http.Get("https://flaky-service.example.com/health")
        if err != nil {
            panic(fmt.Sprintf("Health check failed: %v", err))
        }
        defer resp.Body.Close()

        if resp.StatusCode != 200 {
            panic(fmt.Sprintf("Unhealthy: %d", resp.StatusCode))
        }

        log.Println("Service is healthy")
    })

    c.Start()
    select {}
}

Circuit States:

  1. Closed: Normal operation, failures are counted
  2. Open: After threshold failures, job is skipped entirely
  3. Half-Open: After cooldown, allows one test execution
  4. Closed: Success in half-open state resets the circuit

Key Points:

  • Protects downstream services from being overwhelmed
  • Order matters: CircuitBreaker should be outside RetryWithBackoff
  • Combine with alerting on circuit open events for operational awareness

4. Metrics Integration

Problem: You need to integrate job metrics with Prometheus or another monitoring system.

Solution using ObservabilityHooks:

package main

import (
    "log"
    "net/http"
    "time"

    cron "github.com/netresearch/go-cron"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    jobsScheduled = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cron_jobs_scheduled_total",
            Help: "Total number of job scheduling events",
        },
        []string{"job"},
    )
    jobsStarted = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cron_jobs_started_total",
            Help: "Total number of job starts",
        },
        []string{"job"},
    )
    jobDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "cron_job_duration_seconds",
            Help:    "Job execution duration",
            Buckets: []float64{.1, .5, 1, 5, 10, 30, 60, 300},
        },
        []string{"job"},
    )
    jobPanics = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cron_job_panics_total",
            Help: "Total number of job panics",
        },
        []string{"job"},
    )
)

func init() {
    prometheus.MustRegister(jobsScheduled, jobsStarted, jobDuration, jobPanics)
}

func main() {
    hooks := cron.ObservabilityHooks{
        OnSchedule: func(id cron.EntryID, name string, next time.Time) {
            jobsScheduled.WithLabelValues(name).Inc()
        },
        OnJobStart: func(id cron.EntryID, name string, scheduled time.Time) {
            jobsStarted.WithLabelValues(name).Inc()
        },
        OnJobComplete: func(id cron.EntryID, name string, duration time.Duration, recovered any) {
            jobDuration.WithLabelValues(name).Observe(duration.Seconds())
            if recovered != nil {
                jobPanics.WithLabelValues(name).Inc()
            }
        },
    }

    c := cron.New(cron.WithObservability(hooks))

    c.AddFunc("@every 1m", func() {
        // Your job logic
        log.Println("Job executed")
    })

    c.Start()

    // Expose metrics endpoint
    http.Handle("/metrics", promhttp.Handler())
    log.Fatal(http.ListenAndServe(":9090", nil))
}

Key Points:

  • ObservabilityHooks integrates without modifying job code
  • Track schedule events, starts, duration, and panics
  • Use histogram buckets appropriate for your job durations
  • Name your jobs for meaningful metrics labels

5. Graceful Shutdown

Problem: You need Kubernetes-style graceful termination with job completion.

Solution:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    cron "github.com/netresearch/go-cron"
)

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

    c := cron.New(
        cron.WithLogger(logger),
        cron.WithChain(
            cron.Recover(logger),
            cron.TimeoutWithContext(logger, 5*time.Minute), // Pass context to jobs
        ),
    )

    // Use FuncJobWithContext for context-aware jobs
    c.AddJob("@every 1m", cron.FuncJobWithContext(func(ctx context.Context) {
        log.Println("Starting long job...")

        for i := 0; i < 60; i++ {
            select {
            case <-ctx.Done():
                log.Println("Job cancelled, cleaning up...")
                // Perform cleanup
                return
            case <-time.After(time.Second):
                log.Printf("Working... %d/60", i+1)
            }
        }

        log.Println("Job completed normally")
    }))

    c.Start()
    log.Println("Scheduler started")

    // Wait for shutdown signal
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    <-sig

    log.Println("Received shutdown signal")

    // Create shutdown context with timeout
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // Stop accepting new jobs and wait for running jobs
    stopCtx := c.Stop()

    select {
    case <-stopCtx.Done():
        log.Println("All jobs completed gracefully")
    case <-shutdownCtx.Done():
        log.Println("Shutdown timeout - some jobs may still be running")
    }
}

Kubernetes Integration:

# Add to your Deployment spec
spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: scheduler
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh", "-c", "sleep 5"]

Key Points:

  • Use cron.FuncJobWithContext for jobs that respect cancellation
  • c.Stop() returns a context that's done when all jobs finish
  • Set timeout to match your longest job + buffer
  • Configure Kubernetes terminationGracePeriodSeconds accordingly

6. Testing Jobs

Problem: You need to unit test job functions and scheduling behavior.

Solution - Testing Job Logic:

package myjobs

import (
    "testing"
    "time"

    cron "github.com/netresearch/go-cron"
)

// TestJobExecution tests that a job runs at the expected time
func TestJobExecution(t *testing.T) {
    // Create a fake clock starting at a known time
    start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
    clock := cron.NewFakeClock(start)

    c := cron.New(cron.WithClock(clock))

    executed := make(chan struct{}, 1)
    c.AddFunc("@every 1h", func() {
        executed <- struct{}{}
    })

    c.Start()
    defer c.Stop()

    // Wait for the timer to be registered
    clock.BlockUntil(1)

    // Advance time by 1 hour
    clock.Advance(time.Hour)

    // Verify job was executed
    select {
    case <-executed:
        // Success
    case <-time.After(time.Second):
        t.Fatal("Job was not executed within expected time")
    }
}

// TestJobExecutionCount tests that a job runs the expected number of times
func TestJobExecutionCount(t *testing.T) {
    clock := cron.NewFakeClock(time.Now())
    c := cron.New(cron.WithClock(clock))

    count := 0
    c.AddFunc("@every 10m", func() {
        count++
    })

    c.Start()
    defer c.Stop()

    clock.BlockUntil(1)

    // Advance 1 hour (should trigger 6 executions)
    clock.Advance(time.Hour)
    time.Sleep(100 * time.Millisecond) // Allow goroutines to complete

    if count != 6 {
        t.Errorf("Expected 6 executions, got %d", count)
    }
}

// TestDSTBehavior tests job behavior during DST transitions
func TestDSTBehavior(t *testing.T) {
    loc, _ := time.LoadLocation("America/New_York")

    // Just before spring DST (March 10, 2024, 1:59 AM)
    start := time.Date(2024, 3, 10, 1, 59, 0, 0, loc)
    clock := cron.NewFakeClock(start)

    c := cron.New(
        cron.WithClock(clock),
        cron.WithLocation(loc),
    )

    var executionTime time.Time
    c.AddFunc("30 2 * * *", func() { // 2:30 AM
        executionTime = clock.Now()
    })

    c.Start()
    defer c.Stop()

    clock.BlockUntil(1)
    clock.Advance(2 * time.Hour) // Cross the DST boundary

    time.Sleep(100 * time.Millisecond)

    // During spring DST, 2:30 AM doesn't exist
    // Job should run at 3:00 AM (ISC cron behavior)
    if executionTime.Hour() != 3 {
        t.Errorf("Expected job to run at 3 AM, got %v", executionTime)
    }
}

// TestJobPanic tests that panics are properly handled
func TestJobPanic(t *testing.T) {
    clock := cron.NewFakeClock(time.Now())

    panicRecovered := make(chan struct{}, 1)
    logger := &testLogger{onError: func() { panicRecovered <- struct{}{} }}

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

    c.AddFunc("@every 1m", func() {
        panic("test panic")
    })

    c.Start()
    defer c.Stop()

    clock.BlockUntil(1)
    clock.Advance(time.Minute)

    select {
    case <-panicRecovered:
        // Success - panic was recovered
    case <-time.After(time.Second):
        t.Fatal("Panic was not recovered")
    }
}

// testLogger implements cron.Logger for testing
type testLogger struct {
    onError func()
}

func (l *testLogger) Info(msg string, keysAndValues ...interface{})  {}
func (l *testLogger) Error(err error, msg string, keysAndValues ...interface{}) {
    if l.onError != nil {
        l.onError()
    }
}

Key Points:

  • Use FakeClock for deterministic, fast tests
  • BlockUntil(n) waits for n timers to be registered
  • Test DST transitions with specific timezone and date
  • Test error handling and panic recovery

7. Dynamic Scheduling

Problem: You need to add/remove jobs at runtime based on configuration changes.

Solution:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"

    cron "github.com/netresearch/go-cron"
)

type Scheduler struct {
    cron *cron.Cron
    jobs map[string]cron.EntryID
    mu   sync.RWMutex
}

func NewScheduler() *Scheduler {
    return &Scheduler{
        cron: cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))),
        jobs: make(map[string]cron.EntryID),
    }
}

func (s *Scheduler) Start() {
    s.cron.Start()
}

func (s *Scheduler) Stop() {
    s.cron.Stop()
}

// AddJob adds a new job or replaces an existing one with the given name.
// UpsertJob handles the create-or-update logic atomically, preserving EntryID
// on updates and handling TOCTOU races internally.
//
// WaitForJobByName ensures the currently-running invocation finishes before
// replacement, preventing overlapping executions during schedule changes.
func (s *Scheduler) AddJob(name, spec string, fn func()) error {
    // Wait for any in-flight execution to complete before replacing.
    // Called outside the lock to avoid blocking other callers.
    s.cron.WaitForJobByName(name)

    s.mu.Lock()
    defer s.mu.Unlock()

    id, err := s.cron.UpsertJob(spec, cron.FuncJob(fn), cron.WithName(name))
    if err != nil {
        return err
    }

    s.jobs[name] = id
    log.Printf("Job %q upserted with spec %q (ID: %d)", name, spec, id)
    return nil
}

// RemoveJob removes a job by name
func (s *Scheduler) RemoveJob(name string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    id, exists := s.jobs[name]
    if !exists {
        return false
    }

    s.cron.Remove(id)
    delete(s.jobs, name)
    log.Printf("Job %q removed", name)
    return true
}

// ListJobs returns all registered job names
func (s *Scheduler) ListJobs() []string {
    s.mu.RLock()
    defer s.mu.RUnlock()

    names := make([]string, 0, len(s.jobs))
    for name := range s.jobs {
        names = append(names, name)
    }
    return names
}

// HTTP API for dynamic job management
func main() {
    scheduler := NewScheduler()
    scheduler.Start()
    defer scheduler.Stop()

    // POST /jobs - Add/update a job
    http.HandleFunc("/jobs", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }

        var req struct {
            Name string `json:"name"`
            Spec string `json:"spec"`
        }
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }

        err := scheduler.AddJob(req.Name, req.Spec, func() {
            log.Printf("Executing job: %s", req.Name)
        })
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }

        w.WriteHeader(http.StatusCreated)
    })

    // DELETE /jobs/{name} - Remove a job
    // PATCH  /jobs/{name} - Update job spec
    http.HandleFunc("/jobs/", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodPatch:
            name := r.URL.Path[len("/jobs/"):]
            var req struct{ Spec string `json:"spec"` }
            if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                http.Error(w, err.Error(), http.StatusBadRequest)
                return
            }

            scheduler.mu.RLock()
            id, exists := scheduler.jobs[name]
            scheduler.mu.RUnlock()

            if !exists {
                http.Error(w, "Job not found", http.StatusNotFound)
                return
            }

            if err := scheduler.cron.UpdateJob(id, req.Spec); err != nil {
                if errors.Is(err, cron.ErrEntryNotFound) {
                    // Job was removed after lookup; clean up stale mapping only if ID still matches
                    scheduler.mu.Lock()
                    if currentID, ok := scheduler.jobs[name]; ok && currentID == id {
                        delete(scheduler.jobs, name)
                    }
                    scheduler.mu.Unlock()
                    http.Error(w, "Job not found", http.StatusNotFound)
                    return
                }
                http.Error(w, err.Error(), http.StatusBadRequest)
                return
            }
            w.WriteHeader(http.StatusNoContent)
        case http.MethodDelete:
            name := r.URL.Path[len("/jobs/"):]
            if !scheduler.RemoveJob(name) {
                http.Error(w, "Job not found", http.StatusNotFound)
                return
            }
            w.WriteHeader(http.StatusNoContent)
        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })

    // GET /jobs - List all jobs
    http.HandleFunc("/jobs/list", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(scheduler.ListJobs())
    })

    log.Println("API server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Key Points:

  • Use a map to track job names to EntryIDs
  • Remove existing job before adding updated version
  • cron.Remove(id) is safe to call while scheduler is running
  • Protect map access with mutex for concurrent safety

8. Multi-Timezone

Problem: You need jobs running in different timezones for a global application.

Solution:

package main

import (
    "log"
    "os"
    "time"

    cron "github.com/netresearch/go-cron"
)

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

    // Default scheduler uses UTC for consistency
    c := cron.New(
        cron.WithLocation(time.UTC),
        cron.WithLogger(logger),
        cron.WithChain(cron.Recover(logger)),
    )

    // Job 1: Run at 9 AM New York time (business hours start)
    c.AddFunc("CRON_TZ=America/New_York 0 9 * * MON-FRI", func() {
        log.Println("NYC business hours started")
    })

    // Job 2: Run at 9 AM Tokyo time
    c.AddFunc("CRON_TZ=Asia/Tokyo 0 9 * * MON-FRI", func() {
        log.Println("Tokyo business hours started")
    })

    // Job 3: Run at 9 AM London time
    c.AddFunc("CRON_TZ=Europe/London 0 9 * * MON-FRI", func() {
        log.Println("London business hours started")
    })

    // Job 4: Global daily report at midnight UTC (no timezone override)
    c.AddFunc("0 0 * * *", func() {
        log.Println("Generating daily report (UTC midnight)")
    })

    // Job 5: Using pre-loaded timezone for complex schedules
    sydney, _ := time.LoadLocation("Australia/Sydney")
    schedule, _ := cron.ParseStandard("0 9 * * MON-FRI")
    // Override schedule's location
    if specSched, ok := schedule.(*cron.SpecSchedule); ok {
        specSched.Location = sydney
    }
    c.Schedule(schedule, cron.FuncJob(func() {
        log.Println("Sydney business hours started")
    }))

    c.Start()
    log.Println("Multi-timezone scheduler started")

    // Log current time in all zones for debugging
    ticker := time.NewTicker(time.Hour)
    for range ticker.C {
        now := time.Now()
        log.Printf("Current times - UTC: %s, NYC: %s, Tokyo: %s, London: %s",
            now.UTC().Format("15:04"),
            now.In(mustLoadLocation("America/New_York")).Format("15:04"),
            now.In(mustLoadLocation("Asia/Tokyo")).Format("15:04"),
            now.In(mustLoadLocation("Europe/London")).Format("15:04"),
        )
    }
}

func mustLoadLocation(name string) *time.Location {
    loc, err := time.LoadLocation(name)
    if err != nil {
        panic(err)
    }
    return loc
}

Best Practices for Multi-Timezone:

// DO: Use explicit timezones for clarity
c.AddFunc("CRON_TZ=America/New_York 0 9 * * *", job)

// DO: Use UTC for system-level jobs
c.AddFunc("0 0 * * *", dailyCleanup) // With cron.WithLocation(time.UTC)

// DON'T: Mix local time assumptions
c.AddFunc("0 9 * * *", job) // Which 9 AM? Depends on server location!

// DO: Handle DST-sensitive times with care
// Avoid 2-3 AM in DST-observing timezones
c.AddFunc("CRON_TZ=America/New_York 0 4 * * *", job) // 4 AM is safe

Key Points:

  • Set default location to UTC for predictability
  • Use CRON_TZ= prefix for per-job timezone
  • Use IANA timezone names (not abbreviations like "EST")
  • Quoted values are accepted: CRON_TZ="America/New_York" and CRON_TZ='UTC' both work
  • Avoid scheduling during DST transition hours (2-3 AM)
  • Log times in multiple zones for debugging

9. Production-Ready Template

Problem: You need a complete, production-ready scheduler setup.

Solution:

package main

import (
    "context"
    "errors"
    "log"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    cron "github.com/netresearch/go-cron"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

// Metrics
var (
    jobDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "cron_job_duration_seconds",
            Help:    "Job execution duration",
            Buckets: prometheus.DefBuckets,
        },
        []string{"job"},
    )
    jobStatus = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cron_job_status_total",
            Help: "Job completion status",
        },
        []string{"job", "status"},
    )
)

func init() {
    prometheus.MustRegister(jobDuration, jobStatus)
}

func main() {
    // Structured logging
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    cronLogger := cron.NewSlogLogger(logger)

    // Observability hooks
    hooks := cron.ObservabilityHooks{
        OnJobStart: func(id cron.EntryID, name string, scheduled time.Time) {
            logger.Info("job started", "job", name, "entry_id", id)
        },
        OnJobComplete: func(id cron.EntryID, name string, duration time.Duration, recovered any) {
            jobDuration.WithLabelValues(name).Observe(duration.Seconds())
            status := "success"
            if recovered != nil {
                status = "panic"
                logger.Error("job panicked", "job", name, "panic", recovered)
            }
            jobStatus.WithLabelValues(name, status).Inc()
        },
    }

    // Create scheduler with all production features
    c := cron.New(
        cron.WithLocation(time.UTC),
        cron.WithLogger(cronLogger),
        cron.WithObservability(hooks),
        cron.WithMaxEntries(1000), // Prevent unbounded growth
        cron.WithChain(
            cron.Recover(cronLogger),                     // Always recover panics
            cron.RetryWithBackoff(cronLogger,
                3, time.Second, time.Minute, 2.0),        // Retry transient failures
            cron.CircuitBreaker(cronLogger, 5, 5*time.Minute), // Protect external services
            cron.SkipIfStillRunning(cronLogger),          // Prevent overlap
        ),
    )

    // Add your jobs
    if _, err := c.AddFunc("@every 1m", healthCheck); err != nil {
        logger.Error("failed to add job", "error", err)
        os.Exit(1)
    }

    if _, err := c.AddFunc("0 * * * *", hourlyReport); err != nil {
        logger.Error("failed to add job", "error", err)
        os.Exit(1)
    }

    // Start scheduler
    c.Start()
    logger.Info("scheduler started", "entries", len(c.Entries()))

    // Start metrics server
    go func() {
        http.Handle("/metrics", promhttp.Handler())
        http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
            w.WriteHeader(http.StatusOK)
            w.Write([]byte("OK"))
        })
        http.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
            if len(c.Entries()) > 0 {
                w.WriteHeader(http.StatusOK)
                w.Write([]byte("READY"))
            } else {
                w.WriteHeader(http.StatusServiceUnavailable)
                w.Write([]byte("NO JOBS"))
            }
        })
        if err := http.ListenAndServe(":9090", nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
            logger.Error("metrics server failed", "error", err)
        }
    }()

    // Graceful shutdown
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    <-sig

    logger.Info("shutdown initiated")

    // Create shutdown context
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // Stop scheduler and wait for jobs
    stopCtx := c.Stop()

    select {
    case <-stopCtx.Done():
        logger.Info("all jobs completed")
    case <-ctx.Done():
        logger.Warn("shutdown timeout reached")
    }

    logger.Info("shutdown complete")
}

func healthCheck() {
    // Your health check logic
    log.Println("Health check executed")
}

func hourlyReport() {
    // Your report generation logic
    log.Println("Hourly report generated")
}

Features Included:

  • Structured JSON logging with slog
  • Prometheus metrics integration
  • Health and readiness endpoints
  • Panic recovery with retry and circuit breaker
  • Job overlap prevention
  • Entry limit protection
  • Graceful shutdown with timeout
  • UTC timezone for consistency

10. Pause/Resume

Problem: You need to temporarily suspend jobs during maintenance or enable jobs only after feature activation.

Solution — Maintenance Window:

package main

import (
    "log"
    "os"

    cron "github.com/netresearch/go-cron"
)

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

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

    c.AddFunc("@every 1m", func() {
        log.Println("Syncing data...")
    }, cron.WithName("sync"))

    c.Start()

    // During deployment: pause the job
    if err := c.PauseEntryByName("sync"); err != nil {
        log.Printf("pause failed: %v", err)
    }
    log.Println("Paused sync during deployment")

    // Check status
    if c.IsEntryPausedByName("sync") {
        log.Println("sync is paused")
    }

    // After deployment: resume
    if err := c.ResumeEntryByName("sync"); err != nil {
        log.Printf("resume failed: %v", err)
    }
    log.Println("Resumed sync after deployment")

    select {} // Run forever
}

Solution — Feature-Flagged Jobs:

// Register job in paused state — activate later via feature flag
c.AddFunc("@every 5m", experimentalSync,
    cron.WithPaused(),
    cron.WithName("experimental-sync"),
)

// When feature flag is enabled:
c.ResumeEntryByName("experimental-sync")

// When feature flag is disabled:
c.PauseEntryByName("experimental-sync")

Key Points:

  • Paused entries remain registered with their schedule advancing, but execution is skipped
  • No catch-up flood occurs on resume — the job simply runs at its next scheduled time
  • Pausing/resuming an already-paused/active entry is a no-op (returns nil)
  • WithPaused() option lets you add entries in paused state from the start
  • The pause/resume methods return ErrEntryNotFound for invalid entries, while query methods return false

11. Triggered Jobs

Jobs that never fire automatically — only when explicitly triggered. Useful for API-triggered deployments, workflow orchestration, and event-driven patterns that still benefit from the scheduler's middleware chain.

API-Triggered Pattern

package main

import (
    "errors"
    "log"
    "net/http"

    cron "github.com/netresearch/go-cron"
)

func main() {
    c := cron.New()

    // Register a triggered job — never fires on its own
    c.AddFunc("@triggered", func() {
        log.Println("Deploying...")
        // runDeployPipeline()
    }, cron.WithName("deploy"))

    c.Start()
    defer c.Stop()

    // HTTP handler triggers the job on demand
    http.HandleFunc("/api/deploy", func(w http.ResponseWriter, r *http.Request) {
        if err := c.TriggerEntryByName("deploy"); err != nil {
            switch {
            case errors.Is(err, cron.ErrEntryNotFound):
                http.Error(w, err.Error(), http.StatusNotFound)
            case errors.Is(err, cron.ErrEntryPaused):
                http.Error(w, err.Error(), http.StatusConflict)
            case errors.Is(err, cron.ErrNotRunning):
                http.Error(w, "Scheduler not running", http.StatusServiceUnavailable)
            default:
                http.Error(w, err.Error(), http.StatusInternalServerError)
            }
            return
        }
        w.WriteHeader(http.StatusAccepted)
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Workflow Orchestration

c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))

// Job B is triggered, only runs when Job A completes
c.AddFunc("@triggered", processData, cron.WithName("step-B"))

// Job A runs on schedule and triggers Job B on success
c.AddFunc("@every 1h", func() {
    if err := fetchData(); err != nil {
        log.Println("fetch failed:", err)
        return
    }
    // Chain to next step
    if err := c.TriggerEntryByName("step-B"); err != nil {
        log.Println("trigger step-B failed:", err)
    }
}, cron.WithName("step-A"))

c.Start()

Key Points:

  • Use @triggered, @manual, or @none — all are aliases
  • The full middleware chain (Recover, SkipIfStillRunning, Timeout, etc.) applies to triggered runs
  • TriggerEntry also works on regularly scheduled entries as a "run now" button
  • Returns ErrEntryPaused for paused entries, ErrNotRunning if scheduler is stopped
  • Combined with WithRunOnce(), a triggered entry runs once then removes itself
  • Check entry.Triggered or cron.IsTriggered(entry.Schedule) to identify triggered entries

12. Resilience Monitoring

Monitor retry behavior and circuit breaker state for production alerting.

Retry Metrics

package main

import (
	"fmt"
	"log"
	"sync/atomic"
	"time"

	cron "github.com/netresearch/go-cron"
)

func main() {
	var totalRetries, exhausted int64

	c := cron.New(cron.WithChain(
		cron.Recover(cron.DefaultLogger),
		cron.RetryWithBackoff(cron.DefaultLogger, 3, time.Second, time.Minute, 2.0,
			cron.WithRetryCallback(func(a cron.RetryAttempt) {
				if a.Attempt > 1 {
					atomic.AddInt64(&totalRetries, 1)
				}
				if !a.WillRetry && a.Err != nil {
					atomic.AddInt64(&exhausted, 1)
				}
			}),
		),
	))

	c.AddFunc("@every 1m", func() { /* job */ }, cron.WithName("api-sync"))
	c.Start()
	defer c.Stop()

	// Periodically log metrics
	for range time.Tick(30 * time.Second) {
		fmt.Printf("retries=%d exhausted=%d\n",
			atomic.LoadInt64(&totalRetries), atomic.LoadInt64(&exhausted))
	}
}

Circuit Breaker Health Check

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	cron "github.com/netresearch/go-cron"
)

func main() {
	logger := cron.DefaultLogger

	wrapper, handle := cron.CircuitBreakerWithHandle(logger, 5, 5*time.Minute,
		cron.WithStateChangeCallback(func(e cron.CircuitBreakerEvent) {
			log.Printf("circuit: %s -> %s (failures=%d)",
				e.OldState, e.NewState, e.Failures)
		}),
	)

	c := cron.New(cron.WithChain(cron.Recover(logger), wrapper))
	c.AddFunc("@every 1m", func() { /* job */ }, cron.WithName("payment-sync"))
	c.Start()
	defer c.Stop()

	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		state := handle.State()
		switch state {
		case cron.CircuitOpen:
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprintf(w, "circuit open (failures=%d, cooldown_ends=%s)",
				handle.Failures(), handle.CooldownEnds().Format(time.RFC3339))
		case cron.CircuitHalfOpen:
			w.WriteHeader(http.StatusOK)
			fmt.Fprint(w, "circuit half-open (probing recovery)")
		default:
			w.WriteHeader(http.StatusOK)
			fmt.Fprint(w, "ok")
		}
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}

Summary

RecipeWhen to Use
Basic SetupGetting started, simple scheduling
Retry PatternExternal API calls, transient failures
Circuit BreakerProtecting failing external services
Metrics IntegrationProduction monitoring with Prometheus
Graceful ShutdownKubernetes deployments, clean termination
Testing JobsUnit and integration testing
Dynamic SchedulingRuntime job management via API
Multi-TimezoneGlobal applications, regional schedules
Production TemplateComplete production setup
Pause/ResumeMaintenance windows, feature-flagged jobs
Triggered JobsAPI-triggered jobs, workflow orchestration
Resilience MonitoringRetry metrics, circuit breaker health checks

For more details, see: