Day 075 — 2026-10-15 | ミドルウェア設計パターン: 応答後の仕事を「止めずに止める」——Detach を (*Edge).Go に育て、srv.Shutdown → 仕事の待ち → 期限で切る、の順を Serve の中に固定する(逆順・RegisterOnShutdown・ErrServerClosed で抜ける、の 3 通りは 50 件の監査を 1 件も残さなかった)

2026-10-15 🟡 上級者 / Phase 3 実装 ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: ミドルウェア設計パターン
Day 074 の次回予告どおり「実装」。Day 074 の Detach(ctx, timeout) は「応答後の仕事に渡す ctx」だけを用意し、その仕事が終わるまでプロセスが生きているかは何も保証していなかった。デプロイのたびに Pod は SIGTERM を受けて止まる。今日は、応答後の仕事を (*Edge).Go で数え、停止のときに「新しい要求を断る → 処理中の要求を待つ → 応答後の仕事を待つ → 期限が来たら切る」を 1 本の Serve に閉じる。テスト 15 本・変異 18 通り・止める順 4 通りの実測で確かめる。

📚 背景知識(読んでから問題へ)

ここまでの Edge(Day 074)と、今日足す所

Day 074:  edge.New(Config) → e.Handler(mux) → http.Server に渡す
          応答後の仕事:  dctx, cancel := edge.Detach(r.Context(), 2*time.Second); go func(){ defer cancel(); audit(dctx) }()
                                                       ↑ 誰も数えていない。SIGTERM で main が終わると、走りかけの audit は消える
今日:      e.Go(r.Context(), 2*time.Second, "audit", audit)      ← Edge が数える・上限を持つ・panic を拾う・失敗を 1 行書く
          e.Serve(ctx, srv, ln, grace)                          ← 止める順を Edge が持つ

用語

用語意味
応答後の仕事応答を返した後に続ける処理。監査ログの送信・キャッシュの温め・Webhook の再送など。Day 073 B4 は、これに要求の ctx を渡して全滅した
graceful shutdown(穏やかな停止)新しい仕事を断り、手元の仕事を終えてから止まる。逆は「全部その場で切る」
猶予(grace)止め始めてから強制終了されるまでの時間。Kubernetes では SIGTERM から SIGKILL まで、既定 30 秒(terminationGracePeriodSeconds、記憶)
ドレイン(drain)新しいものを受けずに、手元のものが空になるのを待つこと
切る(cut)期限が来たので仕事の ctx を cancel すること。Go では goroutine を外から止められないので、ctx を見る仕事だけが止まる

今日使う実物(一次資料。2026-10-15 に手元の Go 1.27.2 のソースと x/sync v0.23.0 のモジュールキャッシュで読んだ)

実物今日に関わる所場所
(*http.Server).ShutdownListener を閉じ → onShutdown の関数を go f() で起動するだけ(待たない。3280〜3281 行) → 1 ms から倍々(上限 500 ms・10% の揺らぎ)で「全接続が idle か」をポーリング。ctx が先に終われば ctx.Err()net/http/server.go 3272〜3310 行(上限は 3250 行 shutdownPollIntervalMax = 500 * time.Millisecond)
Shutdown の文書「Serve はすぐに ErrServerClosed を返す。プログラムが終わらずに Shutdown の戻りを待つようにせよ」同 3260〜3262 行
RegisterOnShutdown「プロトコル固有の停止を始めるべきで、完了を待つべきではない」同 3313〜3318 行
sync.WaitGroup.Add「カウンタが 0 のときの正の Add は Wait より前に起きなければならない」。違反は "sync: WaitGroup misuse: Add called concurrently with Wait" の panic(検出できたときだけ)sync/waitgroup.go 61〜76 行・121 行・132 行
WaitGroup.Go(Go 1.25〜)f が panic したら Done を呼ばずに panic を投げ直す(=プロセスが落ちる)同 236〜258 行
errgroup v0.23.0TryGo+SetLimit で上限付き起動。panic は拾わない(「Wait に伝えると問題が増える」と理由を 4 行で書いている。#53757 ほか)errgroup/errgroup.go 72〜106 行・108 行・142 行
context.AfterFuncctx が終わったら f を走らせる登録。戻り値の stop で登録を外せるcontext/context.go 326 行
context.WithCancelCause/WithTimeoutCause/Causecancel の理由を ctx に持たせる(期限切れか、停止で切られたかを区別できる)同 269 行・711 行・289 行

📝 問題

状況

Day 073 の書き出し API(S1)は、応答を返した後に監査ログを外部の監査サービスへ送ります(1 件 200〜500 ms)。Kubernetes で 1 日 10 回ほどローリングデプロイしています。監査チームから「デプロイの時刻の前後だけ、監査ログが数十件ずつ欠ける」と報告がありました。

今の main はこうです。

go func() {
	<-ctx.Done() // SIGTERM
	sctx, c := context.WithTimeout(context.Background(), 20*time.Second)
	defer c()
	srv.Shutdown(sctx)
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
	log.Fatal(err)
}
// ここで main が終わる

課題

Day 074 の edge パッケージに、次の API を足してください(今日の分は標準ライブラリだけで書けます。Day 074 の OTel の部分とは独立なので、今日の模範解答は今日の分だけを edge パッケージとして示します)。

type Config struct {
	Logger  slog.Handler // 必須(Day 074 と同じ)
	MaxJobs int          // 必須: 同時に走る応答後の仕事の上限
}
func New(cfg Config) (*Edge, error)
func (e *Edge) Go(ctx context.Context, timeout time.Duration, name string, fn func(context.Context) error) error
func (e *Edge) Shutdown(ctx context.Context) error
func (e *Edge) Serve(ctx context.Context, srv *http.Server, ln net.Listener, grace time.Duration) error
var ErrClosed, ErrBusy, ErrTimeout, ErrCut error

テストは package edge_test で書き、公開 API だけを使って次の 15 個を固定します。

約束テスト
Logger・MaxJobs が無ければ、1 回の New で 2 つまとめて errorG1
仕事の ctx は要求の値を残し、要求の cancel を外し、timeout の期限を持つ。timeout ≤ 0 は panicG2
期限切れは理由(cause)付きで 1 行、request_id 付きG3
失敗は 1 行、成功は 0 行(Day 071 のログ量)G4
panic でプロセスを落とさない。値・stack・request_id 付きで 1 行G5
上限を超えた Go は始めずに ErrBusy(ログ 1 行)。空いたらまた受けるG6
Shutdown は走っている仕事が終わるまで戻らないG7
Shutdown の後の Go は ErrClosed で、fn は走らないG8
停止の期限が来たら仕事を ErrCut で切る。ctx を見ない仕事は「まだ走っている」と数えて報告するG9
処理中の要求 20 本の所で止めても 20 本とも 200、受けた仕事 20 件が全部終わってから Serve が戻るG10
止め始めた後の新しい接続は断るG11
期限より長い要求があれば、Serve は期限で error を返し、その後の Go は断るG12
Go と Shutdown を同時にぶつけても、Go が nil を返した仕事は必ず Shutdown の前に終わる(500 回)G13
期限で止めきれなかった要求は、接続を切って要求の ctx を cancel するG14
終わった仕事は何も残さない(5 万件で保持 2 MiB 未満)G15

予想してから解く(自己評価欄へ)

処理中の要求 50 本(ハンドラ 50 ms のあと Go で 200 ms の監査)がある所で SIGTERM を受けたとします。

  • P1: 止め方 4 通り——A Serve(srv.Shutdown → e.Shutdown)/B 逆順(e.Shutdown → srv.Shutdown)/C srv.RegisterOnShutdown(e.Shutdown)/D 状況の main(ListenAndServe が戻ったら抜ける)——で、main が抜ける時点で終わっている監査は何件か
  • P2: 要求が 1 本だけ(700 ms)のとき、その要求が終わってから srv.Shutdown が戻るまで何 ms かかるか
  • P3: atomic.Bool(閉じたか)+WaitGroup で書いた素朴版を、Go 32 回と Shutdown 1 回を同時にぶつける試行で 2,000 回流すと、取りこぼし(Shutdown が戻った後に終わる仕事)は何回起きるか

💡 ヒント

ヒント1(方向性)

止める側には 2 つの「入口」があります。HTTP の入口(新しい接続)と、応答後の仕事の入口(Go)。仕事の入口を閉じてよいのは、仕事を生む者(処理中の要求)がいなくなった後です。順番を逆にすると、まだ処理中の要求が Go を呼んだ瞬間に断られます。そして「いなくなった」を確かめる手段は srv.Shutdown の戻りだけです。

ヒント2(アプローチ)
  • 数え方: WaitGroup は「Wait の後に 0 から Add するな」という約束があり、「閉じたか」の確認と Add の間に隙間ができる。sync.Mutex で「閉じたか」と「何本走っているか」を同じ鍵で守り、0 になったら channel を閉じる
  • 仕事の ctx: WithoutCancel(値を残して cancel を外す)→ WithCancelCause(停止で切るため)→ WithTimeoutCause(期限)。停止の合図は Edge が持つ 1 つの ctx にし、context.AfterFunc で各仕事の cancel につなぐ。仕事が終わったら AfterFunc の登録を外す
  • 期限: goroutine は外から止められない。切った後に少しだけ待ち、それでも残る数を error で報告する
  • Serve: srv.Serve(ln) を goroutine で走らせ、ctx.Done() を待つ。止める期限は終わった ctx から作らない(WithoutCancel してから WithTimeout)。srv.Shutdown が期限切れなら srv.Close() で残った接続を切る
ヒント3(コード骨格)
func (e *Edge) Go(ctx context.Context, timeout time.Duration, name string, fn func(context.Context) error) error {
	e.mu.Lock()
	if e.closing { e.mu.Unlock(); /* ログ */ return ErrClosed }
	if e.running >= e.max { e.mu.Unlock(); /* ログ */ return ErrBusy }
	e.running++                    // 同じ鍵の中で
	e.mu.Unlock()
	jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
	jctx, cancelT := context.WithTimeoutCause(jctx, timeout, ErrTimeout)
	unhook := context.AfterFunc(e.stop, func() { cancel(ErrCut) })
	go func() {
		defer func() { /* recover・cause を読む・unhook・cancel・ログ・e.finish() */ }()
		err = fn(jctx)
	}()
	return nil
}

func (e *Edge) Serve(ctx context.Context, srv *http.Server, ln net.Listener, grace time.Duration) error {
	go func() { errc <- srv.Serve(ln) }()
	<-ctx.Done()
	sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), grace)
	srv.Shutdown(sctx)   // 1. 要求を待つ(要求の中の Go はまだ受ける)
	<-errc
	return e.Shutdown(sctx) // 2. 仕事を待つ。期限なら切る
}

