A+B 複合 — 仕様化テストで守りながら Pub/Sub ワーカーを冪等にし、GKE の Pod 終了で二重発行しない(第15章 リファクタリング × PID 1 と SIGTERM × at-least-once)

2026-09-26 土曜(Day 135) 土曜複合: コーディング × インフラ ★★★★★ 仕様化テスト / スモールステップ / 第15章 GKE / Pub/Sub / Cloud SQL (PostgreSQL 17)

概要

📌

直す前に「今」を固定する

仕様化テストはバグも期待値に書く。直すときに期待値を1つずつ別コミットで反転させる。11件中7件を反転、金額表の4件は最後まで緑。

🛑

PID 1 の python は SIGTERM を無視する

ハンドラが無いと猶予を丸ごと待って SIGKILL。実測: docker stop -t 10 で 10.7 秒・終了コード 137。ハンドラありなら 1.0 秒・0。

🔁

止め方を直しても冪等性は要る

ノード障害や OOM では SIGKILL が来る。exactly-once も「ack 成功後」しか守らない。副作用の前に業務キーで一意制約を取る。

⏱️

猶予の秒数は最後に決まる

全 I/O にタイムアウトを付けて足し算(最悪 18 秒)→ 排出予算 20 秒 → 猶予 30 秒。後輩の「600 秒」は根拠が逆向き。

問題

MOps 販促システムチームの coupon-issuer は、会員の誕生月に誕生日クーポンを発行し、アプリに push 通知するワーカー。発行側のスケジューラが毎月1日 00:05(JST)に member-birthday イベントを Pub/Sub に流し、GKE Standard 上の Deployment(4 Pod、HPA あり)が pull 購読で処理する。

9月の棚卸し(2026-08-27〜09-25、本問の前提データ)

観測値出どころ
Pod の終了回数163回(デプロイ 6回×4 Pod、HPA 縮小 131、ノードアップグレード 8)Kubernetes events
終了コード 137(SIGKILL)で終わった回数163回(全部)、いずれも削除から約30秒後同上
同じ会員・同じキャンペーンに2枚以上241件issued_coupons
発行したのに通知が届いていない58件push 事業者の配信ログと突合
誕生月なのに発行されていない19件会員マスタと突合
後輩の提案: 「Pub/Sub の exactly-once delivery を有効にして、terminationGracePeriodSeconds を 600 に上げれば直ると思います」

このコードにはテストが1本もない。第15章(仕様化テスト・スモールステップ)の手順で直す計画とコードを出せ。

制約・前提条件

  • Python 3.12+(検証 3.13)、google-cloud-pubsub 2.x(検証 2.41.0)、psycopg 3 + psycopg_pool、Cloud SQL for PostgreSQL 17
  • 金額表(gold 1,000円/silver 500円/それ以外 300円)は変えない(マーケが告知済み)
  • 2枚目が使われる率 35%(仮定)、重複241件のランク構成 gold 20%/silver 35%/その他 45%(前提)
  • push 事業者は Idempotency-Key を受け付け、同じキーの再送を24時間捨てる(仮定)
  • 発行側スケジューラは改修できる(イベントに項目を足せる)
期待する回答形式: (1) 問題点7点 (2) 直す前のコードに対して緑になる仕様化テスト (3) スモールステップ計画(反転させる期待値・デプロイ順) (4) 改善後コード+DB 移行 SQL・Deployment・サブスクリプション設定 (5) 実行例 (6) 後輩の提案への回答と迷った案 (7) 直した後に見るもの・止め方

悪いコード (Before)

コードとインフラ設定に 7つの問題 が隠れています。
legacy_worker.py / Dockerfile
conn = None  # 最初のメッセージで接続し、全スレッドで使い回す

def callback(message):
    try:
        data = json.loads(message.data)
        c = get_conn()
        cur = c.cursor()
        cur.execute("SELECT rank FROM members WHERE id = %s", (data["member_id"],))
        rank = cur.fetchone()[0]
        if rank == "gold":
            amount = 1000
        elif rank == "silver":
            amount = 500
        else:
            amount = 300
        campaign_id = "birthday-" + datetime.now().strftime("%Y-%m")
        cur.execute("INSERT INTO issued_coupons (...) VALUES (%s, %s, %s)", ...)
        c.commit()
        requests.post(PUSH_URL, json={...})
        message.ack()
    except Exception as e:
        print("error", e)
        message.ack()  # 無限リトライを避けるため

def main():
    subscriber = pubsub_v1.SubscriberClient()
    future = subscriber.subscribe(os.environ["SUBSCRIPTION"], callback)
    future.result()

# Dockerfile
CMD ["python", "legacy_worker.py"]
問題点サマリー(7点)
1PID 1 の python が SIGTERM を無視 — 163回すべて30秒後に SIGKILL。処理中は ack されず再配信
2冪等でない素の INSERT — commit 後・ack 前に殺されると2枚目。重複241件 ÷ 終了163回 = 1.48件/回
3冪等キーを処理時刻(UTC)から作る — JST 月初 00:05〜08:59 は前月扱い。再配信が月をまたぐとキーが変わる
4失敗でも ack、push の 5xx を見ない — 未発行19件・未通知58件が消えた
5接続1本を10スレッドで共有・rollback なし — 1件の失敗で以後全件が InFailedSqlTransaction
6止まる時間の上限が無い — requests 無期限、FlowControl 既定1,000件
7業務ルール・I/O・ack が1関数 — テストを差し込む継ぎ目が無い(第15章の前提が無い)

ヒント(段階的開示)

ヒント1 — 方向性
2つの問いに分ける。「止め方」: Pod が削除されると Kubernetes はメインプロセスに SIGTERM を送り、猶予(既定30秒)が過ぎたら SIGKILL。163回すべてが「約30秒後に 137」なのは、SIGTERM が効いていない証拠。「やり直し方」: Pub/Sub は少なくとも1回届ける。ack 前に止まれば同じメッセージがまた来る。止め方を直すだけでは足りない。そして、テストの無いコードを直す前に第15章は何をしろと言っているか。
ヒント2 — アプローチ
  • PID 1 と信号: PID 名前空間の init(PID 1)には、ハンドラを登録した信号しか届かない。Python は SIGTERM にハンドラを登録しない
  • 仕様化テスト: 「今の振る舞い」を書く。失敗でも ack する、UTC で月を決める——もそのまま期待値に。DB・push・時計は fake・time_machine で観測
  • 冪等キー: (member_id, campaign_id) に一意制約。ただしキーは処理時刻でなくメッセージの中身から
  • ack の意味: ack は「もう来なくていい」。失敗は nack して、上限を超えたら dead letter topic へ
  • 共有接続: 接続1本を10スレッドで使うと、トランザクションも1つを共有する
