0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42日でバックエンドエンジニアの基礎を完全に理解する #17 - context(タイムアウト・キャンセル)

0
Last updated at Posted at 2026-08-27

この記事で学ぶこと

#14〜#16 でレイヤードアーキテクチャが完成し、handler、usecase、repository の3層をリクエストが流れるようになりました。今回はこの構造に「時間」の軸を通します。DBが返事をくれなかったら、いつまで待つのか。クライアントがもう帰ってしまったら、続けている処理はどうするのか。

この「待つのをやめる合図」を運ぶ道具が、#11 の BeginTx で一瞬だけ登場した context です。あのとき「処理に制限時間を付ける仕組み」とだけ説明して先送りにした正体を、今日は実験で確かめながら正面から扱います。最初の実験では、死んだプログラムのために PostgreSQL が働き続ける現場を実際に目撃します。

この記事を終えると以下ができるようになります。

  • context が「この依頼はまだ生きているか」を運ぶ連絡線であることを説明できる
  • WithTimeout で処理に期限を付け、期限切れとキャンセルを ctx.Err() で見分けられる
  • クライアントの切断が handler から DB まで貫通して処理を止める仕組みを説明できる
  • タイムアウトをどの層で決めるべきか、実務の判断基準を説明できる

この回はGo編です。context はGo標準の仕組みですが、「処理に期限を付け、不要になったことを伝播させて止める」という考え方は言語を問いません。Python編では asyncio のタイムアウト・キャンセルで同じ内容を扱う予定です。

見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。


準備

コンテナを起動し、実験用のプロジェクトを作ります。

docker start pg-practice
mkdir ctx-practice && cd ctx-practice
go mod init ctx-practice
go get github.com/lib/pq

後半では #16 で完成させた layered-practice にも手を入れます。手元にない場合は、#16 の「準備」の手順で復元しておいてください。


なぜ「途中でやめる」仕組みが要るのか

レストランの喩えを続けます。客が注文を出した後、席を立って帰ってしまったとします。それに気づけるのはホール係だけです。キッチンに伝える仕組みがなければ、シェフは誰も食べない料理を作り続け、その間コンロは塞がったままです。

バックエンドで起きることも同じです。クライアントはとっくに接続を切ったのに、サーバーは重いクエリの結果を待ち続ける。#12 で学んだコネクションプールを思い出してください。SetMaxOpenConns で接続数には上限があります。誰も待っていない処理が接続を1本握り続ければ、その分だけ生きているリクエストが待たされます。遅い処理が1つあるだけで、詰まりが全体に連鎖するのが恐いところです。

まず、この問題を実際に起こしてみます。

【手を動かす①】死んだプログラムのために、DBは働き続ける

ctx-practice/main.go を作ります。context を使わず、30秒かかるクエリを投げるだけのプログラムです。

package main

import (
	"database/sql"
	"fmt"
	"log"

	_ "github.com/lib/pq"
)

func main() {
	db, err := sql.Open("postgres",
		"host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	fmt.Println("30秒かかるクエリを投げます(context なし)")
	_, err = db.Exec("SELECT pg_sleep(30)")
	fmt.Println("完了:", err)
}

pg_sleep(30) は指定秒数だけ待つPostgreSQLの関数で、遅いクエリの代役です。実行して、3秒ほど待ってから Ctrl+C でプログラムを強制終了してください。

go run .
# 「30秒かかるクエリを投げます」が出たら3秒待って Ctrl+C

クライアントであるGoのプログラムは死にました。ではDB側はどうなっているか。別ターミナルから、実行中のクエリ一覧を見る pg_stat_activity ビューを覗きます。

docker exec pg-practice psql -U postgres -d testdb \
  -c "SELECT state, now() - query_start AS running_for, query
      FROM pg_stat_activity
      WHERE query = 'SELECT pg_sleep(30)' AND state = 'active';"
 state  |  running_for   |        query
--------+----------------+---------------------
 active | 00:00:04.52781 | SELECT pg_sleep(30)
(1 row)

プログラムを殺してから数秒経っているのに、クエリは active のまま走り続けています。このまま放っておくと、誰にも結果を届けないまま30秒間働いて終わります。今回は pg_sleep なので無害ですが、これが本物の重いクエリなら、CPUと接続とロックを30秒間握り続けます。

デプロイのたびにアプリを再起動する現実の運用を想像してください。再起動の瞬間に走っていたクエリは、こうして毎回DB側に置き去りになります。クライアントが死んでも、投げてしまったクエリを止める仕組みはどこにもない。これが context なしの世界です。


🔴 context は「この依頼はまだ生きているか」を運ぶ連絡線

Goの答えが context.Context です。正体は小さなインターフェースで、役割は1つに要約できます。「この依頼はまだ続けてよいか、それとももう不要か」という情報を、処理に関わる全員へ配ることです。

レストランでいえば、注文伝票に付いた連絡線です。伝票が生きている限り調理は続けてよく、客が帰るかキャンセルの合図が出たら、伝票を受け取った全員(ホール係もシェフも仕入れ担当も)が同じ合図を見て手を止めます。大事なのは、1つの依頼に1本の連絡線が対応していて、担当者は受け取った線を次の担当者へそのまま回すことです。

【手を動かす②】期限切れとキャンセルを観察する

main.go を次の内容に入れ替えます。DBはまだ使いません。

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	// 2秒の期限付きcontextを作る
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	fmt.Println("作った直後 :", ctx.Err())

	time.Sleep(3 * time.Second)

	fmt.Println("3秒後     :", ctx.Err())
}
作った直後 : <nil>
3秒後     : context deadline exceeded