✅ 模範解答

決めたこと(12 項目)

項目決めたこと理由(どのテスト・実測で確かめたか)
止める順srv.Shutdown → e.Shutdown。Serve の中に固定し、利用者に選ばせない逆順・RegisterOnShutdown は 50 件中 50 件を断る(実測 1・G10・M11・M15)
main がいつ抜けるかServe は仕事の待ちまで終えてから戻るListenAndServe の戻りで抜けると 0 ms で抜け、終わった監査は 0 件(実測 1 の D・M12)
期限grace 1 つを要求の待ちと仕事の待ちで共有分けると「要求が早く終わったのに仕事の分が使えない」配分を決める根拠が無い(迷った他案)
期限の作り方WithoutCancel(ctx) から作るSIGTERM で終わった ctx から作ると期限 0 で全部切れる(M17 で G10・G11・G12 が落ちる)
期限切れの要求srv.Close() で接続を切る切らないとハンドラが永遠に残る(G14・M16)
数え方Mutex で closing と running を同じ鍵で守る。WaitGroup は使わない素朴版は 2,000 回中 1 回取りこぼし、-race で DATA RACE(実測 3・M8)
仕事の ctx値を残す・要求の cancel を外す・期限・停止で切る、の 4 つを Go の中で必ず付けるDay 073 B4・W2・W3(G2・M2・M3・M4)
期限切れの理由ErrTimeout(自分の期限)と ErrCut(停止で切られた)を cause で分けるログで「仕事が遅い」と「デプロイで切られた」を見分ける(G3・G9)
上限MaxJobs は必須。超えたら待たずに ErrBusy応答を遅らせない。errgroup の TryGo と同じ(G6・M6)
panic拾ってログ 1 行(errgroup・WaitGroup.Go は拾わない)応答後の仕事の panic で、同じプロセスの処理中の要求まで全部落ちる(Day 064 B2)。迷った他案に詳しく
切った後に待つ時間100 ms 固定(cutWait、選ばせない)ctx を見る仕事は切ってから 11〜34 µs で戻った(実測 4)。3,000 倍の余裕
ログ失敗・panic・期限切れ・断った・置き去り、の 5 種を 1 行ずつ。成功は書かないDay 071 のログ量(G4・M14)

準備

mkdir d75 && cd d75 && go mod init d75      # 作業ディレクトリとモジュールを作る(外部の依存なし)
mkdir -p edge cmd/srv                      # パッケージと試しに動かす main の置き場所
# 下の edge/after.go・edge/after_test.go・edge/measure_test.go・cmd/srv/main.go を保存する
go vet ./...                               # 静的検査
go test -race -count=1 ./edge/             # G1〜G15 の 15 本が通る
go test -tags measure -count=1 -v -run Measure ./edge/   # 実測 1〜4 を表示する
go test -tags measure -run XXX -bench . -benchtime 200000x ./edge/   # 実測 5(1 件あたりの費用)

edge/after.go(201 行)

// Package edge の今日の分: 応答後の仕事(Go)と停止(Shutdown・Serve)。
// Day 074 の Edge にはこのファイルのフィールドとメソッドを足す(Detach は Go の中に閉じる)。
package edge

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"runtime/debug"
	"sync"
	"time"
)

// Config は今日の分だけ。Day 074 の Config に MaxJobs を足す。
type Config struct {
	Logger  slog.Handler // 必須
	MaxJobs int          // 必須。同時に走る応答後の仕事の上限(> 0)
}

var (
	ErrClosed  = errors.New("edge: shutting down; job not started")
	ErrBusy    = errors.New("edge: too many after-response jobs; job not started")
	ErrTimeout = errors.New("edge: after-response job timed out")
	ErrCut     = errors.New("edge: after-response job cut by shutdown")
)

// cutWait は、停止の期限で仕事を切ったあと、切られた仕事が戻るのを待つ時間。
// 選ばせない(実測: ctx を見る仕事は切ってから 1 ms 未満で戻る)。
const cutWait = 100 * time.Millisecond

type Edge struct {
	log *slog.Logger
	max int

	mu       sync.Mutex // closing と running を一緒に守る(WaitGroup を使わない理由)
	closing  bool
	running  int
	idle     chan struct{} // closing かつ running == 0 で閉じる
	idleOnce sync.Once

	stop context.Context // 期限切れで cancel され、走っている仕事を全部切る
	cut  context.CancelCauseFunc
}

// New は設定を全部検査し、誤りはまとめて返す。
func New(cfg Config) (*Edge, error) {
	var errs []error
	if cfg.Logger == nil {
		errs = append(errs, errors.New("edge: Config.Logger is required"))
	}
	if cfg.MaxJobs <= 0 {
		errs = append(errs, fmt.Errorf("edge: Config.MaxJobs must be > 0, got %d", cfg.MaxJobs))
	}
	if err := errors.Join(errs...); err != nil {
		return nil, err
	}
	stop, cut := context.WithCancelCause(context.Background())
	return &Edge{
		log: slog.New(cfg.Logger), max: cfg.MaxJobs,
		idle: make(chan struct{}), stop: stop, cut: cut,
	}, nil
}

// Go は応答後の仕事 fn を別の goroutine で走らせる。fn に渡す ctx は
// 要求の値を残し、要求の cancel を外し、timeout の期限を必ず持ち、停止の期限で切られる。
// 始めなかったときは ErrClosed / ErrBusy を返す(ログも 1 行出す)。
// 失敗・panic・期限切れは Edge がログに 1 行書く。成功は書かない。
func (e *Edge) Go(ctx context.Context, timeout time.Duration, name string, fn func(context.Context) error) error {
	if timeout <= 0 {
		panic("edge: Go timeout must be > 0")
	}
	e.mu.Lock()
	switch {
	case e.closing:
		e.mu.Unlock()
		e.log.WarnContext(ctx, "after-response job rejected", "job", name, "reason", "closing")
		return ErrClosed
	case e.running >= e.max:
		e.mu.Unlock()
		e.log.WarnContext(ctx, "after-response job rejected", "job", name, "reason", "busy", "max", e.max)
		return ErrBusy
	}
	e.running++ // closing を見たのと同じ鍵の中で数える
	e.mu.Unlock()

	jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
	jctx, cancelT := context.WithTimeoutCause(jctx, timeout, ErrTimeout)
	unhook := context.AfterFunc(e.stop, func() { cancel(ErrCut) })

	go func() {
		start := time.Now()
		var err error
		defer func() {
			v := recover()
			cause := context.Cause(jctx) // cancel する前に読む
			unhook()
			cancelT()
			cancel(nil)
			ms := time.Since(start).Milliseconds()
			switch {
			case v != nil:
				e.log.ErrorContext(ctx, "after-response job panicked", "job", name,
					"panic", fmt.Sprint(v), "stack", string(debug.Stack()), "dur_ms", ms)
			case err != nil:
				attrs := []any{"job", name, "err", err.Error(), "dur_ms", ms}
				if cause != nil {
					attrs = append(attrs, "cause", cause.Error())
				}
				e.log.ErrorContext(ctx, "after-response job failed", attrs...)
			}
			e.finish()
		}()
		err = fn(jctx)
	}()
	return nil
}

func (e *Edge) finish() {
	e.mu.Lock()
	e.running--
	if e.closing && e.running == 0 {
		e.idleOnce.Do(func() { close(e.idle) })
	}
	e.mu.Unlock()
}

func (e *Edge) runningNow() int {
	e.mu.Lock()
	defer e.mu.Unlock()
	return e.running
}