ヒント3 — 骨格
def handle(raw: bytes, deps: Dependencies) -> Outcome:
    event = BirthdayEvent.parse(raw)          # 失敗 → Outcome.REJECT
    rank = deps.members.find_rank(event.member_id)
    coupon = deps.coupons.issue_once(decide_coupon(event, rank))  # ON CONFLICT DO NOTHING
    if coupon.notified:
        return Outcome.ALREADY_DONE           # 前回は通知後・ack 前に止まった
    deps.push.send(..., idempotency_key=f"coupon-{coupon.coupon_id}")
    deps.coupons.mark_notified(coupon.coupon_id)
    return Outcome.ISSUED

# 停止: SIGTERM → future.cancel()(新規受信停止・未着手は nack)
#       → future.result(timeout=排出予算)  ※ subscribe(..., await_callbacks_on_shutdown=True)
# 排出予算 = コールバック1件の最悪時間(全 I/O のタイムアウトの和)< terminationGracePeriodSeconds

問題点分析(7点)

#問題起きていること根拠
1PID 1 の python が SIGTERM を無視削除のたび30秒待って SIGKILL、処理中は ack されない前提 163/163 が 137。実測 docker stop -t 10 → 10.7 秒・137
2冪等でない素の INSERTcommit 後・ack 前に殺される → 再配信で2枚目+再通知導出 241 ÷ 163 = 1.48件/回
3冪等キーが処理時刻(UTC)由来JST 10/1 00:05 の処理が「9月分」。月またぎの再配信でキーが変わる仕様化テストで固定 → birthday-2026-09
4失敗でも ack/push 5xx を無視未発行19件・未通知58件が誰にも気づかれず消える仕様化テスト4件
5接続1本を共有・rollback なし1件の失敗の後、正しいメッセージも全部失敗 → #4 で消える実測 PG 17.11 で3〜5件目が InFailedSqlTransaction
6止まる時間の上限が無いrequests 無期限、1 Pod が最大1,000件を抱え、実行は10件ライブラリ 2.41.0 の既定値をソースで確認
7業務ルール・I/O・ack が1関数安全網を張る場所が無い。金額がマジックナンバー第15章・第10章

Pod 終了のタイムライン(Before / After)

Pod 削除からの経過秒(猶予 terminationGracePeriodSeconds = 30) 0s5s10s15s20s25s30s Before PID 1 = python ハンドラなし SIGTERM(無視される) 受信を続ける(最大 1,000 件を抱えたまま) コールバック INSERT → commit ✓ push… SIGKILL → 終了コード 137 ack されずに死ぬ 別 Pod に再配信 → 素の INSERT → 2枚目+再通知(241件/月) After PID 1 = python SIGTERM ハンドラ SIGTERM → stop.set() future.cancel(): 新規受信停止・未着手(最大 20 件)は即 nack → 他 Pod へ 排出予算 DRAIN_TIMEOUT_S = 20s(最悪 18s = 接続待ち2 + SQL 3×3 + push 2+5) 実行中のコールバック完了 → ack(通常は数秒) exit 0 余裕 10s(ストリーム停止・プール close) ここまで来るのは排出失敗時だけ それでも SIGKILL が来たら(ノード障害・OOM・排出の時間切れ) 再配信 → INSERT … ON CONFLICT (member_id, campaign_id) WHERE voided_at IS NULL DO NOTHING → 既存行を SELECT notified なら ALREADY_DONE で ack / 未通知なら同じ Idempotency-Key で push をやり直す → クーポンは1枚、通知は1回

横軸は Pod 削除からの秒数。Before の「INSERT → commit ✓ の後、ack 前に SIGKILL」が二重発行の正体。After は「止め方」(上段)と「やり直し方」(下の枠)の2段で守る。

構造(第15章で切り出した継ぎ目)

run() SIGTERM → cancel → drain make_callback() Outcome → ack / nack だけ handle(raw, deps) → ISSUED / ALREADY_DONE / RETRY / REJECT decide_coupon() 純粋関数・金額表 MemberRepository(Protocol) 本番: Pg… / テスト: FakeMembers CouponRepository.issue_once(Protocol) 本番: 部分ユニーク索引 + ON CONFLICT / テスト: dict PushSender(Protocol) 本番: requests + Idempotency-Key / テスト: FakePush 継ぎ目(seam)= Protocol の3つ legacy では get_conn()・requests.post・datetime.now を monkeypatch でしか替えられなかった

仕様化テスト(直す前のコードに対して緑)

第15章の仕様化テスト(Feathers の characterization test)は今の振る舞いをそのまま記録する。#3・#4 のバグもここでは期待値に書き、直すときに別コミットで反転させる。レガシーコードは1行も変えず、get_conn と requests.post を monkeypatch、時計は time_machine でコンテナと同じ UTC に止める。

"""仕様化テスト(characterization test): legacy_worker.callback の「今の振る舞い」を固定する。

正しさではなく現状を記録する。バグに見える振る舞いも、直すまではそのまま期待値に書く
(直すときは、その期待値だけを別コミットで反転させる)。
"""
from __future__ import annotations

import json
from datetime import datetime
from zoneinfo import ZoneInfo

import pytest
import time_machine

import legacy_worker

# コンテナは TZ 未設定=UTC。JST 2026-10-01 00:05 は UTC では 9/30 15:05
JST_OCT_1_0005_IN_UTC = datetime(2026, 9, 30, 15, 5, tzinfo=ZoneInfo("UTC"))


class FakeCursor:
    def __init__(self, conn: FakeConn) -> None:
        self._conn = conn

    def execute(self, sql: str, params: tuple) -> None:
        self._conn.log.append(("execute", " ".join(sql.split()), params))
        if sql.startswith("INSERT") and self._conn.fail_insert:
            raise RuntimeError("db down")

    def fetchone(self):
        return self._conn.row


class FakeConn:
    def __init__(self, row, fail_insert: bool = False) -> None:
        self.row, self.fail_insert, self.log = row, fail_insert, []

    def cursor(self) -> FakeCursor:
        return FakeCursor(self)

    def commit(self) -> None:
        self.log.append(("commit",))


class FakeMessage:
    def __init__(self, payload: bytes) -> None:
        self.data, self.calls = payload, []

    def ack(self) -> None:
        self.calls.append("ack")

    def nack(self) -> None:
        self.calls.append("nack")


class FakeResponse:
    def __init__(self, status_code: int) -> None:
        self.status_code = status_code


@pytest.fixture
def world(monkeypatch):
    """DB・push・時計を差し替え、副作用の順番を1本のログに記録する。"""
    effects: list = []

    def install(row, *, fail_insert=False, push_status=200):
        conn = FakeConn(row, fail_insert)
        conn.log = effects
        monkeypatch.setattr(legacy_worker, "get_conn", lambda: conn)

        def fake_post(url, json):  # noqa: A002 - requests と同じ引数名
            effects.append(("push", json["title"]))
            return FakeResponse(push_status)

        monkeypatch.setattr(legacy_worker.requests, "post", fake_post)
        return effects

    return install