部品を1つずつ見ていきます。

context.Background() は根っこの context です。中身は空で、期限もなく、決してキャンセルされません。「何の制約もない依頼」を表す出発点です。

context.WithTimeout(親, 期間) は、親の context から期限付きの子を作って返します。戻り値は2つで、1つ目が子の context 本体、2つ目が cancel という関数です。

ctx.Err() は連絡線の状態確認です。依頼が生きている間は nil を返し、期限が切れると context.DeadlineExceeded を返します。上の実行結果はまさにその変化で、作った直後は <nil>、期限の2秒を過ぎた後は context deadline exceeded になっています。

defer cancel() は必ず書きます。WithTimeout は裏でタイマーと見張り役を動かしていて、cancel はその後片付けです。呼ばないと、処理が期限より早く終わった場合でも、親が生きている限り見張りが残り続けます。サーバーでは親はたいていプロセスと同寿命なので、リクエストのたびにゴミが積もることになります。書き忘れは go vet が検出してくれます。試しに cancel_ で捨ててみたら、こう叱られました。

./main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

次に、期限ではなく手動のキャンセルです。もう1つ大事な性質も一緒に確かめます。main.go を入れ替えてください。

package main

import (
	"context"
	"fmt"
)

func main() {
	parent, cancelParent := context.WithCancel(context.Background())
	child, cancelChild := context.WithCancel(parent)
	defer cancelChild()

	fmt.Println("親をキャンセルする前の子:", child.Err())

	cancelParent() // 親だけをキャンセルする

	fmt.Println("親をキャンセルした後の子:", child.Err())
}
親をキャンセルする前の子: <nil>
親をキャンセルした後の子: context canceled

キャンセルしたのは親だけなのに、子の Err()context canceled に変わりました。context は親から子への木構造になっていて、キャンセルと期限切れは親から子へ自動的に伝わります(逆方向には伝わりません。子を切っても親は無事です)。

この性質が、後で3層貫通の種明かしになります。「リクエスト全体」を親に、その中の一部の処理に短い期限の子を作る。親であるリクエストが死ねば、そこから派生した処理はすべて道連れで止まる。連絡線が1本の木として繋がっているからできることです。

ctx.Err() の返り値も整理しておきます。期限切れなら context.DeadlineExceeded、キャンセルなら context.Canceled。この2つの見分けは後半で効いてきます。

⚪ なお、context には Done() というメソッドもあり、「キャンセルされた瞬間に通知を受け取る」用途で使います。戻り値がチャネルというまだ学んでいない型なので、今日は使いません。#19 で正体がわかったときに「あれはこういうことだったのか」と繋がります。


【手を動かす③】DBクエリに期限を付ける

道具は揃ったので、①の問題に戻ります。#4 からずっと使ってきた QueryExec には、実は context を受け取る兄弟がいます。QueryContextExecContextQueryRowContext です。context なしの版は、内部で context.Background() を渡しているだけの互換用で、つまり「決して打ち切られない依頼」としてクエリを投げていたわけです。