// Shutdown は新しい Go を断り、走っている仕事が全部終わるまで待つ。
// ctx が先に終わったら、仕事を ErrCut で切り、cutWait だけ戻りを待ってから error を返す。
// 何回呼んでもよい。
func (e *Edge) Shutdown(ctx context.Context) error {
	e.mu.Lock()
	e.closing = true
	if e.running == 0 {
		e.idleOnce.Do(func() { close(e.idle) })
	}
	e.mu.Unlock()

	select {
	case <-e.idle:
		return nil
	case <-ctx.Done():
	}
	cutN := e.runningNow()
	e.cut(ErrCut)
	t := time.NewTimer(cutWait)
	defer t.Stop()
	select {
	case <-e.idle:
	case <-t.C:
	}
	left := e.runningNow()
	if left > 0 {
		e.log.Error("after-response jobs abandoned", "cut", cutN, "still_running", left)
	}
	return fmt.Errorf("edge: shutdown deadline: cut %d jobs, %d still running: %w", cutN, left, ctx.Err())
}

// Serve は srv を ln で動かし、ctx が終わったら止める。止める順は固定(選ばせない):
//  1. srv.Shutdown: 新しい接続を断り、処理中の要求が終わるまで待つ(要求の中の Go はまだ受け付ける)
//  2. e.Shutdown:   新しい Go を断り、走っている仕事を待つ。期限が来たら切る
//
// grace は 1 と 2 で共有する 1 つの期限。Serve は 2 まで終わってから戻る。
func (e *Edge) Serve(ctx context.Context, srv *http.Server, ln net.Listener, grace time.Duration) error {
	if grace <= 0 {
		return errors.New("edge: Serve grace must be > 0")
	}
	errc := make(chan error, 1)
	go func() { errc <- srv.Serve(ln) }()

	var serveErr error
	select {
	case serveErr = <-errc: // サーバーが自分で止まった(Listener の故障など)。仕事は待つ
	case <-ctx.Done():
	}
	sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), grace)
	defer cancel()
	e.log.InfoContext(ctx, "shutdown started", "grace", grace.String(), "jobs", e.runningNow())

	var errSrv error
	if serveErr == nil {
		if err := srv.Shutdown(sctx); err != nil {
			errSrv = fmt.Errorf("edge: http shutdown: %w", err)
			srv.Close() // 期限切れ: 残った接続を切る(要求の ctx が cancel される)
		}
		if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
			errSrv = errors.Join(errSrv, err)
		}
	} else if !errors.Is(serveErr, http.ErrServerClosed) {
		errSrv = serveErr
	}
	return errors.Join(errSrv, e.Shutdown(sctx))
}
edge/after_test.go(534 行。G1〜G15。package edge_test で公開 API だけを使う)
package edge_test

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"net"
	"net/http"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"d75/edge"
)

// ---- テスト用の道具 ----

type ridKey struct{}

func withRID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ridKey{}, id)
}

// rec は slog.Handler。ctx の request_id を鍵として足して覚える(Day 072 の LogHandler の代わり)。
type rec struct {
	mu   sync.Mutex
	recs []map[string]string
}

func (h *rec) Enabled(context.Context, slog.Level) bool { return true }
func (h *rec) WithAttrs([]slog.Attr) slog.Handler       { return h }
func (h *rec) WithGroup(string) slog.Handler            { return h }
func (h *rec) Handle(ctx context.Context, r slog.Record) error {
	m := map[string]string{"msg": r.Message, "level": r.Level.String()}
	if id, ok := ctx.Value(ridKey{}).(string); ok {
		m["request_id"] = id
	}
	r.Attrs(func(a slog.Attr) bool { m[a.Key] = a.Value.String(); return true })
	h.mu.Lock()
	h.recs = append(h.recs, m)
	h.mu.Unlock()
	return nil
}
func (h *rec) find(msg string) []map[string]string {
	h.mu.Lock()
	defer h.mu.Unlock()
	var out []map[string]string
	for _, m := range h.recs {
		if m["msg"] == msg {
			out = append(out, m)
		}
	}
	return out
}
func (h *rec) count() int { h.mu.Lock(); defer h.mu.Unlock(); return len(h.recs) }

func newEdge(t *testing.T, max int) (*edge.Edge, *rec) {
	t.Helper()
	h := &rec{}
	e, err := edge.New(edge.Config{Logger: h, MaxJobs: max})
	if err != nil {
		t.Fatal(err)
	}
	return e, h
}

func shutdownNow(t *testing.T, e *edge.Edge) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := e.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown: %v", err)
	}
}

// ---- G1〜G13 ----

// G1: Logger と MaxJobs が無ければ、1 回の New で 2 つまとめて error。
func TestG1_NewFailsFastWithAllErrors(t *testing.T) {
	_, err := edge.New(edge.Config{})
	if err == nil {
		t.Fatal("New(Config{}) succeeded")
	}
	for _, want := range []string{"Logger", "MaxJobs"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error %q does not mention %s", err, want)
		}
	}
	if _, err := edge.New(edge.Config{Logger: &rec{}, MaxJobs: -1}); err == nil {
		t.Error("MaxJobs=-1 accepted")
	}
}