def run(payload: dict | bytes) -> FakeMessage:
    raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
    msg = FakeMessage(raw)
    with time_machine.travel(JST_OCT_1_0005_IN_UTC, tick=False):
        legacy_worker.callback(msg)
    return msg


@pytest.mark.parametrize(("rank", "amount"), [("gold", 1000), ("silver", 500),
                                              ("bronze", 300), ("unknown-rank", 300)])
def test_amount_by_rank(world, rank, amount):
    effects = world((rank,))
    msg = run({"member_id": 42})
    assert effects[1] == ("execute",
                          "INSERT INTO issued_coupons (member_id, campaign_id, amount) VALUES (%s, %s, %s)",
                          (42, "birthday-2026-09", amount))
    assert msg.calls == ["ack"]


def test_campaign_month_is_processing_time_in_utc(world):
    # 現状: JST 10/1 00:05 に処理すると「9月分」になる(直すまで、この期待値で固定する)
    effects = world(("gold",))
    run({"member_id": 42})
    assert effects[1][2][1] == "birthday-2026-09"


def test_side_effect_order(world):
    effects = world(("silver",))
    msg = run({"member_id": 7})
    assert [e[0] for e in effects] == ["execute", "execute", "commit", "push"]
    assert msg.calls == ["ack"]


def test_same_message_twice_issues_twice(world):
    # 現状: 再配信されると2回 INSERT し、2回通知する(冪等でない)
    effects = world(("gold",))
    run({"member_id": 42})
    run({"member_id": 42})
    assert [e[0] for e in effects].count("commit") == 2
    assert [e[0] for e in effects].count("push") == 2


@pytest.mark.parametrize("case", ["broken_json", "missing_member", "db_error"])
def test_failures_are_acked_and_lost(world, case):
    # 現状: どんな失敗でも ack する → Pub/Sub から消え、再配信されない
    if case == "broken_json":
        world(("gold",))
        msg = run(b"{not json")
    elif case == "missing_member":
        world(None)
        msg = run({"member_id": 404})
    else:
        world(("gold",), fail_insert=True)
        msg = run({"member_id": 42})
    assert msg.calls == ["ack"]


def test_push_5xx_is_treated_as_success(world):
    # 現状: push の 503 を見ていない → クーポンは発行済み・通知なし・ack 済み
    effects = world(("gold",), push_status=503)
    msg = run({"member_id": 42})
    assert effects[-1] == ("push", "お誕生日クーポン 1000円")
    assert msg.calls == ["ack"]
$ uv run --with google-cloud-pubsub --with 'psycopg[binary]' --with pytest --with time-machine --with requests pytest -q test_characterization.py
...........                                                              [100%]
11 passed in 8.58s

実測 JST の Mac 上でも time_machine に UTC の時刻を渡すと datetime.now() が UTC で動き、birthday-2026-09 が出た。

スモールステップの計画

1コミットで「構造だけ変える」か「振る舞いを1つだけ変える」か。構造だけのコミットでは11件すべて緑。振る舞いを変えるコミットでは、反転させる期待値を名指しする。

#コミット種類仕様化テスト(11件)デプロイ順の制約
0test: 仕様化テスト11件テスト11件緑—
1refactor: 金額を COUPON_AMOUNT_YEN_BY_RANK へ構造11件緑—
2refactor: handle()→Outcome と ack アダプタに分割、DB・push・時計を引数へ構造11件緑—
3fix: 月をイベントの campaign_month から振る舞い反転1: campaign_month先に発行側に項目追加 → oldest_unacked_message_age が発行側デプロイからの経過時間より小さくなるのを待つ → ワーカー
4fix: 失敗は nack、5回で DLQ振る舞い反転3: failures ×3先に DLQ と retry policy(無いと nack は即時再配信で回り続ける)
5fix: (member_id, campaign_id) で1枚だけ振る舞い反転2: twice, side_effect_order先に DB 移行(重複の無効化 → 部分ユニーク索引)
6fix: push 5xx は RETRY、Idempotency-Key振る舞い反転1: push_5xx—
7feat: SIGTERM で排出、受信上限20件、接続プール停止時金額表4件は緑のままDeployment と同時

反転は合計7件、変えなかったのは金額表の4件。その4件が最後まで緑であることが「告知済みの金額を変えていない」証拠。

模範解答(Bad → Good)

Before — 全部入りの callback
def callback(message):
    try:
        data = json.loads(message.data)
        c = get_conn()                      共有接続
        ...
        campaign_id = "birthday-" + datetime.now()...
        cur.execute("INSERT INTO issued_coupons ...")
        c.commit()
        requests.post(PUSH_URL, ...)        無期限・5xx 無視
        message.ack()
    except Exception as e:
        print("error", e)
        message.ack()

future = subscriber.subscribe(SUB, callback)
future.result()                             止まれない
After — 判断・副作用・ack・停止を分離
def handle(raw: bytes, deps: Dependencies) -> Outcome:
    try:
        event = BirthdayEvent.parse(raw)
    except InvalidEventError:
        return Outcome.REJECT               消さずに nack
    try:
        rank = deps.members.find_rank(event.member_id)   # プールから1本ずつ
        if rank is None:
            return Outcome.REJECT
        coupon = deps.coupons.issue_once(decide_coupon(event, rank))
        # ↑ campaign_id はイベントの campaign_month から  
        if coupon.notified:
            return Outcome.ALREADY_DONE     再配信でも1枚
        deps.push.send(..., idempotency_key=f"coupon-{coupon.coupon_id}")
        deps.coupons.mark_notified(coupon.coupon_id)
    except TransientError:
        return Outcome.RETRY
    return Outcome.ISSUED

# run(): PID 1 でも SIGTERM を受ける
signal.signal(signal.SIGTERM, lambda *_: stop.set())   
future = subscriber.subscribe(sub, make_callback(deps),
    flow_control=FlowControl(max_messages=20),          
    await_callbacks_on_shutdown=True)
stop.wait(); future.cancel()
future.result(timeout=DRAIN_TIMEOUT_S)   # 20s < grace 30s
"""誕生日クーポン発行ワーカー(Pub/Sub pull 購読, GKE 上で常駐)。

構造(第15章のスモールステップで legacy_worker.callback から切り出した):
    decide_coupon()  … 純粋関数。ランク→金額、キャンペーン ID の決定
    handle()         … 1メッセージの業務処理。結果を Outcome で返し、ack/nack は決めない
    make_callback()  … Outcome を ack/nack に写すだけの薄いアダプタ
    run()            … SIGTERM を受けて「新規受信を止め → 実行中を待ち → 未着手を nack」する
"""
from __future__ import annotations

import json
import logging
import signal
import threading
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Final, Protocol

logger = logging.getLogger("coupon_issuer")

# ── 業務ルール ────────────────────────────────────────────────
class MemberRank(StrEnum):
    GOLD = "gold"
    SILVER = "silver"
    REGULAR = "regular"  # legacy の else 節(bronze・不明ランク)をまとめた名前