期限付きでクエリを投げてみます。main.go を入れ替えてください。

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"time"

	_ "github.com/lib/pq"
)

func main() {
	db, err := sql.Open("postgres",
		"host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	start := time.Now()
	_, err = db.ExecContext(ctx, "SELECT pg_sleep(5)")
	fmt.Printf("経過時間: %.1f秒\n", time.Since(start).Seconds())
	fmt.Println("エラー:", err)
	fmt.Println("errors.Is(err, context.DeadlineExceeded):", errors.Is(err, context.DeadlineExceeded))
	fmt.Println("ctx.Err():", ctx.Err())
}

5秒かかるクエリに2秒の期限。結果はこうなりました。

経過時間: 2.0秒
エラー: pq: canceling statement due to user request (57014)
errors.Is(err, context.DeadlineExceeded): false
ctx.Err(): context deadline exceeded

5秒待たされるはずが、きっちり2.0秒で打ち切られました。#11 で見たのと同じエラーです。canceling statement due to user request はPostgreSQL側のメッセージで、「ユーザー(ここではGoのドライバ)の要求でSQLを中断した」という意味です。期限が切れた瞬間、ドライバがPostgreSQLへキャンセル要求を送り、DB側でもクエリが止まります。①と違って、アプリだけでなくDBも守られているのがポイントです。実際、この直後に pg_stat_activity を覗いても、走り続けるクエリは残っていませんでした。

🟡 タイムアウトのエラーは2つの顔で返ってくる

上の出力で、私は1か所つまずきました。3行目です。タイムアウトしたのだから errors.Is(err, context.DeadlineExceeded) は当然 true だと思い込んでいたのに、実測は false でした。

理由は返ってきたエラーの出どころにあります。クエリの実行中に期限が切れた場合、返るのはPostgreSQLのエラー(コード 57014)をドライバがそのまま包んだもので、この中に context.DeadlineExceeded は入っていません。だから errors.Is は繋がりを見つけられません。

ややこしいことに、タイミングが違うと顔も変わります。期限がすでに切れた後でクエリを投げようとした場合は、database/sql がDBに問い合わせる前に打ち切るので、context.DeadlineExceeded がそのまま返ります。これも実測しました。

	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancel()
	time.Sleep(10 * time.Millisecond) // 先に期限を切らしてから

	_, err = db.ExecContext(ctx, "SELECT 1")
期限切れ後に投げた場合: context deadline exceeded
errors.Is(err, context.DeadlineExceeded): true

同じ「タイムアウト」なのに、切れた瞬間がクエリの前か途中かでエラーの型が変わる。つまり タイムアウトしたかどうかをエラーの型で判定するのは不安定です。確実な方法は、エラーが返ってきたときに自分の手元の ctx.Err() を確認することです。連絡線の状態は出どころに関係なく一貫しています。上の実測でも、エラーの顔が2通りに割れる一方で ctx.Err() は常に context deadline exceeded でした。この判定は最後の実技問題で使います。


【手を動かす④】キャンセルを3層に貫通させる

いよいよ本題です。#16 で完成させた layered-practice に context を通します。目標は、①で見た「死んだクライアントのために働き続けるDB」を、レイヤードアーキテクチャのまま解決することです。

方針は1つだけです。ctx を第一引数で受け取り、次の層へそのまま渡す。Goの標準的な作法として、context は構造体に隠さず、関数の第一引数 ctx として明示的にリレーします。

layered-practice に移動して、データを整えておきます。

docker exec -it pg-practice psql -U postgres -d testdb \
  -c "TRUNCATE users RESTART IDENTITY;"

repository。QueryRowContext に持ち替える

repository/user.go の3メソッドすべてに ctx を通します。変更は「第一引数に ctx context.Context を足す」「QueryRowQueryRowContext に替える」の2点だけです。全文を載せます。

package repository

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

// ErrNotFound は探したデータが存在しなかったことを表す
var ErrNotFound = errors.New("not found")

// User はusersテーブル1行分のデータ
type User struct {
	ID    int
	Name  string
	Email string
}

type UserRepository struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
	return &UserRepository{db: db}
}

// ExistsByEmail は同じメールアドレスのユーザーがいるかを返す
func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
	var exists bool
	err := r.db.QueryRowContext(ctx,
		"SELECT EXISTS (SELECT 1 FROM users WHERE email = $1)", email).Scan(&exists)
	if err != nil {
		return false, fmt.Errorf("ユーザーの存在確認に失敗しました: %w", err)
	}
	return exists, nil
}