// G2: 仕事の ctx は値を残し、要求の cancel を外し、timeout の期限を持つ。timeout<=0 は panic。
func TestG2_JobContext(t *testing.T) {
	e, _ := newEdge(t, 4)
	req, cancelReq := context.WithCancel(withRID(context.Background(), "r-2"))
	type obs struct {
		rid      any
		errAfter error
		deadline time.Duration
		ok       bool
	}
	got := make(chan obs, 1)
	started := make(chan struct{})
	err := e.Go(req, 300*time.Millisecond, "g2", func(ctx context.Context) error {
		close(started)
		time.Sleep(30 * time.Millisecond) // この間に要求が終わる
		dl, ok := ctx.Deadline()
		got <- obs{ctx.Value(ridKey{}), ctx.Err(), time.Until(dl), ok}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	<-started
	cancelReq() // ServeHTTP が戻ったのと同じ
	o := <-got
	if o.rid != "r-2" {
		t.Errorf("value lost: %v", o.rid)
	}
	if o.errAfter != nil {
		t.Errorf("job ctx cancelled with request: %v", o.errAfter)
	}
	if !o.ok || o.deadline <= 0 || o.deadline > 300*time.Millisecond {
		t.Errorf("deadline: ok=%v left=%v", o.ok, o.deadline)
	}
	func() {
		defer func() {
			if recover() == nil {
				t.Error("timeout=0 did not panic")
			}
		}()
		e.Go(req, 0, "zero", func(context.Context) error { return nil })
	}()
	shutdownNow(t, e)
}

// G3: 期限切れは cause=ErrTimeout で 1 行、request_id 付き。
func TestG3_TimeoutLoggedWithCause(t *testing.T) {
	e, h := newEdge(t, 4)
	e.Go(withRID(context.Background(), "r-3"), 20*time.Millisecond, "slow", func(ctx context.Context) error {
		<-ctx.Done()
		if !errors.Is(context.Cause(ctx), edge.ErrTimeout) {
			t.Errorf("cause = %v", context.Cause(ctx))
		}
		return ctx.Err()
	})
	shutdownNow(t, e)
	l := h.find("after-response job failed")
	if len(l) != 1 {
		t.Fatalf("failed lines = %d", len(l))
	}
	if l[0]["request_id"] != "r-3" || !strings.Contains(l[0]["cause"], "timed out") || l[0]["job"] != "slow" {
		t.Errorf("line = %v", l[0])
	}
}

// G4: 失敗は 1 行(request_id 付き)、成功は 0 行。
func TestG4_ErrorOnceSuccessSilent(t *testing.T) {
	e, h := newEdge(t, 4)
	ctx := withRID(context.Background(), "r-4")
	e.Go(ctx, time.Second, "ok", func(context.Context) error { return nil })
	e.Go(ctx, time.Second, "bad", func(context.Context) error { return errors.New("audit write: 503") })
	shutdownNow(t, e)
	if n := h.count(); n != 1 {
		t.Fatalf("log lines = %d, want 1", n)
	}
	l := h.find("after-response job failed")
	if len(l) != 1 || l[0]["request_id"] != "r-4" || l[0]["err"] != "audit write: 503" || l[0]["cause"] != "" {
		t.Errorf("line = %v", l)
	}
}

// G5: panic はプロセスを落とさず、request_id・値・stack 付きで 1 行。数も戻る。
func TestG5_PanicRecovered(t *testing.T) {
	e, h := newEdge(t, 1)
	e.Go(withRID(context.Background(), "r-5"), time.Second, "boom", func(context.Context) error {
		var m map[string]int
		m["x"] = 1 // nil map への書き込み
		return nil
	})
	shutdownNow(t, e) // running が戻らなければここで期限切れ
	l := h.find("after-response job panicked")
	if len(l) != 1 || l[0]["request_id"] != "r-5" ||
		!strings.Contains(l[0]["panic"], "nil map") || !strings.Contains(l[0]["stack"], "TestG5") {
		t.Errorf("line = %v", l)
	}
}

// G6: 上限を超えた Go は ErrBusy で始めず、ログ 1 行。空いたらまた受ける。
func TestG6_MaxJobs(t *testing.T) {
	e, h := newEdge(t, 2)
	release := make(chan struct{})
	block := func(context.Context) error { <-release; return nil }
	ctx := withRID(context.Background(), "r-6")
	for i := 0; i < 2; i++ {
		if err := e.Go(ctx, time.Second, "block", block); err != nil {
			t.Fatal(err)
		}
	}
	var ran atomic.Bool
	if err := e.Go(ctx, time.Second, "third", func(context.Context) error { ran.Store(true); return nil }); !errors.Is(err, edge.ErrBusy) {
		t.Fatalf("third Go err = %v", err)
	}
	close(release)
	deadline := time.Now().Add(time.Second)
	for {
		if err := e.Go(ctx, time.Second, "after", func(context.Context) error { return nil }); err == nil {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("slot never freed")
		}
		time.Sleep(time.Millisecond)
	}
	shutdownNow(t, e)
	if ran.Load() {
		t.Error("rejected job ran")
	}
	if l := h.find("after-response job rejected"); len(l) < 1 || l[0]["reason"] != "busy" || l[0]["request_id"] != "r-6" {
		t.Errorf("rejected line = %v", l)
	}
}

// G7: Shutdown は走っている仕事が終わるまで戻らない。
func TestG7_ShutdownWaits(t *testing.T) {
	e, _ := newEdge(t, 4)
	var done atomic.Bool
	e.Go(context.Background(), time.Second, "w", func(context.Context) error {
		time.Sleep(80 * time.Millisecond)
		done.Store(true)
		return nil
	})
	start := time.Now()
	shutdownNow(t, e)
	if !done.Load() {
		t.Error("Shutdown returned before job finished")
	}
	if d := time.Since(start); d < 70*time.Millisecond {
		t.Errorf("Shutdown took %v", d)
	}
}

// G8: Shutdown の後の Go は ErrClosed で、fn は走らない。
func TestG8_GoAfterShutdown(t *testing.T) {
	e, h := newEdge(t, 4)
	shutdownNow(t, e)
	var ran atomic.Bool
	err := e.Go(withRID(context.Background(), "r-8"), time.Second, "late", func(context.Context) error { ran.Store(true); return nil })
	if !errors.Is(err, edge.ErrClosed) {
		t.Fatalf("err = %v", err)
	}
	time.Sleep(10 * time.Millisecond)
	if ran.Load() {
		t.Error("job ran after shutdown")
	}
	if l := h.find("after-response job rejected"); len(l) != 1 || l[0]["reason"] != "closing" || l[0]["request_id"] != "r-8" {
		t.Errorf("line = %v", l)
	}
	shutdownNow(t, e) // 2 回目も nil
}

// G9: 停止の期限で仕事を切る。ctx を見る仕事は cause=ErrCut で戻り、見ない仕事は「まだ走っている」と数える。
func TestG9_DeadlineCuts(t *testing.T) {
	e, h := newEdge(t, 4)
	cutSeen := make(chan error, 1)
	e.Go(context.Background(), time.Minute, "polite", func(ctx context.Context) error {
		<-ctx.Done()
		cutSeen <- context.Cause(ctx)
		return ctx.Err()
	})
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel()
	err := e.Shutdown(ctx)
	if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "cut 1 jobs, 0 still running") {
		t.Fatalf("polite: err = %v", err)
	}
	if c := <-cutSeen; !errors.Is(c, edge.ErrCut) {
		t.Errorf("cause = %v", c)
	}
	if l := h.find("after-response job failed"); len(l) != 1 || !strings.Contains(l[0]["cause"], "cut by shutdown") {
		t.Errorf("failed line = %v", l)
	}

	e2, h2 := newEdge(t, 4)
	release := make(chan struct{})
	defer close(release)
	e2.Go(context.Background(), time.Minute, "rude", func(context.Context) error { <-release; return nil })
	ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel2()
	start := time.Now()
	err = e2.Shutdown(ctx2)
	if d := time.Since(start); d > 400*time.Millisecond {
		t.Errorf("rude: Shutdown took %v", d)
	}
	if err == nil || !strings.Contains(err.Error(), "cut 1 jobs, 1 still running") {
		t.Fatalf("rude: err = %v", err)
	}
	if l := h2.find("after-response jobs abandoned"); len(l) != 1 || l[0]["still_running"] != "1" {
		t.Errorf("abandoned line = %v", l)
	}
}

// ---- 本物のサーバーで ----

type server struct {
	e        *edge.Edge
	addr     string
	stop     context.CancelFunc
	served   chan error
	jobsDone atomic.Int64
	jobsAcc  atomic.Int64
	inflight sync.WaitGroup // 要求がハンドラに入ったら Done
}

// startServer: /work は handlerDur 寝てから Go(jobDur) を呼び、200 を返す。
func startServer(t *testing.T, h slog.Handler, handlerDur, jobDur, grace time.Duration) *server {
	t.Helper()
	e, err := edge.New(edge.Config{Logger: h, MaxJobs: 1000})
	if err != nil {
		t.Fatal(err)
	}
	s := &server{e: e, served: make(chan error, 1)}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /work", func(w http.ResponseWriter, r *http.Request) {
		s.inflight.Done()
		time.Sleep(handlerDur)
		if err := e.Go(r.Context(), 5*time.Second, "audit", func(ctx context.Context) error {
			select {
			case <-time.After(jobDur):
				s.jobsDone.Add(1)
				return nil
			case <-ctx.Done():
				return ctx.Err()
			}
		}); err == nil {
			s.jobsAcc.Add(1)
		}
		io.WriteString(w, "ok")
	})
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	s.addr = ln.Addr().String()
	ctx, stop := context.WithCancel(context.Background())
	s.stop = stop
	go func() { s.served <- e.Serve(ctx, &http.Server{Handler: mux}, ln, grace) }()
	return s
}

func (s *server) fire(n int) (codes chan int) {
	codes = make(chan int, n)
	s.inflight.Add(n)
	for i := 0; i < n; i++ {
		go func() {
			c := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
			resp, err := c.Get("http://" + s.addr + "/work")
			if err != nil {
				codes <- -1
				return
			}
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
			codes <- resp.StatusCode
		}()
	}
	s.inflight.Wait() // 全部ハンドラに入った
	return codes
}

// G10: 処理中の要求が 20 本ある所で止めても、20 本とも 200、受けた仕事 20 件が全部終わってから Serve が戻る。
func TestG10_ServeDrainsRequestsThenJobs(t *testing.T) {
	h := &rec{}
	s := startServer(t, h, 50*time.Millisecond, 100*time.Millisecond, 3*time.Second)
	codes := s.fire(20)
	s.stop() // SIGTERM と同じ
	err := <-s.served
	if err != nil {
		t.Fatalf("Serve: %v", err)
	}
	for i := 0; i < 20; i++ {
		if c := <-codes; c != 200 {
			t.Errorf("status = %d", c)
		}
	}
	if a, d := s.jobsAcc.Load(), s.jobsDone.Load(); a != 20 || d != 20 {
		t.Errorf("accepted=%d done-before-return=%d, want 20/20", a, d)
	}
	if l := h.find("after-response job rejected"); len(l) != 0 {
		t.Errorf("rejected %d jobs", len(l))
	}
}

// G11: 止め始めた後の新しい接続は断られる。
func TestG11_NewConnectionsRefused(t *testing.T) {
	s := startServer(t, &rec{}, 200*time.Millisecond, 0, 3*time.Second)
	codes := s.fire(1)
	s.stop()
	time.Sleep(30 * time.Millisecond) // srv.Shutdown が Listener を閉じるまで
	if _, err := net.DialTimeout("tcp", s.addr, 200*time.Millisecond); err == nil {
		t.Error("new connection accepted during shutdown")
	}
	if c := <-codes; c != 200 {
		t.Errorf("in-flight status = %d", c)
	}
	if err := <-s.served; err != nil {
		t.Fatal(err)
	}
}

// G12: 期限より長い要求があると、Serve は期限で error を返し、仕事は始まらないか切られる。
func TestG12_ServeGraceExpires(t *testing.T) {
	h := &rec{}
	s := startServer(t, h, 400*time.Millisecond, time.Second, 100*time.Millisecond)
	codes := s.fire(1)
	s.stop()
	start := time.Now()
	err := <-s.served
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("err = %v", err)
	}
	if d := time.Since(start); d > 400*time.Millisecond {
		t.Errorf("Serve took %v", d)
	}
	<-codes
	time.Sleep(400 * time.Millisecond) // ハンドラが目覚めて Go を呼ぶ
	if s.jobsAcc.Load() != 0 {
		t.Error("job accepted after edge closed")
	}
	if l := h.find("after-response job rejected"); len(l) != 1 || l[0]["reason"] != "closing" {
		t.Errorf("rejected = %v", l)
	}
}

// G13: Go と Shutdown を同時に 500 回ぶつける。Go が nil を返した仕事は、必ず Shutdown が戻る前に終わる。
func TestG13_NoLostJobsUnderRace(t *testing.T) {
	lost := 0
	for i := 0; i < 500; i++ {
		e, _ := newEdge(t, 1000)
		var accepted, finished atomic.Int64
		var wg sync.WaitGroup
		for g := 0; g < 8; g++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				for k := 0; k < 4; k++ {
					if e.Go(context.Background(), time.Second, "j", func(context.Context) error {
						time.Sleep(time.Millisecond)
						finished.Add(1)
						return nil
					}) == nil {
						accepted.Add(1)
					}
				}
			}()
		}
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		err := e.Shutdown(ctx)
		finAtReturn := finished.Load()
		cancel()
		wg.Wait()
		if err != nil {
			t.Fatal(err)
		}
		if a, f := accepted.Load(), finAtReturn; f < a {
			lost += int(a - f)
		}
	}
	if lost != 0 {
		t.Errorf("lost %d accepted jobs (finished after Shutdown returned)", lost)
	}
}