COUPON_AMOUNT_YEN_BY_RANK: Final[dict[MemberRank, int]] = {
    MemberRank.GOLD: 1_000,
    MemberRank.SILVER: 500,
    MemberRank.REGULAR: 300,
}

# ── 停止予算(terminationGracePeriodSeconds の根拠。manifest のコメントと対にする) ──
DB_POOL_TIMEOUT_S: Final[float] = 2.0      # 接続待ち
DB_STATEMENT_TIMEOUT_S: Final[float] = 3.0  # 1文あたり(SELECT / INSERT / UPDATE の3文)
PUSH_CONNECT_TIMEOUT_S: Final[float] = 2.0
PUSH_READ_TIMEOUT_S: Final[float] = 5.0
CALLBACK_WORST_CASE_S: Final[float] = (
    DB_POOL_TIMEOUT_S + 3 * DB_STATEMENT_TIMEOUT_S + PUSH_CONNECT_TIMEOUT_S + PUSH_READ_TIMEOUT_S
)  # = 18.0 秒
DRAIN_TIMEOUT_S: Final[float] = 20.0  # 18 秒 + ストリーム停止の余裕 2 秒。grace 30 秒の内側に収める

CALLBACK_THREADS: Final[int] = 10          # ライブラリ既定の ThreadPoolExecutor(max_workers=10) と同じ
MAX_OUTSTANDING_MESSAGES: Final[int] = 2 * CALLBACK_THREADS  # 既定 1,000 件を抱え込まない


# ── 値オブジェクト ──────────────────────────────────────────────
class InvalidEventError(ValueError):
    """メッセージが仕様を満たさない(何度再配信しても成功しない)。"""


@dataclass(frozen=True, slots=True)
class BirthdayEvent:
    """誕生日イベント。対象月は発行側(JST で月を決めるスケジューラ)が決める。"""

    member_id: int
    campaign_month: str  # "YYYY-MM"。処理時刻から作らない=再配信しても同じ冪等キーになる

    @classmethod
    def parse(cls, raw: bytes) -> BirthdayEvent:
        """Pub/Sub のペイロードを検証して生成する。

        Args:
            raw: メッセージ本文(UTF-8 JSON)。

        Returns:
            検証済みの BirthdayEvent。

        Raises:
            InvalidEventError: JSON でない・必須項目がない・型や書式が違う場合。
        """
        try:
            body = json.loads(raw)
            member_id, month = body["member_id"], body["campaign_month"]
        except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError) as e:
            raise InvalidEventError(f"malformed event: {e!r}") from None
        if not isinstance(member_id, int) or member_id <= 0:
            raise InvalidEventError(f"bad member_id: {member_id!r}")
        if not (isinstance(month, str) and len(month) == 7 and month[4] == "-"):
            raise InvalidEventError(f"bad campaign_month: {month!r}")
        return cls(member_id=member_id, campaign_month=month)


@dataclass(frozen=True, slots=True)
class CouponDecision:
    member_id: int
    campaign_id: str   # (member_id, campaign_id) が冪等キー
    amount_yen: int


@dataclass(frozen=True, slots=True)
class IssuedCoupon:
    coupon_id: int
    amount_yen: int
    notified: bool


def decide_coupon(event: BirthdayEvent, rank: MemberRank) -> CouponDecision:
    """誰にいくらのクーポンを出すかを決める(副作用なし)。"""
    return CouponDecision(
        member_id=event.member_id,
        campaign_id=f"birthday-{event.campaign_month}",
        amount_yen=COUPON_AMOUNT_YEN_BY_RANK[rank],
    )


# ── 外部との境目(テストではフェイクに差し替える継ぎ目 = seam) ──────────
class TransientError(RuntimeError):
    """待てば成功しうる失敗(DB タイムアウト、push の 5xx・タイムアウト)。"""


class MemberRepository(Protocol):
    def find_rank(self, member_id: int) -> MemberRank | None: ...


class CouponRepository(Protocol):
    def issue_once(self, decision: CouponDecision) -> IssuedCoupon:
        """(member_id, campaign_id) につき1枚だけ発行し、既にあればそれを返す。"""
        ...

    def mark_notified(self, coupon_id: int) -> None: ...


class PushSender(Protocol):
    def send(self, member_id: int, title: str, *, idempotency_key: str) -> None:
        """送信する。Raises: TransientError(5xx・タイムアウト)。"""
        ...


class Outcome(StrEnum):
    ISSUED = "issued"              # 今回発行して通知まで終えた
    ALREADY_DONE = "already_done"  # 再配信。発行も通知も済んでいた
    RETRY = "retry"                # 一時的な失敗 → nack(Pub/Sub の retry_policy で間隔を空ける)
    REJECT = "reject"              # 何度やっても失敗 → nack し、5回目で dead letter topic へ


@dataclass(frozen=True, slots=True)
class Dependencies:
    members: MemberRepository
    coupons: CouponRepository
    push: PushSender


def handle(raw: bytes, deps: Dependencies) -> Outcome:
    """1メッセージを処理する。何度呼んでも、発行は1枚・通知は(送信側の冪等キーで)1回。

    Args:
        raw: メッセージ本文。
        deps: DB と push の実装。

    Returns:
        処理結果。ack するか nack するかは呼び出し側が Outcome から決める。
    """
    try:
        event = BirthdayEvent.parse(raw)
    except InvalidEventError:
        logger.warning("reject: invalid event", exc_info=True)
        return Outcome.REJECT
    try:
        rank = deps.members.find_rank(event.member_id)
        if rank is None:                        # 退会済み・存在しない会員
            logger.warning("reject: unknown member %s", event.member_id)
            return Outcome.REJECT
        coupon = deps.coupons.issue_once(decide_coupon(event, rank))
        if coupon.notified:                     # 前回、通知まで終えてから ack 前に止まった
            return Outcome.ALREADY_DONE
        deps.push.send(
            event.member_id,
            f"お誕生日クーポン {coupon.amount_yen}円",
            idempotency_key=f"coupon-{coupon.coupon_id}",  # 送信済みなら送信側で捨てられる
        )
        deps.coupons.mark_notified(coupon.coupon_id)
    except TransientError:
        logger.warning("retry: transient failure", exc_info=True)
        return Outcome.RETRY
    return Outcome.ISSUED


class AckableMessage(Protocol):
    data: bytes

    def ack(self) -> None: ...
    def nack(self) -> None: ...


def make_callback(deps: Dependencies) -> Callable[[AckableMessage], None]:
    """Outcome を ack/nack に写す。想定外の例外も nack(ack して消さない)。"""
    def callback(message: AckableMessage) -> None:
        try:
            outcome = handle(message.data, deps)
        except Exception:
            logger.exception("unexpected error -> nack")
            message.nack()
            return
        if outcome in (Outcome.ISSUED, Outcome.ALREADY_DONE):
            message.ack()
        else:
            message.nack()
    return callback


