From Sequential Scripts to Concurrent AI Pipelines in Go — Part 9

Part 9 — Cancellation and Graceful Shutdown: Stopping Safely

Part of the series: Production-Grade Concurrent AI Systems in Go

Full code for this post: github.com/madmmas/go-concurrent-ai-systems/tree/part-09Diff from Part 8: compare/part-08...part-09Run it: go run ./cmd/news-processor inside arc-1-foundations/part-09-cancellation


Part 8 gave every article a per-article deadline. If a single LLM call hangs, the article fails fast and the worker moves on. The pipeline never blocks indefinitely on one slow provider.

But here's what Part 8 can't do: stop.

Send a SIGTERM — from Kubernetes rolling out a new deployment, from a developer hitting Ctrl+C, from an orchestrator scaling down — and ProcessAll ignores it. It keeps running until the last article completes or times out. For a large batch, that could be minutes. A Kubernetes pod that doesn't stop in time gets SIGKILLed, and work in flight disappears with no record.

This part fixes that. We wire the pipeline to OS signals, propagate cancellation through the worker pool, and build a ShutdownReport that accounts for every article — whether it succeeded, failed, was mid-flight when shutdown arrived, or was still waiting in the queue.


What Graceful Shutdown Means

"Graceful" has a precise meaning here. It does not mean "stop immediately" — that's what SIGKILL does, and it's what we're trying to avoid. It means:

  • Workers finish the task they are currently executing, up to a configured timeout
  • Articles that haven't started yet are drained from the queue and reported as not processed
  • The process exits only after every goroutine has had a chance to clean up

The result is a clear inventory: you know exactly what made it through and what didn't. That's the difference between a system you can safely operate and one that drops work silently under pressure.


Signal Handling in Go

Go's signal.NotifyContext is the cleanest way to wire OS signals to context cancellation:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

ctx is a standard context.Context. When the process receives SIGTERM or SIGINT, Go cancels ctx — exactly as if you'd called cancel() yourself. Every piece of code that holds a reference to ctx or a child of it sees ctx.Done() close and can exit cleanly.

The elegance here is that there's nothing pipeline-specific about this. The signal handling is one line at main(). The pipeline just receives a context and respects it. The two concerns are completely separate.


Two Levels of Cancellation Revisited

Part 8 introduced two context levels: per-article and pipeline-wide. Part 9 makes the pipeline-wide level real and useful.

// Pipeline-wide context — cancelled by OS signal or -cancel-after flag
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

// Per-article context — derived from the pipeline context
articleCtx, cancel := context.WithTimeout(ctx, p.ArticleTimeout)
defer cancel()

The hierarchy matters. A per-article context is a child of the pipeline context. When the pipeline context is cancelled:

  • All per-article child contexts are cancelled immediately, regardless of their own timeout
  • Workers see ctx.Done() close on the next iteration of their job loop
  • Articles in the queue are drained and marked as not started

The cancellation flows top-down through the tree automatically. You don't have to explicitly tell each worker to stop — they all inherit the signal from the root context.


The ShutdownReport

Part 8's results slice told you what happened to each article individually. Part 9 adds a ShutdownReport that gives a summary of the whole pipeline's state at the moment it stopped:

type ShutdownReport struct {
    Succeeded int  // completed successfully
    Failed    int  // timed out or hard error
    Cancelled int  // context cancelled while in-flight
    Queued    int  // never dequeued — pipeline stopped first
    Duration  time.Duration
}

The invariant that must always hold:

Succeeded + Failed + Cancelled + Queued == total input articles

This is not just a nice-to-have. If this equation breaks, work has gone missing silently — you don't know what was processed and what wasn't, and you can't safely resume or retry. Every article must produce exactly one outcome, even under abrupt cancellation. Our tests enforce this invariant explicitly across all scenarios.


Draining Queued Articles

The subtle part of graceful shutdown is what happens to articles that are still sitting in the jobs channel when cancellation arrives. Workers are checking the pipeline context at the top of their loop:

func (p *WorkerPool) worker(ctx context.Context, id int,
    jobs <-chan model.Article, results chan<- model.AIResult) {

    for article := range jobs {
        // Has the pipeline been cancelled?
        select {
        case <-ctx.Done():
            fmt.Printf("[worker %d] pipeline cancelled — skipping article %d\n",
                id, article.ID)
            results <- model.AIResult{ArticleID: article.ID, Err: ctx.Err()}
            continue  // keep draining — don't break
        default:
        }

        // Context still live — process normally
        articleCtx, cancel := context.WithTimeout(ctx, p.ArticleTimeout)
        result := p.processArticle(articleCtx, article)
        cancel()
        results <- result
    }
}