// G14: 期限で止めきれなかった要求は、接続を切って要求の ctx を cancel する(ハンドラが ctx を見ていれば戻れる)。
func TestG14_GraceExpiredCancelsRequests(t *testing.T) {
	e, _ := newEdge(t, 4)
	returned := make(chan time.Time, 1)
	entered := make(chan struct{})
	mux := http.NewServeMux()
	mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
		close(entered)
		<-r.Context().Done() // 長い配信。ctx が終わるまで返らない
		returned <- time.Now()
	})
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	ctx, stop := context.WithCancel(context.Background())
	served := make(chan error, 1)
	go func() { served <- e.Serve(ctx, &http.Server{Handler: mux}, ln, 100*time.Millisecond) }()
	go http.Get("http://" + ln.Addr().String() + "/stream")
	<-entered
	stop()
	if err := <-served; !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("Serve err = %v", err)
	}
	select {
	case <-returned:
	case <-time.After(time.Second):
		t.Error("handler still running 1s after Serve returned (connection not closed)")
	}
}

// G15: 終わった仕事は停止用の ctx に何も残さない(AfterFunc の登録を外す)。5 万件で保持が 2 MiB 未満。
func TestG15_NoRetentionPerJob(t *testing.T) {
	e, _ := newEdge(t, 100000)
	var ms runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&ms)
	before := ms.HeapAlloc
	const n = 50000
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		e.Go(context.Background(), time.Minute, "j", func(context.Context) error { wg.Done(); return nil })
	}
	wg.Wait()
	time.Sleep(50 * time.Millisecond) // 後始末の defer が終わるまで
	runtime.GC()
	runtime.ReadMemStats(&ms)
	grown := int64(ms.HeapAlloc) - int64(before)
	t.Logf("heap grown after %d jobs: %d B (%.1f B/job)", n, grown, float64(grown)/n)
	if grown > 2<<20 {
		t.Errorf("heap grew %d B after %d finished jobs", grown, n)
	}
	shutdownNow(t, e)
}
edge/measure_test.go(284 行。実測 1〜5。-tags measure のときだけ入る)
//go:build measure

package edge_test

import (
	"context"
	"errors"
	"io"
	"net"
	"net/http"
	"runtime"
	"sort"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"d75/edge"
)

// 実測 1: 止める順 4 通り。処理中 50 本(ハンドラ 50 ms → Go(仕事 200 ms))の所で止める。
func TestMeasureOrder(t *testing.T) {
	const n = 50
	type variant struct {
		name string
		run  func(ctx context.Context, e *edge.Edge, srv *http.Server, ln net.Listener)
	}
	vs := []variant{
		{"A Serve(srv→仕事)", func(ctx context.Context, e *edge.Edge, srv *http.Server, ln net.Listener) {
			e.Serve(ctx, srv, ln, 3*time.Second)
		}},
		{"B 逆順(仕事→srv)", func(ctx context.Context, e *edge.Edge, srv *http.Server, ln net.Listener) {
			go srv.Serve(ln)
			<-ctx.Done()
			sctx, c := context.WithTimeout(context.Background(), 3*time.Second)
			defer c()
			e.Shutdown(sctx)
			srv.Shutdown(sctx)
		}},
		{"C RegisterOnShutdown で仕事を待つ", func(ctx context.Context, e *edge.Edge, srv *http.Server, ln net.Listener) {
			sctx, c := context.WithTimeout(context.Background(), 3*time.Second)
			defer c()
			srv.RegisterOnShutdown(func() { e.Shutdown(sctx) })
			go srv.Serve(ln)
			<-ctx.Done()
			srv.Shutdown(sctx)
		}},
		{"D Serve が ErrServerClosed を返したら main を抜ける", func(ctx context.Context, e *edge.Edge, srv *http.Server, ln net.Listener) {
			go func() {
				<-ctx.Done()
				sctx, c := context.WithTimeout(context.Background(), 3*time.Second)
				defer c()
				srv.Shutdown(sctx)
				e.Shutdown(sctx)
			}()
			srv.Serve(ln) // ここで戻ったら main が終わる想定
		}},
	}
	for _, v := range vs {
		e, _ := edge.New(edge.Config{Logger: &rec{}, MaxJobs: 1000})
		var acc, rej, done, ok200 atomic.Int64
		var entered sync.WaitGroup
		entered.Add(n)
		mux := http.NewServeMux()
		mux.HandleFunc("GET /w", func(w http.ResponseWriter, r *http.Request) {
			entered.Done()
			time.Sleep(50 * time.Millisecond)
			if e.Go(r.Context(), 5*time.Second, "audit", func(ctx context.Context) error {
				time.Sleep(200 * time.Millisecond)
				done.Add(1)
				return nil
			}) == nil {
				acc.Add(1)
			} else {
				rej.Add(1)
			}
		})
		ln, _ := net.Listen("tcp", "127.0.0.1:0")
		srv := &http.Server{Handler: mux}
		ctx, stop := context.WithCancel(context.Background())
		ret := make(chan struct{})
		go func() { v.run(ctx, e, srv, ln); close(ret) }()
		var cw sync.WaitGroup
		for i := 0; i < n; i++ {
			cw.Add(1)
			go func() {
				defer cw.Done()
				c := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
				if resp, err := c.Get("http://" + ln.Addr().String() + "/w"); err == nil {
					io.Copy(io.Discard, resp.Body)
					resp.Body.Close()
					if resp.StatusCode == 200 {
						ok200.Add(1)
					}
				}
			}()
		}
		entered.Wait()
		start := time.Now()
		stop()
		<-ret
		el := time.Since(start)
		atExit := done.Load()
		cw.Wait()
		time.Sleep(300 * time.Millisecond)
		t.Logf("%-40s 戻るまで %4d ms / 200=%d / 受けた %d・断った %d / 戻った時点で終わった仕事 %d(その後 %d)",
			v.name, el.Milliseconds(), ok200.Load(), acc.Load(), rej.Load(), atExit, done.Load())
	}
}

// 実測 2: srv.Shutdown は「最後の要求が終わってから」何 ms 遅れて戻るか(ポーリング 1 ms→倍々→上限 500 ms)。
func TestMeasureShutdownPollLag(t *testing.T) {
	for _, hd := range []time.Duration{5 * time.Millisecond, 50 * time.Millisecond, 200 * time.Millisecond, 700 * time.Millisecond, 1500 * time.Millisecond} {
		var lags []time.Duration
		for k := 0; k < 5; k++ {
			entered := make(chan struct{})
			finished := make(chan time.Time, 1)
			mux := http.NewServeMux()
			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
				close(entered)
				time.Sleep(hd)
				finished <- time.Now()
			})
			ln, _ := net.Listen("tcp", "127.0.0.1:0")
			srv := &http.Server{Handler: mux}
			go srv.Serve(ln)
			go func() {
				c := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
				if resp, err := c.Get("http://" + ln.Addr().String() + "/"); err == nil {
					resp.Body.Close()
				}
			}()
			<-entered
			srv.Shutdown(context.Background())
			lags = append(lags, time.Since(<-finished))
		}
		sort.Slice(lags, func(i, j int) bool { return lags[i] < lags[j] })
		t.Logf("ハンドラ %5v: 終わってから Shutdown が戻るまで 最小 %v 中央 %v 最大 %v", hd, lags[0].Round(100*time.Microsecond), lags[2].Round(100*time.Microsecond), lags[4].Round(100*time.Microsecond))
	}
}

// 実測 3: WaitGroup + atomic の素朴版。Go と Shutdown がぶつかると何が起きるか。
type naive struct {
	closed atomic.Bool
	wg     sync.WaitGroup
}

func (n *naive) Go(fn func()) error {
	if n.closed.Load() {
		return edge.ErrClosed
	}
	n.wg.Add(1) // closed を見てから Add するまでの隙間
	go func() { defer n.wg.Done(); fn() }()
	return nil
}
func (n *naive) Shutdown() { n.closed.Store(true); n.wg.Wait() }

func TestMeasureNaiveWaitGroup(t *testing.T) {
	const trials = 2000
	lostTrials, lostJobs, panics, acceptedTotal := 0, 0, 0, 0
	for i := 0; i < trials; i++ {
		var nv naive
		var acc, fin atomic.Int64
		var wg sync.WaitGroup
		var panicked atomic.Bool
		for g := 0; g < 8; g++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				defer func() {
					if recover() != nil {
						panicked.Store(true)
					}
				}()
				for k := 0; k < 4; k++ {
					if nv.Go(func() { time.Sleep(time.Millisecond); fin.Add(1) }) == nil {
						acc.Add(1)
					}
				}
			}()
		}
		runtime.Gosched()
		nv.Shutdown()
		f := fin.Load()
		wg.Wait()
		acceptedTotal += int(acc.Load())
		if panicked.Load() {
			panics++
		}
		if a := acc.Load(); f < a {
			lostTrials++
			lostJobs += int(a - f)
		}
		time.Sleep(2 * time.Millisecond)
	}
	t.Logf("素朴版: %d 回中 %d 回で Shutdown が戻った後に終わる仕事があった(計 %d 件 / 受けた %d 件)、WaitGroup misuse の panic %d 回",
		trials, lostTrials, lostJobs, acceptedTotal, panics)

	lost, accTotal := 0, 0
	for i := 0; i < trials; i++ {
		e, _ := edge.New(edge.Config{Logger: &rec{}, MaxJobs: 1000})
		var acc, fin atomic.Int64
		var wg sync.WaitGroup
		for g := 0; g < 8; g++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				for k := 0; k < 4; k++ {
					if e.Go(context.Background(), time.Second, "j", func(context.Context) error { time.Sleep(time.Millisecond); fin.Add(1); return nil }) == nil {
						acc.Add(1)
					}
				}
			}()
		}
		runtime.Gosched()
		e.Shutdown(context.Background())
		f := fin.Load()
		wg.Wait()
		accTotal += int(acc.Load())
		if a := acc.Load(); f < a {
			lost += int(a - f)
		}
	}
	t.Logf("Edge 版: %d 回で取りこぼし %d 件 / 受けた %d 件", trials, lost, accTotal)
}