# ── 常駐と停止 ────────────────────────────────────────────────
class Subscriber(Protocol):
    def subscribe(self, subscription: str, callback: Callable[[Any], None], **kwargs: Any) -> Any: ...


def run(subscriber: Subscriber, subscription: str, deps: Dependencies,
        *, flow_control: Any = None, drain_timeout_s: float = DRAIN_TIMEOUT_S) -> int:
    """SIGTERM まで購読し、受けたら排出(drain)して終了コードを返す。

    Returns:
        0 = 実行中のコールバックを待ち切って止まった。1 = 排出が時間切れ
        (残りは ack されず再配信される。冪等なので二重発行にはならない)。
    """
    stop = threading.Event()
    # PID 1 の python は、ハンドラを登録しない限り SIGTERM を無視する(実測: 30 秒後に SIGKILL)
    signal.signal(signal.SIGTERM, lambda _signum, _frame: stop.set())
    future = subscriber.subscribe(
        subscription, make_callback(deps),
        flow_control=flow_control,
        await_callbacks_on_shutdown=True,   # cancel 後、実行中コールバックの終了を待つ
    )
    logger.info("listening on %s", subscription)
    stop.wait()
    logger.info("SIGTERM: stop pulling, drain in-flight callbacks (<= %.0fs)", drain_timeout_s)
    future.cancel()                          # 新規受信を止め、未着手のメッセージは nack される
    try:
        future.result(timeout=drain_timeout_s)
    except TimeoutError:
        logger.error("drain timed out; unacked messages will be redelivered")
        return 1
    logger.info("drained; exit 0")
    return 0
"""本番の実装(Cloud SQL for PostgreSQL・push API・Pub/Sub)と起動口。"""
from __future__ import annotations

import logging
import os

import psycopg
import requests
from psycopg_pool import ConnectionPool

from coupon_issuer import (CALLBACK_THREADS, DB_POOL_TIMEOUT_S, DB_STATEMENT_TIMEOUT_S,
                           MAX_OUTSTANDING_MESSAGES, PUSH_CONNECT_TIMEOUT_S, PUSH_READ_TIMEOUT_S,
                           CouponDecision, Dependencies, IssuedCoupon, MemberRank, TransientError, run)

_INSERT_ONCE = """
INSERT INTO issued_coupons (member_id, campaign_id, amount)
VALUES (%(member_id)s, %(campaign_id)s, %(amount)s)
ON CONFLICT (member_id, campaign_id) WHERE voided_at IS NULL DO NOTHING
RETURNING coupon_id, amount, notified_at IS NOT NULL
"""
# INSERT と SELECT を1文(CTE)にしない: 同時発行で相手が後からコミットした行は、
# 同じ文の snapshot から見えず0行になる(実測: Postgres 17.11 で [] が返った)
_SELECT_EXISTING = """
SELECT coupon_id, amount, notified_at IS NOT NULL FROM issued_coupons
WHERE member_id = %(member_id)s AND campaign_id = %(campaign_id)s AND voided_at IS NULL
"""


def make_pool(dsn: str) -> ConnectionPool:
    """コールバックのスレッド数と同じ本数の接続。1本を共有しない(トランザクションが混ざる)。"""
    return ConnectionPool(
        dsn, min_size=2, max_size=CALLBACK_THREADS, timeout=DB_POOL_TIMEOUT_S,
        kwargs={"options": f"-c statement_timeout={int(DB_STATEMENT_TIMEOUT_S * 1000)}"},
    )


class PgMemberRepository:
    def __init__(self, pool: ConnectionPool) -> None:
        self._pool = pool

    def find_rank(self, member_id: int) -> MemberRank | None:
        try:
            with self._pool.connection() as conn:   # 抜けるとき commit / 例外なら rollback
                row = conn.execute("SELECT rank FROM members WHERE id = %s", (member_id,)).fetchone()
        except (psycopg.OperationalError, psycopg.errors.QueryCanceled) as e:
            raise TransientError(str(e)) from e
        if row is None:
            return None
        return MemberRank(row[0]) if row[0] in MemberRank else MemberRank.REGULAR


class PgCouponRepository:
    def __init__(self, pool: ConnectionPool) -> None:
        self._pool = pool

    def issue_once(self, d: CouponDecision) -> IssuedCoupon:
        params = {"member_id": d.member_id, "campaign_id": d.campaign_id, "amount": d.amount_yen}
        try:
            with self._pool.connection() as conn:
                row = conn.execute(_INSERT_ONCE, params).fetchone()
                if row is None:                        # 既に発行済み(再配信・同時発行)
                    row = conn.execute(_SELECT_EXISTING, params).fetchone()
        except (psycopg.OperationalError, psycopg.errors.QueryCanceled) as e:
            raise TransientError(str(e)) from e
        return IssuedCoupon(coupon_id=row[0], amount_yen=row[1], notified=row[2])

    def mark_notified(self, coupon_id: int) -> None:
        try:
            with self._pool.connection() as conn:
                conn.execute("UPDATE issued_coupons SET notified_at = now() "
                             "WHERE coupon_id = %s AND notified_at IS NULL", (coupon_id,))
        except (psycopg.OperationalError, psycopg.errors.QueryCanceled) as e:
            raise TransientError(str(e)) from e


class HttpPushSender:
    def __init__(self, url: str, session: requests.Session | None = None) -> None:
        self._url, self._session = url, session or requests.Session()

    def send(self, member_id: int, title: str, *, idempotency_key: str) -> None:
        try:
            resp = self._session.post(
                self._url, json={"member_id": member_id, "title": title},
                headers={"Idempotency-Key": idempotency_key},
                timeout=(PUSH_CONNECT_TIMEOUT_S, PUSH_READ_TIMEOUT_S),  # 無期限に待たない
            )
        except requests.RequestException as e:
            raise TransientError(str(e)) from e
        if resp.status_code >= 500 or resp.status_code == 429:
            raise TransientError(f"push {resp.status_code}")
        resp.raise_for_status()   # 4xx はバグ → 想定外の例外として nack、5回で dead letter


def main() -> None:
    from google.cloud import pubsub_v1  # 起動時だけ import(テストは Pub/Sub 無しで走る)

    logging.basicConfig(level=logging.INFO)
    pool = make_pool(os.environ["DB_DSN"])
    deps = Dependencies(PgMemberRepository(pool), PgCouponRepository(pool),
                        HttpPushSender(os.environ["PUSH_URL"]))
    code = run(pubsub_v1.SubscriberClient(), os.environ["SUBSCRIPTION"], deps,
               flow_control=pubsub_v1.types.FlowControl(max_messages=MAX_OUTSTANDING_MESSAGES))
    pool.close()
    logging.shutdown()
    os._exit(code)  # 排出が時間切れのとき、実行中スレッドの終了を待たずに抜ける


if __name__ == "__main__":
    main()
"""handle / make_callback の単体テスト。legacy の仕様化テストのうち、意図して変えた振る舞いを明示する。"""
from __future__ import annotations