// Create はユーザーを保存し、採番されたIDを詰めて返す
func (r *UserRepository) Create(ctx context.Context, name, email string) (*User, error) {
	u := &User{Name: name, Email: email}
	err := r.db.QueryRowContext(ctx,
		"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
		name, email).Scan(&u.ID)
	if err != nil {
		return nil, fmt.Errorf("ユーザーの保存に失敗しました: %w", err)
	}
	return u, nil
}

// FindByID はIDでユーザーを1件取得する。いなければ ErrNotFound を返す
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx,
		"SELECT id, name, email FROM users WHERE id = $1", id).
		Scan(&u.ID, &u.Name, &u.Email)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("ユーザーの取得に失敗しました: %w", err)
	}
	return &u, nil
}

usecase。受け取って、渡すだけ

usecase/user.go では、interface と2つのメソッドに ctx を足します。importに "context" を追加し、次の箇所を差し替えてください(validateUser は変更なしです)。

// UserRepository は usecase が保存係に求める仕事の一覧
type UserRepository interface {
	ExistsByEmail(ctx context.Context, email string) (bool, error)
	Create(ctx context.Context, name, email string) (*repository.User, error)
	FindByID(ctx context.Context, id int) (*repository.User, error)
}
// Register は入力を検証し、重複がなければユーザーを登録する
func (u *UserUsecase) Register(ctx context.Context, name, email string) (*repository.User, error) {
	if err := validateUser(name, email); err != nil {
		return nil, err
	}
	exists, err := u.repo.ExistsByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	if exists {
		return nil, ErrEmailTaken
	}
	return u.repo.Create(ctx, name, email)
}

// GetUser はIDでユーザーを1件取得する
func (u *UserUsecase) GetUser(ctx context.Context, id int) (*repository.User, error) {
	return u.repo.FindByID(ctx, id)
}

usecase は ctx に対して何もしていないことに注目してください。期限を見ることも、キャンセルすることもなく、受け取った連絡線を repository へそのまま回しているだけです。この「素通し」が手抜きではなく正解である理由は、実験の後で話します。

handler。リクエストの連絡線を取り出す

では連絡線の大元はどこから来るのか。答えは http.Request が持っています。r.Context() はこのリクエスト1本に対応する context を返し、クライアントが接続を切るとnet/httpが自動でキャンセルします。②で学んだ木構造でいえば、リクエストの context を親として、そこから先の処理すべてがぶら下がる形です。

handler/user.go の変更は2行だけです。usecase の呼び出しに r.Context() を渡します。

	// Register 内
	user, err := h.uc.Register(r.Context(), req.Name, req.Email)
	// Get 内
	user, err := h.uc.GetUser(r.Context(), id)

最後に usecase/user_test.go です。interface が変わったので、偽物の fakeUserRepo も追随しないとコンパイルが通りません。3メソッドの第一引数に ctx context.Context を足し(中身では使いません)、テスト本体では ctx := context.Background() を作って各呼び出しに渡します。テストに特別な期限は不要なので、根っこをそのまま渡せば十分です。

func (f *fakeUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
	_, ok := f.users[email]
	return ok, nil
}

この改修、私は repository から順に直しましたが、途中は常にどこかがコンパイルエラーです。それで正しい進め方です。シグネチャの変更は、直し漏れをコンパイラが全部列挙してくれるので、赤字が消えるまで機械的に潰していけば終わります。全部直したら確認します。

go test ./usecase/
go run .
ok  	layered-practice/usecase	0.002s

サーバーを起動したら、別ターミナルで動作が変わっていないことも見ておきます。ユーザーを1件登録して、取得します。

curl -s -w '\nstatus=%{http_code}\n' -X POST localhost:8080/users \
  -d '{"name":"佐藤","email":"sato@example.com"}'
curl -s -w '\nstatus=%{http_code}\n' localhost:8080/users/1
{"id":1,"name":"佐藤"}
status=201
{"email":"sato@example.com","id":1,"name":"佐藤"}
status=200

#16 と同じ挙動のままです。配管を全部替えたのに、水の流れは変わっていません。

実験。客が帰ったら、キッチンは止まるか

