Context, Cancellation, and Pool Tuning
Every database method in this book so far has taken a plain db.Exec or
db.QueryRow — no context.Context anywhere. That was deliberate: the
transaction lesson had enough to teach without it. But the web-services book
already settled how a request's cancellation should be handled — check
ctx.Err() and stop, rather than doing work nobody's waiting for — and a
database call is exactly the kind of work that idiom exists for. A
transaction sitting open while its caller has already given up isn't just
wasted effort; it's holding a connection out of a pool other requests need.
if err := ctx.Err(); err != nil {
return fmt.Errorf("archive task %d: %w", id, err)
}
That check happens before db.BeginTx — before anything is opened, before
any connection is claimed. There's no cleanup to do because nothing started.
This is the cheapest possible response to cancellation: recognizing it before
paying for the work at all, not partway through and having to unwind.
Passing ctx through what does run
tx, err := db.BeginTx(ctx, nil)
tx.QueryRowContext(ctx, `SELECT title FROM tasks WHERE id = ?`, id)
tx.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
The upfront check catches cancellation that already happened. It can't catch
cancellation that happens during the transaction — the caller's deadline
passing while a statement is mid-flight. That's what the Context variants
are for: BeginTx, QueryRowContext, ExecContext all watch the same
ctx and can abort a statement already in progress, the same as
http.Client or any other stdlib API that takes a context. Passing ctx in
is not optional plumbing — it's the only thing that makes cancellation cover
the whole operation, not just its first instant.
Pool tuning, briefly
:memory: with SetMaxOpenConns(1) sidesteps a question a real database
forces you to answer: how many connections should the pool actually hold?
SetMaxOpenConns caps it — too high and a bursty deploy can exhaust the
database's own connection limit; too low and requests queue behind each
other for no reason. SetConnMaxLifetime closes and reopens connections
periodically, which matters behind a load balancer that drops long-lived TCP
connections without warning. Neither has a universal right answer — both are
tuned against the database and traffic you actually have, not set once from
a blog post.
Try it
Implement ArchiveTaskCtx. It refuses to touch the database if the context
is already done, and passes the context through every statement it does run.
Practice
1. Archive a Task, Cancellation-Aware
IntermediateImplement
ArchiveTaskCtx(ctx context.Context, db *sql.DB, id int64) error.Same behavior as
ArchiveTaskfrom the previous lesson — move a row fromtaskstoarchived_tasksin one transaction,ErrNotFoundif it's missing — but context-aware:- If
ctxis already cancelled or past its deadline when the function is called, return that error wrapped, without opening a transaction at all. - Otherwise use
db.BeginTx(ctx, nil)and the...Contextvariants (QueryRowContext,ExecContext) for every statement, so the context is honored throughout, not just at the start.
package main import ( "context" "database/sql" "errors" "fmt" _ "modernc.org/sqlite" ) var ErrNotFound = errors.New("task not found") // ArchiveTaskCtx moves a task from tasks into archived_tasks, atomically, // honoring ctx cancellation throughout. func ArchiveTaskCtx(ctx context.Context, db *sql.DB, id int64) error { // TODO: implement return fmt.Errorf("not implemented") } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- If