import json

import pytest

from coupon_issuer import (CALLBACK_WORST_CASE_S, DRAIN_TIMEOUT_S, CouponDecision, Dependencies,
                           IssuedCoupon, MemberRank, Outcome, TransientError, handle, make_callback)


class FakeMembers:
    def __init__(self, ranks: dict[int, MemberRank]) -> None:
        self.ranks = ranks

    def find_rank(self, member_id: int) -> MemberRank | None:
        return self.ranks.get(member_id)


class FakeCoupons:
    """(member_id, campaign_id) 一意の発行台帳。本番は部分ユニーク索引 + ON CONFLICT。"""
    def __init__(self) -> None:
        self.rows: dict[tuple[int, str], IssuedCoupon] = {}

    def issue_once(self, d: CouponDecision) -> IssuedCoupon:
        key = (d.member_id, d.campaign_id)
        if key not in self.rows:
            self.rows[key] = IssuedCoupon(len(self.rows) + 1, d.amount_yen, notified=False)
        return self.rows[key]

    def mark_notified(self, coupon_id: int) -> None:
        for k, c in self.rows.items():
            if c.coupon_id == coupon_id:
                self.rows[k] = IssuedCoupon(c.coupon_id, c.amount_yen, notified=True)


class FakePush:
    def __init__(self, fail_times: int = 0) -> None:
        self.sent: list[str] = []
        self.fail_times = fail_times

    def send(self, member_id: int, title: str, *, idempotency_key: str) -> None:
        if self.fail_times:
            self.fail_times -= 1
            raise TransientError("push 503")
        self.sent.append(idempotency_key)


def deps(push: FakePush | None = None) -> Dependencies:
    return Dependencies(FakeMembers({42: MemberRank.GOLD, 7: MemberRank.SILVER}), FakeCoupons(), push or FakePush())


def ev(member_id=42, month="2026-10") -> bytes:
    return json.dumps({"member_id": member_id, "campaign_month": month}).encode()


def test_amount_table_unchanged_from_legacy():  # 仕様化テスト test_amount_by_rank と同じ表
    d = deps()
    handle(ev(42), d)
    handle(ev(7), d)
    assert [c.amount_yen for c in d.coupons.rows.values()] == [1_000, 500]


def test_campaign_month_comes_from_event_not_clock():  # 意図した変更 1(legacy: 処理時刻の UTC 月)
    d = deps()
    handle(ev(month="2026-10"), d)
    assert (42, "birthday-2026-10") in d.coupons.rows


def test_redelivery_issues_once_and_notifies_once():  # 意図した変更 2(legacy: 2回発行・2回通知)
    d = deps()
    assert handle(ev(), d) is Outcome.ISSUED
    assert handle(ev(), d) is Outcome.ALREADY_DONE
    assert len(d.coupons.rows) == 1 and d.push.sent == ["coupon-1"]


def test_push_failure_retries_without_second_coupon():  # 意図した変更 3(legacy: 5xx を成功扱いで ack)
    d = deps(FakePush(fail_times=1))
    assert handle(ev(), d) is Outcome.RETRY
    assert handle(ev(), d) is Outcome.ISSUED        # 再配信で通知だけやり直す
    assert len(d.coupons.rows) == 1 and d.push.sent == ["coupon-1"]


@pytest.mark.parametrize("raw", [b"{not json", b'{"member_id": 42}', b'{"member_id": -1, "campaign_month": "2026-10"}'])
def test_invalid_event_is_rejected(raw):
    assert handle(raw, deps()) is Outcome.REJECT


def test_unknown_member_is_rejected():
    assert handle(ev(member_id=404), deps()) is Outcome.REJECT


class Msg:
    def __init__(self, data: bytes) -> None:
        self.data, self.calls = data, []

    def ack(self) -> None:
        self.calls.append("ack")

    def nack(self) -> None:
        self.calls.append("nack")


@pytest.mark.parametrize(("raw", "expected"), [(ev(), ["ack"]), (b"{not json", ["nack"])])
def test_callback_maps_outcome_to_ack_or_nack(raw, expected):  # 意図した変更 4(legacy: 失敗も ack)
    m = Msg(raw)
    make_callback(deps())(m)
    assert m.calls == expected


def test_unexpected_exception_is_nacked():
    class Boom(FakeMembers):
        def find_rank(self, member_id):
            raise KeyError("bug")
    m = Msg(ev())
    make_callback(Dependencies(Boom({}), FakeCoupons(), FakePush()))(m)
    assert m.calls == ["nack"]


def test_drain_budget_fits_in_grace_period():
    TERMINATION_GRACE_PERIOD_S = 30  # deploy/coupon-issuer.yaml と同じ値
    assert CALLBACK_WORST_CASE_S == 18.0
    assert CALLBACK_WORST_CASE_S < DRAIN_TIMEOUT_S < TERMINATION_GRACE_PERIOD_S
箇所設計の考え方書籍の章
仕様化テスト11件 → 7件を別コミットで反転仕様化テスト・スモールステップ第15章
Protocol 3つを deps で渡す継ぎ目(seam)を作る・依存の差し替え第15章・第6-7章
handle() は Outcome を返し ack しないコマンド・クエリ分離(判断と副作用の分離)第13章
BirthdayEvent.parse・frozen dataclass完全コンストラクタ・値オブジェクト第3章
金額表・タイムアウトを名前付き定数にマジックナンバー排除、根拠をコメントに第10章・第12章
失敗を ack で握り潰さない例外の握り潰し禁止第10章

DB 移行・Deployment・サブスクリプション

-- 1) 列を足す(既存行は NULL。ロックは短い)
ALTER TABLE issued_coupons
  ADD COLUMN notified_at timestamptz,
  ADD COLUMN voided_at   timestamptz;

-- 2) 既存の重複241件を「削除せず」無効化する。使用済みの行を優先して残す
--    (使われたクーポンの記録は注文と結び付いているので消さない)
UPDATE issued_coupons c SET voided_at = now()
FROM (SELECT coupon_id,
             row_number() OVER (PARTITION BY member_id, campaign_id
                                ORDER BY used_at IS NULL, issued_at, coupon_id) AS rn
      FROM issued_coupons WHERE voided_at IS NULL) d
WHERE c.coupon_id = d.coupon_id AND d.rn > 1;

-- 3) 有効な行だけに一意制約(CONCURRENTLY で書き込みを止めない。トランザクション外で実行)
CREATE UNIQUE INDEX CONCURRENTLY uq_issued_coupons_member_campaign
  ON issued_coupons (member_id, campaign_id) WHERE voided_at IS NULL;

-- 4) 既存行は「通知済み」とみなす。ただし push ログとの突合で見つけた未通知58件は NULL のまま残し、
--    後でイベントを再 publish したときに「通知だけ」やり直されるようにする
UPDATE issued_coupons SET notified_at = issued_at
WHERE notified_at IS NULL AND voided_at IS NULL
  AND coupon_id NOT IN (SELECT coupon_id FROM ops.unnotified_coupons_20260925);

