フェーズ: 上級者 (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).Shutdown | Listener を閉じ → 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.0 | TryGo+SetLimit で上限付き起動。panic は拾わない(「Wait に伝えると問題が増える」と理由を 4 行で書いている。#53757 ほか) | errgroup/errgroup.go 72〜106 行・108 行・142 行 |
context.AfterFunc | ctx が終わったら f を走らせる登録。戻り値の stop で登録を外せる | context/context.go 326 行 |
context.WithCancelCause/WithTimeoutCause/Cause | cancel の理由を 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 つまとめて error | G1 |
| 仕事の ctx は要求の値を残し、要求の cancel を外し、timeout の期限を持つ。timeout ≤ 0 は panic | G2 |
| 期限切れは理由(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)/Csrv.RegisterOnShutdown(e.Shutdown)/D 状況のmain(ListenAndServeが戻ったら抜ける)——で、mainが抜ける時点で終わっている監査は何件か - P2: 要求が 1 本だけ(700 ms)のとき、その要求が終わってから
srv.Shutdownが戻るまで何 ms かかるか - P3:
atomic.Bool(閉じたか)+WaitGroupで書いた素朴版を、Go32 回とShutdown1 回を同時にぶつける試行で 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 ms | 50 | 50 / 0 | 50 | 50 |
| B 逆順(仕事 → srv) | 74 ms | 50 | 0 / 50 | 0 | 0 |
C RegisterOnShutdown(e.Shutdown) | 73 ms | 50 | 0 / 50 | 0 | 0 |
D ListenAndServe の戻りで抜ける(状況の main) | 0 ms | 50※ | 50 / 0 | 0 | 50※ |
※ D はテストのプロセスが生きていたので応答も監査も後から終わった。本物の main ならここでプロセスが終わり、処理中の 50 本の応答と 50 件の監査がどちらも消える(導出)。
予想との違い: C は「待たないので、戻った時点で終わっていない」だけだと予想していた。実際は始まる前に Edge が閉じて全部断る——B と同じだった。
2. srv.Shutdown は最後の要求が終わってから何 ms 遅れて戻るか(P2)
要求 1 本、各 5 回。
| ハンドラの長さ | 終わってから Shutdown が戻るまで(最小 / 中央 / 最大) |
|---|---|
| 5 ms | 3.6 / 3.7 / 4.0 ms |
| 50 ms | 19.2 / 21.3 / 22.4 ms |
| 200 ms | 70.5 / 78.2 / 82.4 ms |
| 700 ms | 354.9 / 360.6 / 386.6 ms |
| 1.5 s | 70.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 通り
| 変異 | 戻した決めごと | 検出したテスト |
|---|---|---|
| M1 | New が最初の誤りで返る | G1 |
| M2 | 仕事の ctx に要求の cancel が残る(Day 073 B4) | G2・G10 |
| M3 | 期限を掛けない(Day 073 W3) | G2・G3 |
| M4 | 値を捨てる(Background) | G2 |
| M5 | recover しない | テストのプロセスごと落ちる |
| M6 | 上限を見ない | G6 |
| M7 | 停止中でも受ける | G8・G12・G13 |
| M8 | closing の確認と数えるのを別の鍵で(素朴版と同じ隙間、1 µs に拡大) | G13 |
| M9 | 期限で仕事を切らない | G9 |
| M10 | 切った後に戻りを待たない | G9 |
| M11 | 止める順を逆に(仕事 → srv) | G10 |
| M12 | Serve が仕事を待たずに戻る | G10 |
| M13 | AfterFunc の登録を外さない | G15(最初の 13 本では生存) |
| M14 | 成功もログに書く | G4 |
| M15 | RegisterOnShutdown で仕事を待つ | 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+カウンタ+channel | WaitGroup+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 Core | IHostedService/BackgroundService の StopAsync に CancellationToken。待つ時間は HostOptions.ShutdownTimeout(.NET 8 で既定が 30 秒になった、記憶) | 順序はホストが決める(選ばせない)。応答後の仕事は IBackgroundTaskQueue などに入れる型が文書に載っている(記憶) |
| Node.js | server.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 の GOAWAY | Shutdown の中で 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 にテーマが進む前の、このテーマの残り数本