// 実測 4: ctx を見ない仕事は、Shutdown が戻った後も生きている(goroutine は外から止められない)。
func TestMeasureRudeJobSurvives(t *testing.T) {
	base := runtime.NumGoroutine()
	e, _ := edge.New(edge.Config{Logger: &rec{}, MaxJobs: 100})
	release := make(chan struct{})
	var polite, rude atomic.Int64
	cutLat := make(chan time.Duration, 10)
	var cutAt atomic.Int64
	for i := 0; i < 10; i++ {
		e.Go(context.Background(), time.Minute, "polite", func(ctx context.Context) error {
			<-ctx.Done()
			cutLat <- time.Since(time.Unix(0, cutAt.Load()))
			polite.Add(1)
			return ctx.Err()
		})
		e.Go(context.Background(), time.Minute, "rude", func(context.Context) error { <-release; rude.Add(1); return nil })
	}
	ctx, c := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer c()
	time.AfterFunc(50*time.Millisecond-time.Microsecond, func() { cutAt.Store(time.Now().UnixNano()) })
	err := e.Shutdown(ctx)
	var lats []time.Duration
	for i := 0; i < 10; i++ {
		lats = append(lats, <-cutLat)
	}
	sort.Slice(lats, func(i, j int) bool { return lats[i] < lats[j] })
	t.Logf("Shutdown: %v", err)
	t.Logf("戻った後の goroutine 増分 %d(ctx を見る仕事 %d 件は戻った・見ない仕事 %d 件はまだ)", runtime.NumGoroutine()-base, polite.Load(), 10-rude.Load())
	t.Logf("期限から ctx を見る仕事が戻るまで: 最小 %v 最大 %v", lats[0], lats[9])
	close(release)
}

// 実測 5: 1 件あたりの費用。Day 074 の Detach+go と比べる。
func BenchmarkGo(b *testing.B) {
	e, _ := edge.New(edge.Config{Logger: &rec{}, MaxJobs: 1 << 20})
	ctx := withRID(context.Background(), "r")
	var wg sync.WaitGroup
	b.ReportAllocs()
	for b.Loop() {
		wg.Add(1)
		e.Go(ctx, time.Second, "j", func(context.Context) error { wg.Done(); return nil })
	}
	wg.Wait()
}

func BenchmarkDetachGo(b *testing.B) {
	ctx := withRID(context.Background(), "r")
	var wg sync.WaitGroup
	b.ReportAllocs()
	for b.Loop() {
		wg.Add(1)
		dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
		go func() { defer cancel(); _ = dctx; wg.Done() }()
	}
	wg.Wait()
}

var _ = errors.Is

cmd/srv/main.go(59 行。状況の main を書き直したもの)

// go run ./cmd/srv で起動し、SIGTERM(Ctrl-C でも可)で止める。
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"d75/edge"
)

func main() {
	h := slog.NewTextHandler(os.Stderr, nil)
	e, err := edge.New(edge.Config{Logger: h, MaxJobs: 256})
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	log := slog.New(h)
	mux := http.NewServeMux()
	mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(200 * time.Millisecond) // 書き出し
		if err := e.Go(r.Context(), 2*time.Second, "audit", func(ctx context.Context) error {
			select {
			case <-time.After(500 * time.Millisecond): // 監査ログを外部に送る
				log.Info("audit sent")
				return nil
			case <-ctx.Done():
				return ctx.Err()
			}
		}); err != nil {
			http.Error(w, "try again", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "exported")
	})

	ln, err := net.Listen("tcp", "127.0.0.1:8075")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	log.Info("listening", "addr", ln.Addr().String())
	// Kubernetes の既定の猶予 30 s から、preStop と余裕を引いた値(仮定)
	if err := e.Serve(ctx, srv, ln, 20*time.Second); err != nil {
		log.Error("stopped with error", "err", err)
		os.Exit(1)
	}
	log.Info("stopped cleanly")
}

実行結果(Go 1.27.2、darwin/arm64、2026-10-15)

$ go test -race -count=1 -v ./edge/ 2>&1 | grep -E '^(---|ok)'
--- PASS: TestG1_NewFailsFastWithAllErrors (0.00s)
--- PASS: TestG2_JobContext (0.03s)
--- PASS: TestG3_TimeoutLoggedWithCause (0.02s)
--- PASS: TestG4_ErrorOnceSuccessSilent (0.00s)
--- PASS: TestG5_PanicRecovered (0.00s)
--- PASS: TestG6_MaxJobs (0.00s)
--- PASS: TestG7_ShutdownWaits (0.08s)
--- PASS: TestG8_GoAfterShutdown (0.01s)
--- PASS: TestG9_DeadlineCuts (0.17s)
--- PASS: TestG10_ServeDrainsRequestsThenJobs (0.16s)
--- PASS: TestG11_NewConnectionsRefused (0.28s)
--- PASS: TestG12_ServeGraceExpires (0.50s)
--- PASS: TestG13_NoLostJobsUnderRace (0.55s)
--- PASS: TestG14_GraceExpiredCancelsRequests (0.10s)
--- PASS: TestG15_NoRetentionPerJob (0.59s)
ok  	d75/edge	3.653s

-race -count=5 でも 15 本すべて通った。go vet ./... も指摘なし。

本物のプロセスに SIGTERM を送った記録(/export の処理中に kill -TERM、その 50 ms 後に新しい要求):

$ ./srv 2>srv.log & ; curl /export &(処理中) ; kill -TERM $P ; curl -m 1 /export
 000                                  ← 止め始めた後の新しい接続: curl exit=7(接続拒否)
server exit=0
exported 200                          ← 処理中だった要求は最後まで返った
level=INFO msg=listening addr=127.0.0.1:8075
level=INFO msg="shutdown started" grace=20s jobs=0
level=INFO msg="audit sent"           ← SIGTERM の後、要求の中で始まった監査が終わってから
level=INFO msg="stopped cleanly"      ← プロセスが抜けた

jobs=0 は、SIGTERM の時点ではまだハンドラが書き出し中(200 ms)で、Go が呼ばれていなかったことを示す。順を逆にしていたら、この監査は ErrClosed で断られていた(実測 1 の B)。

🪜 Step-by-Step 解説

Step 1: 状況の main は 3 か所で監査を落とす

場所起きること根拠
ListenAndServe が戻ったら main が終わるShutdown を呼んだ瞬間に ErrServerClosed が返る(文書 3260〜3262 行)。処理中の要求も、その後の監査もプロセスごと消える実測 1 の D: 0 ms で抜け、終わった監査 0 件
go func(){…}() の監査を誰も数えないShutdown が要求を待っても、応答後の goroutine は待たないDay 074 の Detach の限界
監査の goroutine で panicプロセスごと落ちる(Day 064 B2)変異 M5 でテストのプロセスが落ちた

Step 2: 数えるのは Mutex。WaitGroup にしなかった理由

最初に書いたのは atomic.Bool + WaitGroup でした。

func (n *naive) Go(fn func()) error {
	if n.closed.Load() { return ErrClosed }
	n.wg.Add(1)            // ← closed を見てから Add するまでの隙間
	go func() { defer n.wg.Done(); fn() }()
	return nil
}
func (n *naive) Shutdown() { n.closed.Store(true); n.wg.Wait() }

Go が closed=false を読んだ直後に Shutdown が Store(true)→Wait()(カウンタ 0 なのですぐ戻る)を済ませると、その後の Add(1) で始まった仕事は誰にも待たれません。WaitGroup の文書(61〜76 行)が禁じている「Wait と同時に 0 から Add」そのものです。2,000 回の試行で起きたのは 1 回(実測 3)——テストを数回流しただけでは見つからない頻度です。-race は同じ 2,000 回で DATA RACE を報告しました。

Mutex で closing と running を同じ鍵で守れば、「閉じたかを見る」と「1 本増やす」が分けられません。0 になったら idle channel を閉じ、Shutdown はそれを待ちます。2,000 回で取りこぼし 0 件。

Step 3: 仕事の ctx に 4 つを必ず付ける

jctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) // ① 値を残す ② 要求の cancel を外す
jctx, cancelT := context.WithTimeoutCause(jctx, timeout, ErrTimeout) // ③ 自分の期限
unhook := context.AfterFunc(e.stop, func() { cancel(ErrCut) })       // ④ 停止で切る

④ を AfterFunc にしたのは、WithoutCancel で親とのつながりを切った後でも、もう 1 本の親(Edge の停止)をつなぐためです。context には「親を 2 つ持つ」関数が無いので、AfterFunc で片方の終わりをもう片方の cancel に流します。

