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

singleton-mode.mddocs/guides/concurrency/

Singleton Mode

WithSingletonMode, LimitModeReschedule vs LimitModeWait for preventing concurrent job execution.

Overview

Singleton mode prevents a job from running concurrently with itself. When a job is already running and its next scheduled time arrives, gocron uses the LimitMode to decide what to do:

  • LimitModeReschedule: Skip the overlapping run; reschedule for next time
  • LimitModeWait: Queue the run; execute when current run completes

WithSingletonMode

func WithSingletonMode(mode LimitMode) JobOption

type LimitMode int
const (
    LimitModeReschedule LimitMode = 1
    LimitModeWait       LimitMode = 2
)

Prevents a job from running concurrently with itself:

j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(myFunc),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

LimitModeReschedule

Skip overlapping runs. Best for idempotent jobs where missing an execution is acceptable.

Basic Example

j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute) // takes 60s, interval is 30s
    }),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
// Runs at: 0s, 60s, 120s (skips runs at 30s, 90s)

Execution Timeline

0s:  Job starts
30s: Job still running → skip, reschedule for 60s
60s: Job completes, next run starts
90s: Job still running → skip, reschedule for 120s
120s: Job completes, next run starts

When to Use

Ideal for:

  • Health checks (missing one check is fine)
  • Metrics collection (gap in data is acceptable)
  • Cache refresh (stale cache for one cycle is OK)
  • Status polling
  • Idempotent operations

Example use cases:

// Health check every 30 seconds
j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(checkHealth),
    gocron.WithName("health-check"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// Cache refresh every 5 minutes
j, _ = s.NewJob(
    gocron.DurationJob(5*time.Minute),
    gocron.NewTask(refreshCache),
    gocron.WithName("cache-refresh"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// Metrics collection every minute
j, _ = s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(collectMetrics),
    gocron.WithName("metrics"),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Characteristics

Pros:

  • Predictable timing (interval-based)
  • No queue buildup
  • Resource-friendly
  • Simple to reason about

Cons:

  • May skip runs
  • Not suitable for sequential processing
  • Gaps in execution history

LimitModeWait

Queue overlapping runs. All runs execute sequentially.

Basic Example

j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute)
    }),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)
// Runs start at: 0s, then queue builds up

Execution Timeline

0s:  Job starts
30s: Job still running → queue run
60s: Job completes, queued run starts immediately
90s: Job still running → queue run
120s: Job completes, queued run starts immediately

When to Use

Ideal for:

  • Processing queues (every item matters)
  • Database migrations (all steps must execute)
  • Sequential workflows (order matters)
  • Financial transactions
  • Critical operations

Example use cases:

// Process queue items (all items matter)
j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(processQueue),
    gocron.WithName("queue-processor"),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

// Sequential database migrations
j, _ = s.NewJob(
    gocron.DurationJob(5*time.Minute),
    gocron.NewTask(runMigrations),
    gocron.WithName("migrations"),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

// Financial transactions (can't skip)
j, _ = s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(processTransactions),
    gocron.WithName("transactions"),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

Characteristics

Pros:

  • All runs execute eventually
  • No missed executions
  • Suitable for sequential work

Cons:

  • Queue can grow unbounded
  • Unpredictable timing
  • Potential memory pressure

Queue Monitoring

Monitor queue size to prevent unbounded growth:

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(slowOperation),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

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

    for range ticker.C {
        waiting := s.JobsWaitingInQueue()
        if waiting > 10 {
            log.Printf("Warning: %d jobs waiting", waiting)
        }
    }
}()

Choosing Between Modes

FactorLimitModeRescheduleLimitModeWait
Execution guaranteeMay skip runsAll runs execute
Predictable timingYes (interval-based)No (depends on queue)
Queue buildupNonePossible if jobs are slow
Idempotent jobsIdealWorks
Sequential jobsNot suitableIdeal
Use whenSkipping is acceptableEvery run matters

Decision Tree

Is every execution critical?
├─ Yes → Use LimitModeWait
│   └─ Monitor queue size
│
└─ No → Use LimitModeReschedule
    └─ Is the job idempotent?
        ├─ Yes → Perfect fit
        └─ No → Consider redesigning job

Without Singleton Mode

By default, jobs can run concurrently with themselves:

