CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/golang-github-com-go-co-op-gocron-v2

A Golang job scheduling library that lets you run Go functions at pre-determined intervals using cron expressions, fixed durations, daily, weekly, monthly, or one-time schedules with support for distributed deployments.

Overview
Eval results
Files

concurrency-control.mddocs/examples/by-feature/

Concurrency Control Examples

Control how jobs run concurrently using singleton mode, scheduler limits, and interval strategies.

Singleton Mode (Per-Job Concurrency)

Skip Overlapping Runs (Reschedule Mode)

// If job is running when next run is scheduled, skip it
j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute) // Takes longer than interval
    }),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Queue Overlapping Runs (Wait Mode)

// If job is running, queue the next run
j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute)
    }),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

Comparison: Reschedule vs Wait

// Reschedule mode: skip runs if previous is still executing
j1, _ := s.NewJob(
    gocron.DurationJob(10*time.Second),
    gocron.NewTask(func() {
        fmt.Println("Start:", time.Now().Format("15:04:05"))
        time.Sleep(25 * time.Second) // Longer than interval
        fmt.Println("End:", time.Now().Format("15:04:05"))
    }),
    gocron.WithName("reschedule-mode"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
// Run 1: 0s-25s
// Run 2: 30s-55s (skipped 10s and 20s)
// Run 3: 60s-85s (skipped 40s and 50s)

// Wait mode: queue runs if previous is still executing
j2, _ := s.NewJob(
    gocron.DurationJob(10*time.Second),
    gocron.NewTask(func() {
        fmt.Println("Start:", time.Now().Format("15:04:05"))
        time.Sleep(25 * time.Second)
        fmt.Println("End:", time.Now().Format("15:04:05"))
    }),
    gocron.WithName("wait-mode"),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)
// Run 1: 0s-25s
// Run 2: 25s-50s (queued at 10s)
// Run 3: 50s-75s (queued at 20s)

Singleton for Database Operations

// Ensure only one database cleanup runs at a time
j, _ := s.NewJob(
    gocron.DurationJob(5*time.Minute),
    gocron.NewTask(func() {
        fmt.Println("Starting database cleanup")
        cleanupOldRecords()
        vacuumTables()
        updateStatistics()
        fmt.Println("Database cleanup complete")
    }),
    gocron.WithName("db-cleanup"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Singleton for API Calls

// Prevent multiple simultaneous API calls
j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        data, err := fetchFromExternalAPI()
        if err != nil {
            log.Printf("API call failed: %v", err)
            return
        }
        processData(data)
    }),
    gocron.WithName("api-sync"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Scheduler-Level Concurrency Limits

Limit Maximum Concurrent Jobs

// Allow max 3 jobs to run concurrently
s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(3, gocron.LimitModeReschedule),
)

// Add 10 jobs
for i := 0; i < 10; i++ {
    s.NewJob(
        gocron.DurationJob(time.Minute),
        gocron.NewTask(func(id int) {
            fmt.Printf("Job %d running\n", id)
            time.Sleep(30 * time.Second)
        }, i),
    )
}

s.Start()
// At most 3 jobs run concurrently

Queue Mode with Concurrent Limit

s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(3, gocron.LimitModeWait),
)

// Jobs wait in queue if limit reached

Check Queue Size

s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(3, gocron.LimitModeWait),
)

// Add jobs...
s.Start()

// Later: check queue
go func() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        waiting := s.JobsWaitingInQueue()
        fmt.Printf("%d jobs waiting in queue\n", waiting)
    }
}()

Resource-Constrained Environment

// Limit concurrent jobs based on available resources
cpuCount := runtime.NumCPU()
maxJobs := cpuCount * 2 // 2x CPU cores

s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(maxJobs, gocron.LimitModeReschedule),
)

// Add resource-intensive jobs
for i := 0; i < 20; i++ {
    s.NewJob(
        gocron.DurationJob(time.Minute),
        gocron.NewTask(func(id int) {
            performHeavyComputation(id)
        }, i),
        gocron.WithName(fmt.Sprintf("computation-%d", i)),
    )
}