仕込みとして、repository/user.goFindByID のSQLに一時的に pg_sleep(3) を混ぜ、3秒かかる遅いクエリに変えます。あわせて handler/user.goGet に開始ログを1行足します。どちらも実験用で、後で戻します。

	// repository/user.go の FindByID(実験用に3秒待たせる)
	err := r.db.QueryRowContext(ctx,
		"SELECT id, name, email FROM users, pg_sleep(3) WHERE id = $1", id).
		Scan(&u.ID, &u.Name, &u.Email)
	// handler/user.go の Get。usecase 呼び出しの直前に
	log.Println("GetUser 開始")
	user, err := h.uc.GetUser(r.Context(), id)

サーバーを再起動して、まず普通に叩きます。

curl -s -w '\nstatus=%{http_code} time=%{time_total}s\n' localhost:8080/users/1
{"email":"sato@example.com","id":1,"name":"佐藤"}
status=200 time=2.945964s

クライアントが待ち続ける限り、3秒かかっても正常に返ります。次が本番です。curl に --max-time 1 を付けて、1秒で諦めて接続を切るクライアントを演じます。

curl -s --max-time 1 localhost:8080/users/1 ; echo "curl exit=$?"
curl exit=28

curl は1秒でタイムアウトして去りました(exit 28 は curl のタイムアウトを表す終了コードです)。サーバー側のログを見ます。

2026/08/25 09:43:14 GetUser 開始
2026/08/25 09:43:15 ユーザーの取得に失敗しました: pq: canceling statement due to user request (57014)

タイムスタンプに注目してください。開始からエラーまで1秒です。クエリは3秒かかるはずでした。クライアントが切断した瞬間にキャンセルが走り、2秒分の無駄な仕事が省かれています。pg_stat_activity も確認しましたが、走り続けるクエリはいませんでした。①で見た置き去りクエリ問題が、消えています。

起きたことを登場人物の順に並べます。

  1. クライアントが接続を切る
  2. net/http が r.Context() をキャンセルする
  3. その同じ context が usecase を素通りして QueryRowContext に渡っているので、ドライバが検知してPostgreSQLへキャンセル要求を送る
  4. PostgreSQLがクエリを中断し、エラーがrepositoryから返ってくる(handler は500を返そうとしますが、書き込む相手はもういません)

3層の誰も「切断を検知するコード」を書いていません。handler は r.Context() を取り出して渡しただけ、usecase は素通し、repository は QueryRowContext に渡しただけです。連絡線を切らさずリレーしてさえいれば、キャンセルは末端まで勝手に届く。これが context の設計です。逆にいえば、途中の誰かが横着して context.Background() を渡した瞬間、そこから先には何も伝わらなくなります。素通しは手抜きではなく、責務です。

実験用に足した pg_sleep(3) と開始ログは、この後の実技問題でもう一度使うので、まだ残しておいてください。


実務での判断基準

🔴 ctx は第一引数。受け取って、渡す

Goの世界には「context は関数の第一引数、名前は ctx」という強い慣習があり、標準ライブラリも主要なライブラリもこれに従っています。構造体のフィールドに入れて持ち回るのは避けます。context はリクエスト1本ごとに違うものなので、リクエストをまたいで生きる構造体(handler や repository 本体)に持たせると、別の依頼の連絡線が混線するからです。

そして今日いちばん伝えたい実務の型はこれです。下層の関数を書くときは、使う予定がなくても最初から ctx を受け取っておく。今回の改修で体験したとおり、後から ctx を通す作業はシグネチャ変更の連鎖になり、interface も偽物も呼び出し側も全部に波及します。今日は3メソッドだから数分で済みましたが、実務のコードベースで何百関数に後付けするのは大工事です。新規のコードなら最初の1行で済みます。

🔴 外部を待つ処理には、必ず期限を付ける

DB、外部API、キャッシュ。ネットワークの向こう側を待つ処理は、すべて「永遠に返ってこない可能性」があります。期限のない待ちは、相手の障害を自分の障害として無限に輸入する行為です。

特に警戒すべきなのが http.Client で、ゼロ値のまま使うとタイムアウトは無制限です。外部APIを呼ぶときは http.Client{Timeout: ...} を設定するか、http.NewRequestWithContext でリクエストに context を紐付けます。#11 の鉄則「外部APIをトランザクションの中で呼ばない」と合わせて、「外部を無期限で待たない」も体に入れてください。障害対応の現場で「原因は無期限の待ちが詰まったこと」というのは、本当によくある結末です。