// No singleton mode
j, _ := s.NewJob(
    gocron.DurationJob(30*time.Second),
    gocron.NewTask(func() {
        time.Sleep(time.Minute) // takes 60s, interval is 30s
    }),
)
// Multiple instances run concurrently at: 0s, 30s, 60s, 90s...

Timeline:

0s:  Instance 1 starts
30s: Instance 2 starts (Instance 1 still running)
60s: Instance 1 completes, Instance 3 starts (Instance 2 still running)
90s: Instance 2 completes, Instance 4 starts (Instance 3 still running)

When acceptable:

  • Job is thread-safe
  • No shared resource access
  • Concurrent execution is desired

Monitoring and Events

ConcurrencyLimitReached Event

type SchedulerMonitor interface {
    ConcurrencyLimitReached(limitType string, job Job)
    // ... other methods
}

Called when a job cannot start due to singleton mode:

type myMonitor struct{}

func (m *myMonitor) ConcurrencyLimitReached(limitType string, job gocron.Job) {
    log.Printf("Singleton limit reached for job %s", job.Name())
    // limitType: "singleton"
}

// ... implement other SchedulerMonitor methods

s, _ := gocron.NewScheduler(
    gocron.WithSchedulerMonitor(&myMonitor{}),
)

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(slowJob),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

See Observability: Lifecycle Monitoring for details.

Advanced Patterns

Conditional Singleton Mode

Apply singleton mode only when needed:

func createJob(s gocron.Scheduler, needsSingleton bool) {
    options := []gocron.JobOption{
        gocron.WithName("my-job"),
    }

    if needsSingleton {
        options = append(options, gocron.WithSingletonMode(gocron.LimitModeReschedule))
    }

    s.NewJob(
        gocron.DurationJob(time.Minute),
        gocron.NewTask(myFunc),
        options...,
    )
}

Combining with Context

Ensure graceful cancellation:

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-ctx.Done():
                log.Println("Job cancelled, cleaning up...")
                return
            case <-ticker.C:
                doWork()
            }
        }
    }),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

Hybrid Approach

Use both modes for different jobs:

// Critical jobs: Wait mode
s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(criticalOperation),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

// Non-critical jobs: Reschedule mode
s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(statusCheck),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Best Practices

1. Choose Based on Job Characteristics

// Idempotent → Reschedule
j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(checkHealth),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// Sequential → Wait
j, _ = s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(processBatch),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

2. Monitor Queue with Wait Mode

go func() {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        waiting := s.JobsWaitingInQueue()
        if waiting > 50 {
            log.Printf("Queue backlog: %d jobs", waiting)
        }
    }
}()

3. Use Appropriate Intervals

// Good: interval > expected duration
j, _ := s.NewJob(
    gocron.DurationJob(5*time.Minute), // Job takes ~2 minutes
    gocron.NewTask(myFunc),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

// Bad: interval < expected duration (will always skip)
j, _ = s.NewJob(
    gocron.DurationJob(30*time.Second), // Job takes 2 minutes
    gocron.NewTask(slowFunc),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

4. Handle Long-Running Jobs

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) {
        select {
        case <-ctx.Done():
            return
        default:
            doWork()
        }
    }),
    gocron.WithSingletonMode(gocron.LimitModeWait),
)

5. Consider Resource Access

// Accessing database → Use singleton mode
j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(updateDatabase),
    gocron.WithSingletonMode(gocron.LimitModeReschedule),
)

Troubleshooting

Jobs Not Running

Symptom: Jobs scheduled but not executing.

Cause: Singleton mode blocking due to long-running execution.

Solution:

  • Check if previous run is still executing
  • Use LimitModeReschedule instead of LimitModeWait
  • Increase interval
  • Optimize job duration

Queue Growing Unbounded

Symptom: JobsWaitingInQueue() keeps increasing.

Cause: Jobs take longer than interval with LimitModeWait.

Solution:

  • Switch to LimitModeReschedule
  • Increase interval
  • Optimize job performance
  • Use WithIntervalFromCompletion()

Unexpected Behavior

Symptom: Jobs run at unexpected times.

Cause: LimitModeWait causing queued runs to execute immediately after completion.

Solution: Use LimitModeReschedule for predictable timing.

Related Documentation

Install with Tessl CLI

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

docs

index.md

tile.json