Interval from Completion

Ensure Rest Period Between Runs

// Wait 5 minutes AFTER job completes, not from start
j, _ := s.NewJob(
    gocron.DurationJob(5*time.Minute),
    gocron.NewTask(func() {
        // Takes variable time to complete
        doWork()
    }),
    gocron.WithIntervalFromCompletion(),
)

Queue Processing with Rest

// Process queue with consistent wait time between batches
j, _ := s.NewJob(
    gocron.DurationJob(10*time.Second),
    gocron.NewTask(func() {
        batch := fetchQueueBatch()
        processBatch(batch) // Variable duration
    }),
    gocron.WithIntervalFromCompletion(),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
    gocron.WithName("queue-processor"),
)
// Always waits 10 seconds after batch completes

Rate-Limited API Polling

// Ensure minimum time between API calls
j, _ := s.NewJob(
    gocron.DurationJob(5*time.Second),
    gocron.NewTask(func() {
        // API call duration varies
        data, err := callRateLimitedAPI()
        if err != nil {
            log.Printf("API error: %v", err)
            return
        }
        processAPIData(data)
    }),
    gocron.WithIntervalFromCompletion(),
    gocron.WithName("api-poller"),
)
// Waits 5 seconds after each call completes

Combining Concurrency Controls

Singleton + Scheduler Limit

// Scheduler allows max 5 concurrent jobs
s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(5, gocron.LimitModeReschedule),
)

// Individual jobs use singleton mode
s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(doWork),
    gocron.WithName("worker-1"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// This prevents both:
// 1. More than 5 jobs running across scheduler
// 2. Same job running multiple times concurrently

Singleton + Interval from Completion

j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        // Variable duration work
        processItems()
    }),
    gocron.WithIntervalFromCompletion(),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
// Best for ensuring consistent rest periods between runs
// and preventing overlaps

Global Job Options

// Apply singleton mode to all jobs
s, _ := gocron.NewScheduler(
    gocron.WithGlobalJobOptions(
        gocron.WithSingletonMode(gocron.LimitModeReschedule),
    ),
)

// All jobs inherit singleton mode
j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(myFunc),
    // Can override if needed
    // gocron.WithSingletonMode(gocron.LimitModeWait),
)

Context and Cancellation

Context with Timeout

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) error {
        // Create timeout context
        timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
        defer cancel()

        select {
        case <-timeoutCtx.Done():
            return fmt.Errorf("job timeout: %w", timeoutCtx.Err())
        case <-time.After(doWork()):
            return nil
        }
    }),
    gocron.WithName("timeout-job"),
)

Cancellable Long-Running Job

ctx, cancel := context.WithCancel(context.Background())

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("Job cancelled")
                return
            default:
                if !processNextItem() {
                    return // No more items
                }
            }
        }
    }),
    gocron.WithContext(ctx),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// Later: cancel to stop the job
cancel()

Graceful Shutdown with Context

package main

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

    "github.com/go-co-op/gocron/v2"
)

func main() {
    s, _ := gocron.NewScheduler(
        gocron.WithStopTimeout(30*time.Second),
    )
    defer s.Shutdown()

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

    s.NewJob(
        gocron.DurationJob(10*time.Second),
        gocron.NewTask(func(ctx context.Context) {
            select {
            case <-ctx.Done():
                fmt.Println("Context cancelled, stopping work")
                return
            default:
                fmt.Println("Working...")
                time.Sleep(20 * time.Second)
                fmt.Println("Work complete")
            }
        }),
        gocron.WithContext(ctx),
        gocron.WithSingletonMode(gocron.LimitModeReschedule),
    )

    s.Start()

    // Wait for interrupt
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    <-sigCh

    fmt.Println("Shutting down gracefully...")
    cancel() // Cancel all job contexts
}

Monitoring Concurrency