🟡 期限は入口で決め、途中の層は素通しする

ではタイムアウトの WithTimeout はどの層に書くべきか。原則は入口(handlerかその手前)で全体の予算を決め、下の層は素通しです。

理由は、予算がリクエスト単位の関心事だからです。「この API は2秒で返す」という約束はエンドポイントの仕様であって、repository の知ったことではありません。もし repository が勝手に「クエリは1秒まで」と決めてしまうと、バッチ処理から同じ repository を使いたいとき(10分かかってよい場面)に邪魔になります。②で見た木構造のおかげで、入口で作った期限は下の全処理に自動で効くので、下層が自衛する必要はありません。

⚪ 今日は流してよいもの

context にはもう1つ、context.WithValue でリクエストに紐づく値(認証済みユーザーIDなど)を運ぶ機能があります。乱用すると「どこで何が詰められたか分からない袋」になるので、使い所は限られます。#27 の認証ミドルウェアで、正しい使い方を実物で扱います。

また、DB側にも statement_timeout という自衛の設定があり、アプリが行儀悪くてもDBが自分でクエリを打ち切れます。多層防御として実務では併用されますが、存在だけ知っていれば今は十分です。


確認テスト

知識問題

Q1. db.Querydb.QueryContext の違いを説明してください。また、context なしの版だけでできているシステムで「アプリの再起動」が繰り返されると、DB側に何が積み上がるか。①の実験結果を根拠に説明してください。

Q2. context.WithTimeout を使うとき defer cancel() を書くのはなぜですか。書き忘れると何が起き、書き忘れをどうやって機械的に見つけられますか。

Q3. DBクエリがタイムアウトで打ち切られたのに、errors.Is(err, context.DeadlineExceeded) が false になることがあります。なぜ起きるのか、そしてタイムアウトを確実に判定するにはどうすればよいかを説明してください。

Q4. ④の実験で、クライアントが切断してからDBのクエリが止まるまでに何が起きたかを、handler、usecase、repository、ドライバ、PostgreSQL の登場人物で順に説明してください。usecase は context に対して何のコードも書いていないのに、なぜキャンセルが貫通したのかも述べること。

実技問題

Q5. GET /users/{id} に「2秒以内に返せなければ 504 Gateway Timeout を返す」という仕様を追加してください。④の実験で仕込んだ pg_sleep(3) 入りの FindByID のまま動作確認し、504 が実測できたら仕込みを元に戻すこと。ヒント:タイムアウトの判定方法は Q3 の答えを使います。


確認テスト 解答・解説

Q1

db.Query は内部で context.Background() を使う互換用の版で、db.QueryContext はキャンセルや期限を運ぶ context を明示的に渡せる版です。Background() は決してキャンセルされないので、context なしの版で投げたクエリは外から打ち切る手段が存在しません

①の実験では、プログラムをCtrl+Cで殺した数秒後も、pg_stat_activity にクエリが active のまま残っていました。クライアントの死は、それだけではDBに伝わらないということです。再起動が繰り返されるシステムでは、再起動の瞬間に走っていたクエリが毎回こうして置き去りになり、誰にも結果を届けないままCPUと接続とロックを消費します。ロックを持ったままの置き去りクエリがいれば、#9 で学んだロック待ちの連鎖で、生きているリクエストまで巻き添えになります。

Q2

WithTimeout は期限を見張るためのタイマーを裏で動かしており、cancel はその見張りを解散させる後片付けだからです。処理が期限より早く終わった場合、cancel を呼ばなければ見張りは期限まで(親が先に死ななければ)働き続けます。サーバーでは親が Background() 系でプロセスと同寿命なことが多く、リクエストのたびに解放されない見張りが積もっていきます。defer cancel() と書いておけば、関数を抜けるとき必ず片付きます。

書き忘れは go vetlostcancel という検査で見つけてくれます。実際に cancel を捨てたコードに掛けると the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak と指摘されました。CIに go vet を入れておけば、人間が目視で探す必要はありません。

Q3