無効化した未使用の重複クーポンは、カート側でも使えないようにする必要がある(範囲外)。

apiVersion: apps/v1
kind: Deployment
metadata: { name: coupon-issuer, namespace: mops-promo }
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  selector: { matchLabels: { app: coupon-issuer } }
  template:
    metadata: { labels: { app: coupon-issuer } }
    spec:
      serviceAccountName: coupon-issuer          # Workload Identity で Pub/Sub・Cloud SQL へ
      # 排出予算 20 秒(コールバック最悪 18 秒 + ストリーム停止 2 秒)+ 余裕 10 秒。
      # 既定値と同じ 30 秒だが、根拠を coupon_issuer.py の定数と対にして明示する
      terminationGracePeriodSeconds: 30
      containers:
        - name: worker
          image: asia-northeast1-docker.pkg.dev/mops-prod/promo/coupon-issuer:1.4.0
          # exec 形式: python が PID 1 になり、SIGTERM を自分のハンドラで直接受ける
          command: ["python", "-m", "adapters"]
          env:
            - { name: SUBSCRIPTION, value: projects/mops-prod/subscriptions/birthday-coupon-issuer }
          resources:
            requests: { cpu: 250m, memory: 256Mi }
            limits:   { memory: 256Mi }
          # preStop は付けない: pull 型で Service の endpoints が無く、外すべき受信経路が無い
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: coupon-issuer, namespace: mops-promo }
spec:
  maxUnavailable: 1                              # ノードアップグレードで4台同時に排出しない
  selector: { matchLabels: { app: coupon-issuer } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: coupon-issuer, namespace: mops-promo }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: coupon-issuer }
  minReplicas: 2
  maxReplicas: 12
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 600            # 既定 300 秒。縮小131回/月の揺れを減らす
      policies: [{ type: Pods, value: 1, periodSeconds: 120 }]
resource "google_pubsub_subscription" "birthday_coupon_issuer" {
  name                 = "birthday-coupon-issuer"
  topic                = google_pubsub_topic.member_birthday.id
  ack_deadline_seconds = 30  # 初期値。実際の延長はクライアントのリース管理が行う

  retry_policy {             # 無いと nack は即時再配信(公式: 既定は immediate redelivery)
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.birthday_coupon_dlq.id
    max_delivery_attempts = 5  # 5〜100。REJECT も5回で DLQ に落ちる
  }
}

# Pub/Sub のサービスエージェントに、DLQ への publish と元サブスクリプションの subscribe を許可する
locals { pubsub_agent = "serviceAccount:service-${data.google_project.this.number}@gcp-sa-pubsub.iam.gserviceaccount.com" }

resource "google_pubsub_topic_iam_member" "dlq_publisher" {
  topic  = google_pubsub_topic.birthday_coupon_dlq.id
  role   = "roles/pubsub.publisher"
  member = local.pubsub_agent
}
resource "google_pubsub_subscription_iam_member" "dlq_subscriber" {
  subscription = google_pubsub_subscription.birthday_coupon_issuer.id
  role         = "roles/pubsub.subscriber"
  member       = local.pubsub_agent
}

実行例(実測)

検証環境: M1 Mac、Python 3.13.12、Docker(Colima)の python:3.12-slim と postgres:17-alpine(17.11)、google-cloud-pubsub 2.41.0。Pub/Sub 本体とは接続していない(停止の順番はフェイクで確認)。

① SIGTERM が効くか(docker stop -t 10、各1回)

起動のしかた止まるまで終了コード読み方
A: exec 形式の python、ハンドラなし(Bad と同じ)10.7 秒137PID 1 が無視 → 猶予切れで SIGKILL
B: sh -c "python ...; echo done"10.4 秒137sh が PID 1、python に転送しない
C: SIGTERM ハンドラを登録(本解答)1.0 秒0自分で止まれる
D: --init(tini が PID 1)0.2 秒143速いが python は既定動作で即死 → 排出なし

② 実 Postgres 17.11

(a) 接続1本を共有し、失敗後に rollback しない(legacy と同じ)
(1, 'issued')
(2, 'acked+lost: InvalidTextRepresentation')   ← 2件目だけ INSERT が失敗
(3, 'acked+lost: InFailedSqlTransaction')      ← 以後、正しいメッセージも全部失敗
(4, 'acked+lost: InFailedSqlTransaction')
(5, 'acked+lost: InFailedSqlTransaction')

(b) 部分ユニーク索引 + ON CONFLICT
  index before dedupe -> UniqueViolation         ← 重複を残したまま索引は作れない
  voided duplicates: 1 | kept: [(8, False), (9, True)]   ← 使用済みの行が残る
  attempt 1: RETURNING -> (5,)
  attempt 2: RETURNING -> None                   ← 2回目は何もしない

(c) 同じキーを2つのワーカーが同時に発行
  CTE 1 statement: second worker got -> []       ← 同じ文の snapshot から相手の行が見えない
  INSERT then SELECT: second worker got -> [(9,)]

adapters.PgCouponRepository で handle() を2回
1st: issued / 2nd: already_done / push sent: ['coupon-13']
rows: [(1, 'birthday-2026-10', 1000, True)] / statement_timeout via pool: ('3s',)

③ 改善後コードのテストと停止の順番

$ uv run --with pytest pytest -q test_coupon_issuer.py
12 passed in 0.04s

$ python shutdown_demo.py 2 20      # 1件2秒を3件実行中に SIGTERM、排出予算20秒(Pub/Sub はフェイク)
   106ms listening on projects/p/subscriptions/birthday
   406ms SIGTERM: stop pulling, drain in-flight callbacks (<= 20s)
   406ms cancel: stop pulling (acked so far=0)
  2111ms in-flight callbacks finished: acked=3
  2111ms drained; exit 0

$ python shutdown_demo.py 6 1       # 排出が時間切れ
  1418ms drain timed out; unacked messages will be redelivered   # os._exit で 1.64 秒・終了コード1
                                                                 # sys.exit だと実行中スレッドを待って 6.81 秒

④ 効果の見積もり 導出

項目Before(9月)After
1回の Pod 終了にかかる時間約30秒(全163回)最悪20秒、通常は数秒
重複発行241件/月0(同時発行も (c) で確認)
重複の原価241 × 平均510円 × 35% ≈ 4.3万円/月0
未発行・未通知19件・58件/月(消えて気づけない)nack → 最大5回 → DLQ に残る

金額は小さい。直す理由は「誕生日に何も来ない会員が月19人いて、誰も気づけない」こと。

後輩の提案への回答と、迷った案