ここで 1 つ見落としていました。AfterFunc の登録は e.stop 側に残るので、仕事が終わったら unhook() で外さないと、終わった仕事 1 件ごとに約 400 B が停止まで残ります(変異 M13: 5 万件で 20.8 MB、外せば 0.3 MB)。最初の 13 本のテストではこれを見つけられず、G15 を足して検出しました。

Step 4: Shutdown は「待つ → 切る → 少し待つ → 数えて報告」

closing = true(以後の Go は ErrClosed)
├─ running == 0 になった      → nil
└─ ctx が先に終わった          → e.cut(ErrCut)(全部の仕事の ctx が終わる)
                                 → 100 ms だけ idle を待つ
                                 → "cut N jobs, M still running" を error で返す(M>0 ならログも 1 行)

Go では goroutine を外から止められません。ctx を見ない仕事(<-release で寝ているだけ)は、Shutdown が戻った後も生きています(実測 4: goroutine が 10 本残った)。だから Shutdown にできるのは「切った」「まだ走っている数」を正直に報告することまでです。

Step 5: Serve に順番を閉じる

srv.Shutdown(sctx)      // Listener を閉じ、処理中の要求が終わるまで待つ。この間の Go は受ける
srv.Close()             // (期限切れのときだけ)残った接続を切る → 要求の ctx が cancel
<-errc                  // srv.Serve の戻り(ErrServerClosed)
e.Shutdown(sctx)        // ここで初めて Go を閉じる

RegisterOnShutdown に e.Shutdown を登録する案は「待たない」だけだと思っていましたが、実測するともっと悪いことが分かりました。Shutdown は onShutdown を Listener を閉じた直後に go f() で起動する(3280〜3281 行)ので、処理中の要求がまだ Go を呼ぶ前に Edge が閉じ、50 件すべてが ErrClosed で断られました(実測 1 の C)。逆順(B)と同じ結果です。

Step 6: 期限は 1 つを共有し、終わった ctx から作らない

Serve が受け取る ctx は SIGTERM でもう終わっています。そこから WithTimeout(ctx, grace) を作ると、期限が最初から過ぎていて、srv.Shutdown も e.Shutdown も即座に期限切れになります(M17 で G10・G11・G12 が落ちた)。WithoutCancel(ctx) で値だけ残してから期限を付けます。

📏 実測(予想と外れた所を含む)

測定時のロードアベレージは 1.2〜13(15 コア)。時間は参考、件数と割り当てを根拠にする。

1. 止める順 4 通り(P1)

処理中 50 本(ハンドラ 50 ms → Go で 200 ms の監査)が全部ハンドラに入った所で止めた。

止め方戻るまで200 の応答Go が受けた/断った戻った時点で終わった監査その後
A Serve(srv → 仕事)253 ms5050 / 05050
B 逆順(仕事 → srv)74 ms500 / 5000
C RegisterOnShutdown(e.Shutdown)73 ms500 / 5000
D ListenAndServe の戻りで抜ける(状況の main)0 ms50※50 / 0050※

※ D はテストのプロセスが生きていたので応答も監査も後から終わった。本物の main ならここでプロセスが終わり、処理中の 50 本の応答と 50 件の監査がどちらも消える(導出)。

予想との違い: C は「待たないので、戻った時点で終わっていない」だけだと予想していた。実際は始まる前に Edge が閉じて全部断る——B と同じだった。

2. srv.Shutdown は最後の要求が終わってから何 ms 遅れて戻るか(P2)

要求 1 本、各 5 回。

ハンドラの長さ終わってから Shutdown が戻るまで(最小 / 中央 / 最大)
5 ms3.6 / 3.7 / 4.0 ms
50 ms19.2 / 21.3 / 22.4 ms
200 ms70.5 / 78.2 / 82.4 ms
700 ms354.9 / 360.6 / 386.6 ms
1.5 s70.1 / 92.7 / 113.8 ms

ポーリングの間隔は 1・2・4・…・256・500・500 ms(+10% の揺らぎ)で、問い合わせる時刻はおよそ 1・3・7・…・511・1011・1511 ms(導出)。700 ms の要求は 511 ms と 1011 ms の間で終わるので、次の問い合わせまで約 300 ms 待たされる。1.5 s の要求はたまたま問い合わせの直前に終わった。最悪で約 500 ms+揺らぎを猶予から失うので、猶予の配分ではこの分を見込む。

3. WaitGroup の素朴版(P3)

8 本の goroutine が Go を 4 回ずつ、同時に Shutdown を 1 回。2,000 回。

実装受けた仕事取りこぼし(Shutdown の後に終わった)-race
素朴版(atomic.Bool+WaitGroup)42,787 件2,000 回中 1 回・1 件DATA RACE(-race 下の 2,000 回では取りこぼし 0 回だったが、競合は報告された)
Edge(Mutex)9,486 件0 件報告なし

WaitGroup misuse の panic は 1 回も出なかった(検出は「たまたま同時に見えたとき」だけ)。取りこぼしの頻度は 0.05%。テストを数回流して通っても安心できない。G13 の 500 回では素朴版の隙間を見つけきれないので、変異 M8 では隙間を 1 µs に広げて G13 で検出した(隙間がナノ秒のままだと G13 は通ってしまう可能性が高い。-race 付きで流すことが本当の防御)。

Edge の方が受けた件数が少ないのは、Mutex で Go が直列になり、Shutdown が先に鍵を取りやすいため。

4. 期限で切る: ctx を見ない仕事は残る

ctx を見る仕事 10 件・見ない仕事 10 件を走らせ、期限 50 ms で Shutdown。

Shutdown: edge: shutdown deadline: cut 20 jobs, 10 still running: context deadline exceeded
戻った後の goroutine 増分 10(ctx を見る仕事 10 件は戻った・見ない仕事 10 件はまだ)
期限から ctx を見る仕事が戻るまで: 最小 11µs 最大 34µs

cutWait を 100 ms にした根拠はこの 11〜34 µs(ctx を見る仕事なら 100 ms で十分すぎる)。見ない仕事は何秒待っても戻らないので、待つ時間を伸ばしても意味が無い。

5. 1 件あたりの費用

BenchmarkGo-15          200000   1098〜1570 ns/op   1008〜1015 B/op   15 allocs/op
BenchmarkDetachGo-15    200000    547〜622 ns/op         344 B/op    8 allocs/op   ← Day 074 の Detach+go

+664 B・+7 allocs(ctx 2 段・AfterFunc の登録・defer の閉包・Mutex の往復)。監査 1 件が外部への HTTP 呼び出し(数百 µs〜ms)であることを考えると、1 µs 前後の差は無視できる(導出)。

6. 変異 18 通り

変異戻した決めごと検出したテスト
M1New が最初の誤りで返るG1
M2仕事の ctx に要求の cancel が残る(Day 073 B4)G2・G10
M3期限を掛けない(Day 073 W3)G2・G3
M4値を捨てる(Background)G2
M5recover しないテストのプロセスごと落ちる
M6上限を見ないG6
M7停止中でも受けるG8・G12・G13
M8closing の確認と数えるのを別の鍵で(素朴版と同じ隙間、1 µs に拡大)G13
M9期限で仕事を切らないG9
M10切った後に戻りを待たないG9
M11止める順を逆に(仕事 → srv)G10
M12Serve が仕事を待たずに戻るG10
M13AfterFunc の登録を外さないG15(最初の 13 本では生存)
M14成功もログに書くG4
M15RegisterOnShutdown で仕事を待つG10
M16期限切れで srv.Close しないG14(最初の 13 本では生存)
M17停止の期限を終わった ctx から作るG10・G11・G12
M18仕事 0 件の Shutdown で idle を閉じないG2・G8・G11・G13

最初の 13 本(G1〜G13)では 16 / 18。生き残った M13・M16 のために G14・G15 を足して 18 / 18。

7. 何もしない実装(スタブ)

New は常に成功、Go・Shutdown・Serve は何もせず nil を返す実装で流すと、15 本中 10 本が失敗、5 本(G2・G10・G11・G12・G14)は待ちが終わらず 5 秒で打ち切り。通る 1 本は無い。

⚖️ 迷った他案と採らなかった理由

