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

graceful-shutdown.mddocs/guides/advanced/

Graceful Shutdown

Guide to implementing clean shutdown procedures for gocron applications.

Overview

Graceful shutdown ensures:

  • Running jobs complete or are cancelled cleanly
  • Resources are released properly
  • No data loss or corruption
  • Clean application exit

Basic Shutdown

Simple Shutdown

s, _ := gocron.NewScheduler()
defer s.Shutdown()

// Add jobs...

s.Start()
select {} // block

With Signal Handling

s, _ := gocron.NewScheduler()
defer s.Shutdown()

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

// Add jobs...

s.Start()

<-ctx.Done() // Wait for signal
log.Println("Shutting down...")

if err := s.Shutdown(); err != nil {
    log.Printf("Shutdown error: %v", err)
}

Shutdown Timeout

Configure how long to wait for running jobs:

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

Behavior:

  • Waits up to 30 seconds for running jobs to complete
  • Jobs exceeding timeout receive context cancellation
  • Scheduler exits after timeout regardless

Context-Aware Jobs

Jobs should respect context cancellation:

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                log.Println("Job cancelled, cleaning up...")
                cleanup()
                return
            default:
                doWork()
                time.Sleep(time.Second)
            }
        }
    }),
)

Long-Running Jobs

Checkpoint and Resume

j, _ := s.NewJob(
    gocron.DurationJob(time.Hour),
    gocron.NewTask(func(ctx context.Context) error {
        for i := 0; i < 1000; i++ {
            select {
            case <-ctx.Done():
                // Save checkpoint
                saveCheckpoint(i)
                return ctx.Err()
            default:
                processItem(i)
            }
        }
        return nil
    }),
)

Graceful Work Completion

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) error {
        // Start work
        workChan := make(chan workItem, 100)
        go produceWork(ctx, workChan)

        for {
            select {
            case <-ctx.Done():
                // Finish current batch
                drainQueue(workChan)
                return ctx.Err()
            case item := <-workChan:
                processItem(item)
            }
        }
    }),
)

Cleanup Resources

Database Connections

type jobContext struct {
    db *sql.DB
}

func (c *jobContext) Close() error {
    return c.db.Close()
}

// Setup
jc := &jobContext{db: openDB()}
defer jc.Close()

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func() {
        // Use jc.db
    }),
)

File Handles

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) error {
        f, err := os.Open("data.txt")
        if err != nil {
            return err
        }
        defer f.Close()

        // Process file
        for {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
                // Read and process
            }
        }
    }),
)

Network Connections

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) error {
        conn, err := net.Dial("tcp", "example.com:80")
        if err != nil {
            return err
        }
        defer conn.Close()

        // Use connection
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            // Network operations
        }

        return nil
    }),
)

Shutdown Patterns

Two-Phase Shutdown

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

    // Add jobs...

    s.Start()

    // Phase 1: Stop accepting new work
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    <-ctx.Done()
    log.Println("Stopping scheduler...")

    // Phase 2: Wait for completion
    if err := s.Shutdown(); err != nil {
        log.Printf("Shutdown error: %v", err)
    }

    // Phase 3: Cleanup
    log.Println("Cleaning up resources...")
    cleanup()

    log.Println("Shutdown complete")
}

Parallel Shutdown

func main() {
    s1, _ := gocron.NewScheduler()
    s2, _ := gocron.NewScheduler()

    // Add jobs to both schedulers...

    s1.Start()
    s2.Start()

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    <-ctx.Done()

    // Shutdown both in parallel
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        defer wg.Done()
        if err := s1.Shutdown(); err != nil {
            log.Printf("S1 shutdown error: %v", err)
        }
    }()

    go func() {
        defer wg.Done()
        if err := s2.Shutdown(); err != nil {
            log.Printf("S2 shutdown error: %v", err)
        }
    }()

    wg.Wait()
    log.Println("All schedulers shutdown")
}

Forced Shutdown

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

    // Add jobs...

    s.Start()

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    <-ctx.Done()

    // Try graceful shutdown with timeout
    done := make(chan bool)
    go func() {
        if err := s.Shutdown(); err != nil {
            log.Printf("Shutdown error: %v", err)
        }
        done <- true
    }()

    select {
    case <-done:
        log.Println("Graceful shutdown complete")
    case <-time.After(60 * time.Second):
        log.Println("Shutdown timeout, forcing exit")
        os.Exit(1)
    }
}

Health Checks During Shutdown

var shuttingDown atomic.Bool

func main() {
    s, _ := gocron.NewScheduler()

    // Health endpoint
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        if shuttingDown.Load() {
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte("shutting down"))
            return
        }
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    })

    go http.ListenAndServe(":8080", nil)

    // Add jobs...

    s.Start()

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    <-ctx.Done()

    // Mark as shutting down
    shuttingDown.Store(true)

    // Wait for health checks to update
    time.Sleep(time.Second)

    // Shutdown
    s.Shutdown()
}

Kubernetes Integration

PreStop Hook

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "sleep 10"]

Application Code

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

    // Add jobs...

    s.Start()

    // Handle SIGTERM (K8s sends this)
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM)
    defer cancel()

    <-ctx.Done()
    log.Println("Received SIGTERM, shutting down...")

    if err := s.Shutdown(); err != nil {
        log.Printf("Shutdown error: %v", err)
        os.Exit(1)
    }

    log.Println("Shutdown complete")
}

Testing Shutdown

func TestGracefulShutdown(t *testing.T) {
    s, _ := gocron.NewScheduler(
        gocron.WithStopTimeout(5*time.Second),
    )

    var executed atomic.Bool
    var cleaned atomic.Bool

    j, _ := s.NewJob(
        gocron.DurationJob(100*time.Millisecond),
        gocron.NewTask(func(ctx context.Context) {
            executed.Store(true)
            time.Sleep(time.Second)

            select {
            case <-ctx.Done():
                cleaned.Store(true)
            default:
            }
        }),
    )

    s.Start()
    time.Sleep(200 * time.Millisecond) // Let job start

    err := s.Shutdown()
    assert.NoError(t, err)
    assert.True(t, executed.Load())
    assert.True(t, cleaned.Load())
}

Best Practices

1. Always Set Timeout

s, _ := gocron.NewScheduler(
    gocron.WithStopTimeout(30*time.Second),
)

2. Use Context in Jobs

gocron.NewTask(func(ctx context.Context) {
    // Check ctx.Done()
})

3. Handle Cleanup

j, _ := s.NewJob(
    gocron.DurationJob(time.Minute),
    gocron.NewTask(func(ctx context.Context) error {
        defer cleanup()

        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            return doWork()
        }
    }),
)

4. Log Shutdown Events

<-ctx.Done()
log.Println("Shutdown initiated")

if err := s.Shutdown(); err != nil {
    log.Printf("Shutdown error: %v", err)
} else {
    log.Println("Shutdown complete")
}

5. Test Shutdown Scenarios

Test graceful shutdown, timeout scenarios, and cleanup logic.

Common Issues

Jobs Don't Stop

Cause: Jobs don't check context.

Solution: Always check ctx.Done() in loops.

Shutdown Hangs

Cause: Jobs blocking indefinitely.

Solution: Set appropriate timeout.

Resource Leaks

Cause: Missing cleanup in jobs.

Solution: Use defer for cleanup.

See Also

  • Lifecycle Guide - Job lifecycle
  • API: Scheduler Lifecycle - Shutdown API
  • Advanced Patterns Guide - Production best practices

Install with Tessl CLI

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

docs

index.md

tile.json