「exactly-once + grace 600秒」は採らない。直す問題は7点中 0 個。
  • exactly-once でも二重発行は残る: 守るのは「ack 成功後は再配信しない」まで。commit 後・ack 前に止まれば正当に再配信される。公式も「ack 失敗時の重複処理は処理の進み具合の情報で防げ」と書く。同一リージョン接続のみ・ack_with_response() の確認も要る
  • grace 600秒は #1 を直さない: SIGTERM を無視している限り、伸ばした分だけ丸ごと待つ。デプロイ1回で 4 Pod × 600秒 = 40分(導出)
  • 「猶予を明示的に決める」発想は正しいので、根拠つきの30秒として残した
迷った案採らなかった理由前提が変わったら
tini(--init)を PID 1 に30秒待ちは消える(0.2秒)が python は即死、排出できない(143)。冪等化後なら応急処置にはなる子プロセスを起動するなら tini + ハンドラ
preStop で sleep 5pull 型には外すべき endpoints が無く、猶予を食うだけpush 購読(HTTP 受信)なら入れる
REJECT は ack して自前の失敗テーブルへ見る場所が2つになる。REJECT の nack は4回ムダだが月19件なら無視できる不正メッセージが大量なら即 DLQ publish
message_id で冪等化発行側が同じ会員を2回 publish すると message_id が別で防げない。業務キーなら防げる業務キーが作れないイベントなら message_id
INSERT と SELECT を CTE の1文に同時発行で相手の行が見えず0行(実測 (c))同時実行が起き得ないなら1文でも可
重複行を DELETE使用済みは注文と結び付いており、集計が変わる。voided_at で無効化未使用かつ保存義務なしなら削除も可
受信上限を既定1,000のまま1 Pod が抱え込み、処理が偏る。20件(スレッドの2倍)1件が数十ms に縮んだら上げる
HPA をバックログ指標に本来はこちらが合うが、今回は止め方と冪等性に絞った月初の一斉投入が続くなら次に着手

直した後に見るもの・止め方

指標正常こうなったら止める/戻す
Pod の終了コード0137 が1回でも出たら排出の時間切れかハンドラの不具合
DLQ のメッセージ数0〜数件/月1日10件超 → campaign_month 移行漏れか発行側のバグ
重複チェック(有効行の GROUP BY)0件1件でも出たら発行を止めて調べる
未通知(発行から1時間超)0件push 事業者の障害を確認
月初1日の処理完了00:05 から30分以内遅れたら受信上限かレプリカ数を見直す

ロールバック: 戻す順は 7 → 6 → 4。コミット5と DB 移行は戻さない(重複が再発する)。

ポイント解説

1仕様化テストは「正しさ」でなく「今」を固定する
バグも期待値に書き、直すときに1つずつ別コミットで反転。意図した変化とうっかりの変化が履歴で区別できる。
2Pod の止め方は3段
(a) 信号が届くか(PID 1)(b) 新規受信を止めて実行中を待てるか(cancel() + await_callbacks_on_shutdown)(c) 待つ時間に上限があるか(タイムアウトの和 < 猶予)。
3at-least-once では、止め方を直しても冪等性が要る
SIGKILL はいつか来る。副作用の前に業務キーで一意制約、通知には冪等キー。
4冪等キーはメッセージの中身だけから作る
処理時刻・乱数・Pod 名を混ぜると再配信でキーが変わる。月の決定は JST を知る発行側へ寄せた。

実務への応用

  • 他のワーカーにも3点検査: ① 終了コード 137 の割合 ② except 節の ack を grep ③ 副作用の前に一意制約があるか。Argo Workflows の長いステップも同じ仕組みで止まる
  • Cloud Run でも同じ: 停止時に SIGTERM → 猶予後 SIGKILL。PID 1 の python の問題はそのまま起きる
  • レビューの定型質問: 「この ack/commit の直前で止まったら何が起きるか」を副作用ごとに1行。#2・#4・#6 はこの1問で出る
  • dbt で重複検知: issued_coupons に unique_combination_of_columns(voided_at is null 条件付き)を置けば、索引が外れても翌朝気づける

扱わなかった範囲(と理由)

  • 無効化した重複クーポンをカート側でも使えなくする — 利用側システムの変更。無効化と同じ日に voided_at IS NULL を判定に足す
  • 未通知58件・未発行19件の救済 — 1回限りの運用。コミット5・6の後、該当会員のイベントを再 publish すれば「未発行は発行、未通知は通知だけ」になる
  • HPA のバックログ指標化(KEDA 等) — 次の着手候補として分けた
  • Pub/Sub との実接続 — フェイクで確認。cancel() が未着手を nack し、await_callbacks_on_shutdown=True で実行中を待つことは 2.41.0 のソース(StreamingPullManager._shutdown)で確認。本番前にステージングで Pod 削除して ack 数・再配信数を数える
  • ノード障害・OOM の SIGKILL — 止め方では防げず、冪等化だけで守る範囲
  • ordering key — 会員ごとに1件で順序に意味が無い

一次資料(参照日 2026-09-26)

  • Kubernetes「Container Lifecycle Hooks」 https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/ /「Pod Lifecycle」 https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/ — preStop は SIGTERM の前に完了、猶予は preStop の前から数える、既定30秒
  • pid_namespaces(7) https://man7.org/linux/man-pages/man7/pid_namespaces.7.html — "Only signals for which the "init" process has established a signal handler can be sent"
  • Google Cloud「Exactly-once delivery」 https://docs.cloud.google.com/pubsub/docs/exactly-once-delivery /「Dead-letter topics」 https://docs.cloud.google.com/pubsub/docs/handling-failures /「Subscription retry policy」 https://docs.cloud.google.com/pubsub/docs/subscription-retry-policy
  • google-cloud-pubsub 2.41.0 ソース — FlowControl 既定 max_messages=1000、ThreadPoolExecutor(max_workers=10)、shutdown で未着手を nack
  • PostgreSQL 17「INSERT」 https://www.postgresql.org/docs/17/sql-insert.html / psycopg 3「Connection pools」 https://www.psycopg.org/psycopg3/docs/advanced/pool.html
  • IETF draft-ietf-httpapi-idempotency-key-header / 仙塲大也『良いコード/悪いコードで学ぶ設計入門』第15章 / M. Feathers『レガシーコード改善ガイド』

今日のまとめ

テストの無いコードは、まず仕様化テストで「今」を固定し、1コミット1変更で期待値を意図して反転させながら直す。Pod を安全に止めるには「信号が届く・新規受信を止めて実行中を待つ・待つ時間に上限がある」の3段が要り、それでも SIGKILL は来るので、副作用は業務キーで冪等にするのが最後の防御線になる。

次のステップ

  • 発展問題: ブラックフライデーで「購入金額に応じた後日クーポン」(1会員が1日に何度も対象)を同じワーカーに載せる。業務キー、一意制約、KEDA の Pub/Sub スケーラでの HPA まで設計せよ(ピークは誕生日の40倍)
  • 参考: characterization test、golden master、seam、PID 1 signal handling、graceful shutdown、StreamingPull flow control、idempotency key、partial unique index、transactional outbox

自己評価(あとで記入)

自分の回答

気づき・メモ