Log Singleton Conflicts

j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute)
    }),
    gocron.WithName("monitored-job"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
    gocron.WithEventListeners(
        gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
            fmt.Printf("[%s] Starting\n", jobName)
        }),
        gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
            fmt.Printf("[%s] Completed\n", jobName)
        }),
    ),
)

Track Queue Buildup

s, _ := gocron.NewScheduler(
    gocron.WithLimitConcurrentJobs(3, gocron.LimitModeWait),
)

// Monitor queue
go func() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        queueSize := s.JobsWaitingInQueue()
        if queueSize > 10 {
            log.Printf("WARNING: Queue buildup - %d jobs waiting", queueSize)
            alertOnQueueBuildup(queueSize)
        }
    }
}()

Complete Concurrency Example

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "sync/atomic"
    "syscall"
    "time"

    "github.com/go-co-op/gocron/v2"
)

var activeJobs atomic.Int32

func main() {
    // Create scheduler with global concurrency limit
    s, err := gocron.NewScheduler(
        gocron.WithLimitConcurrentJobs(5, gocron.LimitModeReschedule),
        gocron.WithGlobalJobOptions(
            gocron.WithSingletonMode(gocron.LimitModeReschedule),
        ),
    )
    if err != nil {
        panic(err)
    }
    defer s.Shutdown()

    // Fast job with singleton
    s.NewJob(
        gocron.DurationJob(5*time.Second),
        gocron.NewTask(func() {
            activeJobs.Add(1)
            defer activeJobs.Add(-1)

            fmt.Println("Fast job running")
            time.Sleep(3 * time.Second)
        }),
        gocron.WithName("fast-job"),
    )

    // Slow job with interval from completion
    s.NewJob(
        gocron.DurationJob(10*time.Second),
        gocron.NewTask(func() {
            activeJobs.Add(1)
            defer activeJobs.Add(-1)

            fmt.Println("Slow job running")
            time.Sleep(20 * time.Second)
            fmt.Println("Slow job complete")
        }),
        gocron.WithName("slow-job"),
        gocron.WithIntervalFromCompletion(),
    )

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

    s.NewJob(
        gocron.DurationJob(15*time.Second),
        gocron.NewTask(func(ctx context.Context) {
            activeJobs.Add(1)
            defer activeJobs.Add(-1)

            select {
            case <-ctx.Done():
                fmt.Println("Cancellable job stopped")
                return
            case <-time.After(10 * time.Second):
                fmt.Println("Cancellable job complete")
            }
        }),
        gocron.WithContext(ctx),
        gocron.WithName("cancellable-job"),
    )

    // Start scheduler
    s.Start()
    fmt.Println("Scheduler started")

    // Monitor active jobs and queue
    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()

        for range ticker.C {
            active := activeJobs.Load()
            waiting := s.JobsWaitingInQueue()
            fmt.Printf("Status: %d active, %d waiting\n", active, waiting)
        }
    }()

    // Wait for interrupt
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    <-sigCh

    fmt.Println("Shutting down...")
    cancel() // Cancel context-aware jobs
}

Best Practices

  1. Use singleton mode for jobs that shouldn't overlap
  2. Set scheduler limits to prevent resource exhaustion
  3. Use interval from completion for variable-duration jobs
  4. Add context support for graceful cancellation
  5. Monitor queue size to detect bottlenecks
  6. Choose appropriate limit mode:
    • Reschedule for time-sensitive jobs (skip if too late)
    • Wait for jobs that must run (queue them)
  7. Test under load to verify concurrency behavior

Related Documentation

  • API: Job OptionsWithSingletonMode, WithIntervalFromCompletion
  • API: Scheduler OptionsWithLimitConcurrentJobs, WithGlobalJobOptions
  • Guide: Concurrency Control
  • Examples: Interval Scheduling

Install with Tessl CLI

npx tessl i tessl/golang-github-com-go-co-op-gocron-v2

docs

index.md

tile.json