論点採った案迷った他案と採らなかった理由前提が変わったら
止める順をどこに書くか(*Edge).Serve に閉じるShutdown だけ公開し、順は文書に書く: 実測 1 の 4 通り中 3 通りが全滅。Day 074 の「積む順を選ばせない」と同じ理由。http.Server を Edge が作る: ReadHeaderTimeout・TLS などの設定を Edge が全部持つことになるHTTP 以外(gRPC・キューの consumer)も同じプロセスで止めるなら、Serve を「止める対象の列」を受ける形に広げる
数え方Mutex+カウンタ+channelWaitGroup+atomic.Bool: 2,000 回に 1 回取りこぼす(実測 3)。errgroup: Wait の後の Go を断る仕組みが無く、panic も拾わない。channel をセマフォにして全部取り戻す(容量 MaxJobs を全部 send できたら空): 上限と待ちを 1 つで書けるが、閉じた後の Go を断るのに結局 Mutex か別の channel が要るGo が 1 秒に数十万回呼ばれ、Mutex が詰まるのが pprof で見えたら、atomic のカウンタで「閉じた」を最上位ビットに詰める(WaitGroup 自身の実装と同じ手)
上限を超えたら待たずに ErrBusy待つ(errgroup の Go): 応答が遅れ、要求の待ちと仕事の待ちが絡んで停止が読めなくなる。上限なし: 監査サービスが落ちたとき goroutine が積み上がる(Day 061 の TimeoutHandler と同じ形)断った仕事を捨てられない(監査が法令で必須)なら、ErrBusy を受けた側でディスクの送信箱に書く。Edge の中には入れない
panic拾ってログ 1 行(stack 付き)拾わない(errgroup・WaitGroup.Go の選択): errgroup は「stack が値になり、監視ツールから隠れる」と書いている。それは正しいが、応答後の仕事の panic で同じプロセスの処理中の要求まで落ちる損の方が大きい。stack はログに残すので、監視はログで拾う仕事が共有状態を壊しうる(panic 後に続けると危ない)なら、拾った後に Edge を閉じ、プロセスを止める判断を上に返す
期限grace 1 つを共有要求の分と仕事の分を分ける(例: 15 s+5 s): 要求が 1 s で終わっても仕事が 5 s しか使えない。分ける根拠になる数字が無い監査の SLO が「デプロイ時も 99.9% 届く」になったら、仕事の分を最低保証として別に取る
期限切れで切った後100 ms だけ待って、残りを数えて errorずっと待つ: SIGKILL まで待つことになり、ログも出ないまま殺される。待たない: ctx を見る仕事のログ(「cut by shutdown」)が出る前にプロセスが終わる(M10)—
cutWait を選ばせるか選ばせない(100 ms)Config.CutWait: 11〜34 µs で戻るのに選ばせる理由が無い切られた仕事が後始末に外部呼び出しをする(ロールバックの送信など)なら、その分を見込んで伸ばす
MaxJobs必須(ゼロ値は error)既定 100: 根拠になる数字を持っていない。サービスごとに監査サービスの受けられる量が違う3 サービスの値が揃ったら、その値を既定にする
拒否した仕事呼び出し側に error を返し、Edge もログ 1 行同期で走らせる(閉じた後は要求の中で実行): 要求の中ならまだ srv.Shutdown が待っているので安全そうに見える。だが G12 のように要求自体が期限を越えた後だと、誰も待たない—

🧭 設計思想・なぜこう書くのか

  • 「goroutine は止められない、止まってもらうだけ」: Go には Thread.stop に当たるものがありません。止める側にできるのは ctx を終わらせることと、待つことと、数えて報告することだけです。だから Go は必ず期限付きの ctx を渡し、Shutdown は「まだ走っている数」を error に入れて返します
  • 始めた者が終わりを待つ: go 文を書いた所が、その goroutine の終わりに責任を持つ(structured concurrency の考え方)。今日は go 文を Edge.Go の中にだけ置き、利用者が go を書かなくて済むようにしました
  • 共有メモリで通信せず、通信で共有せよ——ただし数えるのは Mutex: 「閉じたか」と「何本か」は 1 つの不変条件(closing && running == 0 で idle)なので、channel で表すより鍵 1 つで守る方が短く、正しさも読みやすい。idle だけを channel にして「待つ」に使いました
  • 文書に書いてある約束は、コードで守らせる: Shutdown の「プログラムが終わらずに待て」、RegisterOnShutdown の「待つな」、WaitGroup の「Wait の後に 0 から Add するな」——3 つとも文書にありますが、状況の main は 1 つ目を破っていました。Serve の中に閉じれば、文書を読まない人も破れません

🌐 他言語との比較

言語・FW応答後の仕事・停止の仕組み今日の Go との違い
Go(今日)Edge.Go で数え、Serve が srv → 仕事の順に待つ。期限で ctx を切るgoroutine は外から止められない。ctx を見ない仕事は残る
Java(ExecutorService)shutdown()(新規を断る)→ awaitTermination(t)(待つ)→ shutdownNow()(割り込み)の 3 段(記憶)今日の Shutdown はこの 3 段を 1 回の呼び出しにまとめた形。shutdownNow の割り込みも、相手が見なければ止まらないのは同じ
ASP.NET CoreIHostedService/BackgroundService の StopAsync に CancellationToken。待つ時間は HostOptions.ShutdownTimeout(.NET 8 で既定が 30 秒になった、記憶)順序はホストが決める(選ばせない)。応答後の仕事は IBackgroundTaskQueue などに入れる型が文書に載っている(記憶)
Node.jsserver.close(cb) は新規を断り、既存の接続が終わると cb。応答後の Promise は誰も数えない(記憶)状況の main と同じ穴: process.exit の前に自分で数える必要がある
Python(uvicorn)SIGTERM で新規を断り、処理中を待つ。--timeout-graceful-shutdown で上限(記憶)。FastAPI の BackgroundTasks は応答後に同じリクエストの中で走るので待たれる(記憶)BackgroundTasks は「要求の中で走る」ので srv の待ちに含まれる。今日の Go 版は別 goroutine なので自分で数える

(Java・ASP.NET・Node・Python の行は今回ソースも実行も確認していない)

🏭 実務での使いどころ

  • ローリングデプロイの「欠け」の調査: 「デプロイの時刻だけ欠ける」は、ほぼ停止処理。ListenAndServe の戻りで main が終わっていないか、応答後の go 文を誰が数えているかを見る
  • 猶予の配分: Kubernetes の既定 30 s(記憶)から、preStop の待ち(エンドポイントから外れるまで)と、実測 2 のポーリング遅れ(最悪 約 500 ms)を引いた値を grace にする。今日の main の 20 s は仮定
  • PR レビューで見る 3 点: 応答後の go 文が Edge.Go 以外に無いか(grep -n 'go func' )/RegisterOnShutdown で何かを「待って」いないか/停止の期限を SIGTERM で終わった ctx から作っていないか
  • 上限の決め方: MaxJobs は「監査サービスが落ちたとき、期限(timeout)の間に積み上がる数」で決める。1,000 rps で応答後の仕事が 1 件ずつ、期限 2 s なら最大 2,000(導出)。それより小さくすれば ErrBusy が先に出る

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
ListenAndServe が戻ったら止まり終わった戻り値が ErrServerClosed なので「閉じた」と読めるShutdown を呼んだ瞬間に戻る(文書 3260〜3262 行)。Shutdown の戻りを待つ
RegisterOnShutdown に登録すれば停止時に待ってくれる名前が「停止のときに」なのでgo f() で起動するだけで待たない(3280〜3281 行)。しかも Listener を閉じた直後に走るので、処理中の要求より先に動く
先に応答後の仕事を止めてから HTTP を止める「後ろから順に止める」と考える処理中の要求が新しい仕事を生む。仕事の入口は最後に閉じる(実測 1 の B で 50 件全部断った)
WaitGroup で数えれば十分よくある例がそう書いている「閉じたか」の確認と Add が分かれると、2,000 回に 1 回取りこぼす。-race で流す
期限が来たら goroutine は止まるctx を cancel したのでctx を見ない仕事は止まらない(実測 4 で 10 本残った)。Edge にできるのは数えて報告することだけ
停止の期限は受け取った ctx から作ればよいctx を渡すのが Go の作法なのでSIGTERM でもう終わっている。WithoutCancel してから期限を付ける(M17)
context.AfterFunc は放っておいてよい一度しか走らないので親が終わるまで登録が残る。1 件 約 400 B(M13)

🚧 扱わなかった範囲と理由

範囲理由
Day 074 の OTel 部分との結合(Go の仕事に span を作る・応答の span に link する)今日は停止の正しさに絞った。仕事の span は応答の span の子にすると、応答の span が終わった後の子になる。link にするかは次に決める
応答後の仕事のメトリクス(受けた・断った・切った・置き去りの数)ログだけにした。Day 072 の semconv に該当する名前が無いので、名前を決めてから
readiness を先に落とす(preStop・エンドポイントから外れるまでの待ち)Kubernetes 側の話。実クラスタで測っていない
WebSocket など hijack された接続Shutdown は待たない(文書 3264〜3268 行)。今日のサービスに無い
HTTP/2・HTTP/3 の GOAWAYShutdown の中で h3 の分岐があるのは見たが(3276 行)、試していない
断った仕事を送信箱(ディスク)に退避する監査が「必ず届く」必要があるならここが本命だが、今日の範囲(停止の順序)を超える
仕事が仕事を生む(Go の中から Go)停止中は ErrClosed になる。親の仕事が終わる前に子を断ってよいかは決めていない
Java・ASP.NET・Node・Python の行記憶。確認していない
時間の比較ロードアベレージ 1.2〜13 の幅があったので、ns/op は参考。件数と割り当てを根拠にした

➡️ 次のステップ

  • 発展: Serve を「止める対象の列」(HTTP サーバー・キューの consumer・定期実行)を受ける形に広げ、どの順で止めれば仕事を生む者が先にいなくなるかを、依存のグラフから決める
  • 次回予告: 同テーマで「デバッグ」——停止にまつわるバグ(readiness を落とさずに止めて接続拒否が出る・ハンドラが ctx を見ずに srv.Shutdown が毎回期限切れ・仕事の中の Go が停止中に断られる・signal.Notify の channel が容量 0 で SIGTERM を取りこぼす・os.Exit で defer が走らない)。10/25 にテーマが進む前の、このテーマの残り数本

🎯 自己評価(解いた後に記入)

自分の回答(止める順・数え方・期限の扱いで模範解答と違った所)

P1・P2・P3 の予想と答え合わせ

気づき・メモ