概要
直す前に「今」を固定する
仕様化テストはバグも期待値に書く。直すときに期待値を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-pubsub2.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時間捨てる(仮定) - 発行側スケジューラは改修できる(イベントに項目を足せる)
悪いコード (Before)
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"]
ヒント(段階的開示)
ヒント1 — 方向性
ヒント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点)
| # | 問題 | 起きていること | 根拠 |
|---|---|---|---|
| 1 | PID 1 の python が SIGTERM を無視 | 削除のたび30秒待って SIGKILL、処理中は ack されない | 前提 163/163 が 137。実測 docker stop -t 10 → 10.7 秒・137 |
| 2 | 冪等でない素の INSERT | commit 後・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 削除からの秒数。Before の「INSERT → commit ✓ の後、ack 前に SIGKILL」が二重発行の正体。After は「止め方」(上段)と「やり直し方」(下の枠)の2段で守る。
構造(第15章で切り出した継ぎ目)
仕様化テスト(直す前のコードに対して緑)
第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件) | デプロイ順の制約 |
|---|---|---|---|---|
| 0 | test: 仕様化テスト11件 | テスト | 11件緑 | — |
| 1 | refactor: 金額を COUPON_AMOUNT_YEN_BY_RANK へ | 構造 | 11件緑 | — |
| 2 | refactor: handle()→Outcome と ack アダプタに分割、DB・push・時計を引数へ | 構造 | 11件緑 | — |
| 3 | fix: 月をイベントの campaign_month から | 振る舞い | 反転1: campaign_month | 先に発行側に項目追加 → oldest_unacked_message_age が発行側デプロイからの経過時間より小さくなるのを待つ → ワーカー |
| 4 | fix: 失敗は nack、5回で DLQ | 振る舞い | 反転3: failures ×3 | 先に DLQ と retry policy(無いと nack は即時再配信で回り続ける) |
| 5 | fix: (member_id, campaign_id) で1枚だけ | 振る舞い | 反転2: twice, side_effect_order | 先に DB 移行(重複の無効化 → 部分ユニーク索引) |
| 6 | fix: push 5xx は RETRY、Idempotency-Key | 振る舞い | 反転1: push_5xx | — |
| 7 | feat: SIGTERM で排出、受信上限20件、接続プール | 停止時 | 金額表4件は緑のまま | Deployment と同時 |
反転は合計7件、変えなかったのは金額表の4件。その4件が最後まで緑であることが「告知済みの金額を変えていない」証拠。
模範解答(Bad → Good)
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() 止まれない
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 秒 | 137 | PID 1 が無視 → 猶予切れで SIGKILL |
B: sh -c "python ...; echo done" | 10.4 秒 | 137 | sh が 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 でも二重発行は残る: 守るのは「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 5 | pull 型には外すべき 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 の終了コード | 0 | 137 が1回でも出たら排出の時間切れかハンドラの不具合 |
| DLQ のメッセージ数 | 0〜数件/月 | 1日10件超 → campaign_month 移行漏れか発行側のバグ |
| 重複チェック(有効行の GROUP BY) | 0件 | 1件でも出たら発行を止めて調べる |
| 未通知(発行から1時間超) | 0件 | push 事業者の障害を確認 |
| 月初1日の処理完了 | 00:05 から30分以内 | 遅れたら受信上限かレプリカ数を見直す |
ロールバック: 戻す順は 7 → 6 → 4。コミット5と DB 移行は戻さない(重複が再発する)。
ポイント解説
バグも期待値に書き、直すときに1つずつ別コミットで反転。意図した変化とうっかりの変化が履歴で区別できる。
(a) 信号が届くか(PID 1)(b) 新規受信を止めて実行中を待てるか(
cancel() + await_callbacks_on_shutdown)(c) 待つ時間に上限があるか(タイムアウトの和 < 猶予)。SIGKILL はいつか来る。副作用の前に業務キーで一意制約、通知には冪等キー。
処理時刻・乱数・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日に何度も対象)を同じワーカーに載せる。業務キー、一意制約、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