The continue instead of break is deliberate. When we detect cancellation, we don't exit the loop — we skip the LLM calls but keep pulling articles from jobs and emitting skipped results. This is what empties the queue and ensures every article eventually produces a result. Without it, articles left in jobs when the pipeline stops would never get a result entry, breaking the invariant.


What You See

Normal run — everything completes:

Pipeline: 6 articles, 3 workers, 4s per-article timeout

[worker 1] starting article 1
[worker 2] starting article 2
[worker 3] starting article 3
[worker 3] article 3: done
[worker 3] starting article 4
[worker 1] article 1: done
[worker 1] starting article 5
...
[worker 2] exiting
[worker 1] exiting
[worker 3] exiting

═════════════════════════════════════════════════════════
Succeeded : 6
Failed    : 0 (timeout/error)
Cancelled : 0 (in-flight when shutdown arrived)
Queued    : 0 (never started)
Duration  : 5.3s
═════════════════════════════════════════════════════════
Total accounted for: 6 / 6

Simulated cancellation after 1 second, with 10 articles in-flight:

Will cancel pipeline after 1s

[worker 1] starting article 1
[worker 2] starting article 2
[worker 3] starting article 3
  [1] Summarization started (1.2s)
  [2] Summarization started (900ms)
  [3] Summarization started (1.4s)

[worker 3] pipeline cancelled — skipping article 4
[worker 3] pipeline cancelled — skipping article 5
...
[worker 3] pipeline cancelled — skipping article 10
[worker 3] exiting
[worker 1] article 1: context deadline exceeded
[worker 1] exiting
[worker 2] article 2: context deadline exceeded
[worker 2] exiting

═════════════════════════════════════════════════════════
Succeeded : 0
Failed    : 0 (timeout/error)
Cancelled : 3 (in-flight when shutdown arrived)
Queued    : 7 (never started)
Duration  : 1.000s
═════════════════════════════════════════════════════════
Total accounted for: 10 / 10

Three articles were in-flight when the 1-second cancellation fired. They were mid-way through their first LLM task, got cancelled, and reported as Cancelled. Seven articles were still in the queue — they got drained by workers 3, reported as Queued, and never had any LLM calls made. The pipeline stopped in exactly 1 second. Ten in, ten accounted for.


The Real-World Scenario

Replace the -cancel-after flag with OS signal handling and this is exactly what happens when Kubernetes sends SIGTERM before rolling out a new deployment:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

_, report := pool.ProcessAll(ctx, articles)

// After SIGTERM:
// Succeeded : 847
// Cancelled :   3   (these 3 need to be retried in the next deployment)
// Queued    : 150   (these 150 were never started — resume from here)

You know exactly where to resume. Article IDs 848 through 1000 were either queued or cancelled — re-queue them. Articles 1 through 847 are done. No guessing, no re-processing things that already completed.


Arc 1 Complete

This is the final part of Arc 1. Here's what the whole arc built, and what each part added:

PartConceptWhat you couldn't do before it
1Sequential baselineNothing — this is the starting point
2Goroutines + WaitGroupProcess more than one article at a time
3MutexShare results across goroutines safely
4DeadlocksRecognise blocking failure modes before using channels
5ChannelsRemove shared memory from the design entirely
6Buffered channelsDecouple producers and consumers under burst load
7Worker poolControl how many goroutines run at once
8Context + timeoutsStop waiting for a hung provider
9Cancellation + shutdownStop the whole pipeline cleanly from the outside

The pipeline at the end of Part 9 is meaningfully different from Part 1. It's concurrent, bounded, race-free, timeout-protected, and gracefully stoppable. You could run this in production — within the limits of a single process.

Those limits are what Arc 2 addresses. Single-process pipelines have no way to spread work across machines, no way to recover from a process crash mid-batch, and no way to handle back pressure from a downstream system that's slower than the producers feeding it. Arc 2 starts with those problems: fan-out/fan-in pipelines, concurrent scraping, retries, circuit breakers, and streaming — the patterns that take a solid single-process design and make it production-grade in the distributed sense.


This is Part 9 of the series "Production-Grade Concurrent AI Systems in Go," and the final part of Arc 1 — Concurrency Foundations. Read Part 8 — Context and Timeouts or jump ahead to Arc 2 — Production Concurrent AI Systems.