エラーの出どころが2通りあるからです。期限がクエリ実行中に切れた場合、ドライバはPostgreSQLへキャンセル要求を送り、PostgreSQL側のエラー(canceling statement due to user request、コード 57014)が返ります。これは context.DeadlineExceeded を包んでいないので、errors.Is は false になります。一方、期限がクエリを投げる前にすでに切れていた場合は、database/sql がDBへ行かずに context.DeadlineExceeded をそのまま返すので true になります。同じタイムアウトでも、切れたタイミングでエラーの顔が変わるわけです。

確実なのは、返ってきたエラーの型を当てにせず、自分の手元の ctx.Err() を見ることです。エラーが返ってきて、かつ ctx.Err()context.DeadlineExceeded なら、原因は期限切れだと判定できます。連絡線の状態は、エラーがどの経路で作られたかに関係なく一貫しています。

Q4

経路は次のとおりです。

  1. クライアントが接続を切る
  2. net/http がそのリクエストの r.Context() をキャンセルする(ここはGoの標準ライブラリの仕事で、自分では何も書いていません)
  3. handler が渡し、usecase が素通しした ctx は、この r.Context()同じ1本の連絡線なので、repository の QueryRowContext の見張りに即座に伝わる
  4. ドライバがPostgreSQLへキャンセル要求を送り、PostgreSQLがクエリを中断する。エラーが repository から usecase、handler へ返る(handler は500を書こうとするが、相手はもういない)

usecase に何のコードも要らなかったのは、context の設計が「各層が連絡線を加工する」のではなく「同じ1本を受け渡す」形だからです。キャンセルの検知は線の末端(ドライバ)が受け持ち、途中の層の仕事は線を切らさないことだけです。だからこそ、途中の誰かが context.Background() に差し替えると、そこから先は永遠にキャンセルを知れなくなります。

Q5

書く場所は handler/user.goGet です。「このAPIは2秒で返す」はエンドポイントの仕様なので、入口である handler が決めます。r.Context() を親に、2秒の子contextを作って usecase へ渡します。importに "context""time" を足してください。

	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	user, err := h.uc.GetUser(ctx, id)
	switch {
	case errors.Is(err, repository.ErrNotFound):
		http.Error(w, "user not found", http.StatusNotFound)
		return
	case err != nil && errors.Is(ctx.Err(), context.DeadlineExceeded):
		http.Error(w, "timeout", http.StatusGatewayTimeout)
		return
	case err != nil:
		log.Println(err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

pg_sleep(3) 入りのまま実測しました。

curl -s -w '\nstatus=%{http_code} time=%{time_total}s\n' localhost:8080/users/1
timeout
status=504 time=2.025708s

3秒クエリが2.0秒で打ち切られ、504が返りました。

ポイント

  • タイムアウト判定は Q3 のとおり ctx.Err() で行う。errors.Is(err, context.DeadlineExceeded) だと、クエリ実行中に切れたケース(今回まさにそれ)を取りこぼして500になってしまう
  • ステータスは 504 Gateway Timeout。「サーバーの背後(ここではDB)が時間内に応答しなかった」を表す定番で、入力が悪い4xx系とも、バグの500とも区別できる
  • 親を Background() ではなく r.Context() にしたので、「2秒の期限」と「クライアント切断」の早い方で処理が止まる。④の貫通も生きたまま

確認できたら、FindByID のSQLと開始ログを元に戻してサーバーを再起動しておいてください。


まとめ

  • context は「この依頼はまだ生きているか」を運ぶ連絡線。期限切れとキャンセルは親から子の木構造を自動で伝わり、状態は ctx.Err() で確認できる
  • context なしの待ちは打ち切る手段がない。実験では、プログラムを殺した後もDBのクエリは active のまま走り続けた
  • r.Context() を第一引数で素通しするだけで、クライアントの切断が3層を貫通してDBのクエリまで止めた。素通しは手抜きではなく、連絡線を切らさない責務
  • タイムアウトのエラーは、切れたタイミングでPostgreSQL由来と context 由来の2つの顔になる。判定は ctx.Err() で行う
  • 期限は入口の層で決め、下層は素通しする。外部を待つ処理に無期限は禁物で、http.Client のゼロ値は無制限なことも忘れない

次回は #18 並行処理① goroutine です。サーバーが遅いリクエストを1本処理している間、他のリクエストはなぜ待たされずに済むのか。実は今日のサーバーもとっくに並行で動いています。その裏側にいる goroutine を、自分の手で起動するところから始めます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?