A+B 複合 — ループネスト解消 × コレクションのカプセル化(注文キャンセル明細処理 Bad→Good 第9章)× order-cancellation-service Pub/Sub Push配信の信頼性設計(デッドレターキュー × Exactly-once × 順序保証 × OIDC検証)

2026-08-29 (Day 111) 土曜複合問題 ★★★★☆ Python 3.12 / frozenset / ファーストクラスコレクション / 第9章 GCP Pub/Sub / Cloud Run / デッドレターキュー

概要

🔁

リストの二重ループはfrozensetで解消する

SKU照合をlistへの線形探索で行うとO(n×m)になる。frozensetに変換するだけでO(n+m)に落ちる(第9章)。

📦

コレクションは生のlistで渡さずカプセル化する

get_items()が内部listをそのまま返すと、呼び出し元が自由に書き換えられる。操作はコレクション自身のメソッドとして持たせる(第9章)。

☠️

デッドレターキューがないと失敗メッセージが無限リトライされる

不正なペイロードなど「絶対に成功しないメッセージ」を専用トピックへ隔離しないと、正常メッセージの処理を圧迫し続ける。

🪪

at-least-onceが前提ならインフラ+アプリの二重防御

Exactly-once配信を有効化しても、アプリ側のmessage_idベース冪等性チェックは省略できない。

問題 A: コーディング — ループネスト解消 × コレクションのカプセル化(注文キャンセル明細処理 Bad→Good)

以下の「悪いコード」は、ECサイト MOps チームが運用する注文キャンセル処理(order-cancellation-service)の一部で、キャンセルされた注文明細のうち「フラッシュセール対象SKUと重複する明細」を抽出し、「高額商品(1万円超)の件数」を集計する処理です。SKU照合をリストの二重ループで行っており、さらに明細一覧を生のlistのまま外部に晒しているため、呼び出し元が中身を自由に書き換えられてしまいます。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第9章(コレクション: ループネスト解消、コレクションのカプセル化)を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+
  • find_flash_sale_cancelled_items のSKU照合を、リストの二重ループ(O(n×m))ではなくset/frozensetを使った照合(O(n+m))に変更すること
  • キャンセル明細一覧を生のlistとして外部に渡さず、ファーストクラスコレクション(明細一覧をラップした不変クラス)として扱い、「重複抽出」「閾値以上の件数集計」をそのクラス自身のメソッドとして提供すること
  • @dataclass(frozen=True, slots=True)を使い、キャンセル明細・明細一覧の両方を不変にすること
  • 高額商品の閾値(10,000円)は名前付き定数化すること
  • Google スタイル docstring・インラインコメント・型ヒントを含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before) — カテゴリ A

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_cancelled_items.py — ネストループ × カプセル化崩壊
def find_flash_sale_cancelled_items(cancelled_items, flash_sale_skus):
    result = []
    for item in cancelled_items:
        for sku in flash_sale_skus:  # 問題1: listに対するネストループでO(n*m)
            if item["sku"] == sku:
                result.append(item)
    return result


def count_high_value_items(cancelled_items):
    count = 0
    for item in cancelled_items:
        if item["price"] > 10000:  # 問題2: マジックナンバー10000
            count += 1
    return count


class CancelledOrderProcessor:
    def __init__(self):
        self.items = []  # 問題3: 生のlistをそのまま保持

    def add_item(self, item):
        self.items.append(item)  # 問題4: 重複チェックなし

    def get_items(self):
        return self.items  # 問題5: 内部listの参照をそのまま返す
問題点サマリー(7点)
1flash_sale_skus(list)への線形探索によるネストループ(Ch9) — 最悪n×m回の比較が発生
2閾値10000がマジックナンバー(Ch10) — 意味が名前から読み取れない
3itemsが生listとしてインスタンス変数に保持(Ch9) — コレクションが「ただの入れ物」扱い
4add_itemに重複チェックがない — 同一明細を何度でも追加できる
5get_items()が内部listの参照をそのまま返す(Ch9のカプセル化違反) — 外部から書き換え放題
6コレクション操作ロジックが独立関数に散在(Ch9) — 重複抽出・集計がCancelledOrderProcessorと無関係の場所にある
7型ヒントが一切なくitemの構造(dict)が不明瞭

ヒント A(段階的開示)

ヒント1 — 方向性
find_flash_sale_cancelled_items は「リストの中にリストの要素が含まれるか」を毎回線形探索しており、cancelled_itemsがn件、flash_sale_skusがm件あると最悪n×m回の比較が発生する。flash_sale_skusをset/frozensetに変換するだけで、item.sku in flash_sale_skusの判定が平均O(1)になり、全体でO(n+m)に落ちる。もう一つの問題は、CancelledOrderProcessor.get_items()が内部のlistインスタンスをそのまま返している点で、呼び出し元がprocessor.get_items().append(...)のように外部から中身を書き換えられてしまう。コレクションに対する操作(重複抽出・閾値集計)はコレクション自身の責務として持たせるべき、というのが第9章の要点。
ヒント2 — アプローチ
  • flash_sale_skus: list[str]を受け取ったら、関数の先頭でfrozenset(flash_sale_skus)に変換してからin判定に使う
  • CancelledItemとCancelledItems(複数形の名前でファーストクラスコレクションだと分かるようにする)を@dataclass(frozen=True, slots=True)で定義する
  • CancelledItemsにoverlapping_with(flash_sale_skus)とcount_above_price(threshold)をメソッドとして持たせる
  • _items: tuple[CancelledItem, ...]を非公開のフィールドにし、外部には__iter__・__len__のみ公開する
  • 閾値10000は_HIGH_VALUE_THRESHOLD_YEN: Final[int] = 10_000として定数化する
ヒント3 — コードの骨格
@dataclass(frozen=True, slots=True)
class CancelledItem:
    sku: str
    price: int
    qty: int

@dataclass(frozen=True, slots=True)
class CancelledItems:
    _items: tuple[CancelledItem, ...]

    def overlapping_with(self, flash_sale_skus: frozenset[str]) -> "CancelledItems":
        ...  # frozensetとの照合でO(n)に

    def count_above_price(self, threshold: int) -> int:
        ...

問題点分析 — カテゴリ A

#問題点分類改善方法
1flash_sale_skusへの線形探索でネストループループネスト Ch9frozensetでO(n+m)に
2閾値10000がマジックナンバーCh10名前付き定数化
3itemsが生listとして保持カプセル化 Ch9ファーストクラスコレクション化
4add_itemに重複チェックなし整合性of()生成時に重複SKUを正規化
5get_items()が内部listをそのまま返すカプセル化 Ch9__iter__/__len__のみ公開
6コレクション操作が独立関数に散在責務集約 Ch9CancelledItemsにメソッドとして集約
7型ヒントがなくitem構造が不明瞭可読性dataclassで型を明示

模範解答 A

Before — ネストループ・カプセル化崩壊
def find_flash_sale_cancelled_items(cancelled_items, flash_sale_skus):
    result = []
    for item in cancelled_items:
        for sku in flash_sale_skus:  # ネストループ
            if item["sku"] == sku:
                result.append(item)
    return result

class CancelledOrderProcessor:
    def __init__(self):
        self.items = []       # 生list
    def add_item(self, item):
        self.items.append(item)  # 重複チェックなし
    def get_items(self):
        return self.items     # 参照をそのまま返す
After — frozenset照合 × CancelledItemsに操作を集約
"""cancelled_items.py — ループネスト解消 × コレクションのカプセル化(Ch9)"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Final, Iterator

_HIGH_VALUE_THRESHOLD_YEN: Final[int] = 10_000


@dataclass(frozen=True, slots=True)
class CancelledItem:
    """キャンセルされた注文明細1行分(不変)。"""
    sku: str
    price: int
    qty: int


@dataclass(frozen=True, slots=True)
class CancelledItems:
    """キャンセル明細一覧のファーストクラスコレクション(不変)。

    生のlist/tupleを外部に渡さず、コレクションに対する操作(重複抽出・
    閾値集計)をメソッドとして提供することで、操作ロジックの散在と
    カプセル化崩壊を防ぐ。
    """
    _items: tuple[CancelledItem, ...]

    @classmethod
    def of(cls, items: list[CancelledItem]) -> "CancelledItems":
        """listからファーストクラスコレクションを生成する(重複SKUは1件に正規化)。"""
        seen_skus: set[str] = set()
        deduped: list[CancelledItem] = []
        for item in items:
            if item.sku in seen_skus:  # 修正4: 重複SKUの追加を防ぐ
                continue
            seen_skus.add(item.sku)
            deduped.append(item)
        return cls(_items=tuple(deduped))

    def overlapping_with(self, flash_sale_skus: frozenset[str]) -> "CancelledItems":
        """フラッシュセール対象SKUと重複する明細を抽出する(修正1: O(n)照合)。"""
        overlapped = tuple(i for i in self._items if i.sku in flash_sale_skus)
        return CancelledItems(_items=overlapped)

    def count_above_price(self, threshold: int = _HIGH_VALUE_THRESHOLD_YEN) -> int:
        """指定した価格を超える明細の件数を数える(修正6: 責務をコレクション側に集約)。"""
        return sum(1 for i in self._items if i.price > threshold)

    def __iter__(self) -> Iterator[CancelledItem]:  # 修正5: 生listを返さない
        return iter(self._items)

    def __len__(self) -> int:
        return len(self._items)
items = [
    CancelledItem(sku="SKU-A", price=12_000, qty=1),
    CancelledItem(sku="SKU-B", price=3_000, qty=2),
    CancelledItem(sku="SKU-C", price=15_000, qty=1),
    CancelledItem(sku="SKU-A", price=12_000, qty=1),  # 重複SKU
]
cancelled = CancelledItems.of(items)
print(len(cancelled))  # → 3 (重複SKU-Aが正規化され1件に)

flash_sale_skus = frozenset({"SKU-A", "SKU-C", "SKU-Z"})
overlap = cancelled.overlapping_with(flash_sale_skus)
print(len(overlap))               # → 2 (SKU-A, SKU-Cが該当)
print([i.sku for i in overlap])   # → ["SKU-A", "SKU-C"]

print(cancelled.count_above_price())  # → 2 (12,000円と15,000円が10,000円超)
ポイント適用した設計原則/パターン書籍対応章
flash_sale_skusをfrozensetに変換して照合ループネストの解消(O(n×m)→O(n+m))Ch9
CancelledItemsに重複抽出・集計メソッドを持たせるファーストクラスコレクションCh9
_itemsを非公開にし__iter__/__len__のみ公開コレクションのカプセル化Ch9
_HIGH_VALUE_THRESHOLD_YENの定数化マジックナンバー排除Ch10
CancelledItems.of()での重複SKU正規化不変コレクション生成時の整合性保証Ch9/Ch3
# tests/test_cancelled_items.py
import pytest
from cancelled_items import CancelledItem, CancelledItems


class TestCancelledItemsOf:
    def test_deduplicates_same_sku(self):
        items = [CancelledItem("SKU-A", 1000, 1), CancelledItem("SKU-A", 1000, 1)]
        assert len(CancelledItems.of(items)) == 1


class TestOverlappingWith:
    def test_returns_only_matching_skus(self):
        items = [CancelledItem("SKU-A", 1000, 1), CancelledItem("SKU-B", 1000, 1)]
        cancelled = CancelledItems.of(items)
        overlap = cancelled.overlapping_with(frozenset({"SKU-A"}))
        assert [i.sku for i in overlap] == ["SKU-A"]

    def test_does_not_mutate_original(self):
        items = [CancelledItem("SKU-A", 1000, 1)]
        cancelled = CancelledItems.of(items)
        cancelled.overlapping_with(frozenset({"SKU-A"}))
        assert len(cancelled) == 1  # 元のコレクションは不変のまま


class TestCountAbovePrice:
    def test_counts_only_above_threshold(self):
        items = [CancelledItem("SKU-A", 12_000, 1), CancelledItem("SKU-B", 3_000, 1)]
        assert CancelledItems.of(items).count_above_price() == 1

問題 B: インフラ — order-cancellation-service の Pub/Sub Push配信の信頼性設計(デッドレターキュー × Exactly-once × 順序保証)

問題Aの order-cancellation-service は Cloud Run 上で稼働し、order-events トピックからPush型サブスクリプションでキャンセルイベントを受信します。受信後、在庫引当の解放・発行済みクーポンの失効・顧客への通知メール送信を同期的に行いますが、以下の課題があります。

現状の課題:
  • サブスクリプションにデッドレターポリシーが設定されておらず、処理に失敗し続けるメッセージ(不正なペイロード・存在しない注文IDなど)が無限に再配信され続け、Cloud Runのリクエスト数・ログ量が異常に増加している
  • メッセージ順序保証(ordering key)が設定されていないため、同一注文に対して「キャンセル」イベントと直後の「返金確定」イベントが逆順で到着し、まだキャンセル処理が終わっていない注文に返金処理が実行される競合が発生した
  • ack_deadline_secondsがデフォルトの10秒のままで、在庫解放・クーポン失効・通知メール送信を同期的に行う処理(実測で平均12〜18秒)が終わる前にAckデッドラインが切れ、Pub/Sub側が「未処理」と判断して同じメッセージを再送し、二重処理が発生している
  • サブスクリプションがExactly-once配信を有効化しておらず、アプリケーション側にもメッセージIDベースの冪等性チェックがないため、再配信のたびに在庫解放・クーポン失効処理が重複実行される
  • Pushエンドポイント(Cloud Run)がPub/SubからのOIDCトークンを検証していないため、エンドポイントURLさえ知っていれば誰でも偽のキャンセルイベントを送り込める状態になっている
  • 再配信のリトライポリシー(backoff間隔)が未設定で、Pub/Subのデフォルト(即時リトライに近い間隔)のまま運用されており、downstream(在庫DB・クーポンサービス)が一時的に不調になった際に再送が殺到する
  • デッドレターキューへ滞留したメッセージ件数・処理失敗率を監視する仕組みがなく、poison messageが発生していても気づけない

要件

#要件
1デッドレターポリシーを設定し、最大配信試行回数(5回)を超えたメッセージを専用トピックへ隔離すること
2メッセージ順序保証を有効化し、order_idをordering keyとして使い、同一注文のイベントが到着順に処理されるようにすること
3ack_deadline_secondsを処理時間の実測値に対して十分な余裕(60秒)に引き上げること
4Exactly-once配信を有効化し、加えてアプリケーション側でもmessage_idベースの冪等性チェックを実装すること(インフラ側とアプリ側の二重の防御)
5PushエンドポイントでPub/SubのOIDCトークンを検証し、正規のPub/Subサービスアカウント以外からのリクエストを拒否すること
6リトライポリシーに指数バックオフ(最小/最大間隔)を設定すること
7デッドレターキューの滞留件数をCloud Monitoringで監視し、poison messageの発生を検知できるようにすること
8上記変更をTerraform(Pub/Subサブスクリプション + デッドレターポリシー + 監視)とPython(OIDC検証 + 冪等性チェック付きPushハンドラ)で実装すること
期待する回答形式: Terraform(Pub/Subサブスクリプション設定一式 + 監視) + Python(OIDC検証付きPushハンドラ + 冪等性チェック) + Bad vs Good 比較表 + 確認コマンド

ヒント B(段階的開示)

ヒント1 — 方向性
Pub/SubのPush配信は「Cloud RunがHTTP 200を返すまでがAckの猶予期間(ack_deadline_seconds)」という仕組みになっている。処理時間がこの猶予期間を超えると、Pub/Subは「処理が失敗した」と判断して同じメッセージを再送してしまう——これは通信障害ではなく設定不足が原因の「自作自演の二重配信」である。さらに、Pub/Subは仕組み上「少なくとも1回配信(at-least-once)」が原則であり、Exactly-once配信を有効化しても完全に重複がゼロになるわけではないため、インフラ側の対策とアプリケーション側の冪等性チェックは両方必要という前提で設計する。デッドレターポリシーが無いと、「絶対に成功しないメッセージ」が消えることなく永久にリトライされ続け、正常なメッセージの処理リソースを圧迫する。
ヒント2 — アプローチ
  • dead_letter_policy { dead_letter_topic = ..., max_delivery_attempts = 5 }をサブスクリプションに設定
  • enable_message_ordering = trueにし、Publish側でordering_key=order_idを指定してメッセージを送る
  • ack_deadline_seconds = 60に引き上げる(処理時間の実測値12〜18秒に対して十分な余裕)
  • enable_exactly_once_delivery = trueを設定しつつ、アプリ側でもmessage_idを記録し「既に処理済みか」を確認してから処理する
  • Push設定にoidc_token { service_account_email = ..., audience = ... }を指定し、Cloud Run側では受信したBearerトークンをgoogle.oauth2.id_token.verify_oauth2_tokenで検証する
  • retry_policy { minimum_backoff = "10s", maximum_backoff = "600s" }を設定する
ヒント3 — リソースの骨格
Pushハンドラの骨格(Python)
def handle_push(request):
    claims = verify_oidc_token(request)   # 修正5
    message = request.json()["message"]
    if not idempotency_store.mark_if_new(
        message["messageId"]
    ):                                    # 修正4
        return Response(status_code=200)
    process_cancellation(message)
    return Response(status_code=200)
サブスクリプションの骨格(Terraform)
resource "google_pubsub_subscription" "order_cancelled_sub" {
  name  = "order-cancelled-sub"
  ack_deadline_seconds         = 60
  enable_message_ordering      = true
  enable_exactly_once_delivery = true
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.dlq.id
    max_delivery_attempts = 5
  }
}

アーキテクチャ図 — デッドレターキュー × Exactly-once × 順序保証 × OIDC検証

order-events トピック order.cancelled / order.refunded 修正2: ordering_key=order_id 同一注文のイベント順序を保証 (publish側) order-cancelled-sub 修正2: enable_message_ordering=true 修正3: ack_deadline_seconds=60 修正4: enable_exactly_once_delivery=true 修正6: retry_policy(backoff 10s〜600s) push_config: endpoint=order-cancellation-service 修正5: oidc_token(service_account,audience) dead_letter_policy: 修正1: max_delivery_attempts=5 超過分はDLQへ隔離、無限リトライを遮断 Cloud Run: order-cancellation-service 修正5: OIDCトークン検証 (audience + service_account一致確認) 修正4: message_id冪等性チェック 在庫解放/クーポン失効/通知メール送信 200応答=Ack / 5xx=Pub/Subが再送 order-cancelled-dlq 5回失敗したpoison messageを隔離 正常メッセージの処理リソースを圧迫しない 手動調査 or 再投入の対象 Cloud Monitoring 修正7: DLQ滞留件数 > 0 でアラート PagerDuty通知 冪等性ストア message_id → 処理済み TTL 86,400秒 修正4補完: アプリ側の二重防御 再配信時は200を返し それ以上のリトライを止める Before → After Before: デッドレター/順序保証/OIDC検証なしのPush配信 デッドレターポリシー未設定 → poison messageが無限リトライ ordering key未設定 → キャンセル/返金イベントが逆順到着 ack_deadline=10秒 < 実測処理12〜18秒 → 自作自演の二重配信 Exactly-once未設定+アプリ冪等性チェックなし → 重複処理が発生 OIDCトークン未検証 → エンドポイントが偽装可能 リトライbackoff未設定 → downstream不調時に再送殺到 DLQ滞留の監視なし → poison message発生に気づけない After: 7つの信頼性対策で二重処理・偽装・無限リトライを遮断 dead_letter_policy(max_delivery_attempts=5)でDLQへ隔離 enable_message_ordering + ordering_key=order_idで順序保証 ack_deadline_seconds=60で処理完了前の再送を防止 Exactly-once + message_id冪等性チェックの二重防御 oidc_token検証でPub/Sub以外からのリクエストを拒否 retry_policy(指数バックオフ)でthundering herdを回避 Cloud MonitoringでDLQ滞留件数を常時監視 ✓ 「配信できた=処理済み」ではなく「処理が完了しAckされた」ことを保証する設計 ✓ at-least-once配信を前提に、インフラ層とアプリ層の二重防御で冪等性を担保 ✓ 「絶対に成功しないメッセージ」を隔離し、正常系の処理リソースを守る

模範解答 B

# 修正1: 失敗し続けるメッセージを隔離するデッドレタートピック
resource "google_pubsub_topic" "order_cancelled_dlq" {
  name = "order-cancelled-dlq"
}

resource "google_pubsub_subscription" "order_cancelled_sub" {
  name  = "order-cancelled-sub"
  topic = google_pubsub_topic.order_events.id

  ack_deadline_seconds         = 60    # 修正3: 実測処理時間(12〜18秒)に十分な余裕
  enable_message_ordering      = true  # 修正2: order_idごとの到着順処理を保証
  enable_exactly_once_delivery = true  # 修正4: インフラ側の重複配信抑制

  push_config {
    push_endpoint = "https://order-cancellation-service-xxxx.a.run.app/pubsub/push"
    oidc_token {  # 修正5: 正規のPub/Subサービスアカウントであることを証明
      service_account_email = google_service_account.pubsub_pusher.email
      audience               = "https://order-cancellation-service-xxxx.a.run.app"
    }
  }

  dead_letter_policy {  # 修正1
    dead_letter_topic     = google_pubsub_topic.order_cancelled_dlq.id
    max_delivery_attempts = 5
  }

  retry_policy {  # 修正6: 指数バックオフでdownstream不調時の再送殺到を回避
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }
}

# デッドレタートピックへの発行権限をPub/Subサービスエージェントへ付与
resource "google_pubsub_topic_iam_member" "dlq_publisher" {
  topic  = google_pubsub_topic.order_cancelled_dlq.id
  role   = "roles/pubsub.publisher"
  member = "serviceAccount:service-${var.project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

resource "google_pubsub_subscription_iam_member" "dlq_subscriber_ack" {
  subscription = google_pubsub_subscription.order_cancelled_sub.id
  role         = "roles/pubsub.subscriber"
  member       = "serviceAccount:service-${var.project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

# 修正7: デッドレターキューの滞留を監視し、poison messageを検知
resource "google_monitoring_alert_policy" "dlq_backlog" {
  display_name = "order-cancelled-dlq unacked message count"
  combiner      = "OR"
  conditions {
    display_name = "dlq backlog > 0"
    condition_threshold {
      filter          = "resource.type=\"pubsub_topic\" AND resource.labels.topic_id=\"order-cancelled-dlq\" AND metric.type=\"pubsub.googleapis.com/topic/send_message_operation_count\""
      comparison      = "COMPARISON_GT"
      threshold_value = 0
      duration        = "60s"
    }
  }
  notification_channels = [google_monitoring_notification_channel.mops_pagerduty.id]
}
"""order_cancellation_push_handler.py — OIDC検証(修正5) × 冪等性チェック(修正4)"""
from __future__ import annotations

import base64
import json
from dataclasses import dataclass
from typing import Final

from fastapi import FastAPI, HTTPException, Request, Response
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token

app = FastAPI()

_EXPECTED_AUDIENCE: Final[str] = "https://order-cancellation-service-xxxx.a.run.app"
_EXPECTED_SERVICE_ACCOUNT: Final[str] = "pubsub-pusher@mops-prod.iam.gserviceaccount.com"
_IDEMPOTENCY_TTL_SECONDS: Final[int] = 86_400


@dataclass(frozen=True, slots=True)
class CancellationEvent:
    """order-eventsトピックから受信するキャンセルイベント(不変)。"""
    order_id: str
    message_id: str


def _verify_pubsub_oidc_token(request: Request) -> None:
    """Pub/Subからの正規リクエストであることをOIDCトークンで検証する(修正5)。

    Args:
        request: Cloud Runが受け取ったHTTPリクエスト。

    Raises:
        HTTPException: トークンが無効、またはaudience/service accountが不一致の場合。
    """
    auth_header = request.headers.get("authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=403, detail="missing bearer token")

    token = auth_header.removeprefix("Bearer ").strip()
    try:
        claims = id_token.verify_oauth2_token(
            token, google_requests.Request(), audience=_EXPECTED_AUDIENCE
        )
    except ValueError as exc:
        raise HTTPException(status_code=403, detail="invalid oidc token") from exc

    if claims.get("email") != _EXPECTED_SERVICE_ACCOUNT:
        raise HTTPException(status_code=403, detail="unexpected service account")


def _parse_event(envelope: dict) -> CancellationEvent:
    """Pub/Sub Pushのエンベロープからキャンセルイベントを取り出す。"""
    message = envelope["message"]
    payload = json.loads(base64.b64decode(message["data"]))
    return CancellationEvent(order_id=payload["order_id"], message_id=message["messageId"])


@app.post("/pubsub/push")
async def handle_push(request: Request) -> Response:
    """Pub/Sub Pushエンドポイント(OIDC検証 + 冪等性チェック付き)。

    Returns:
        処理成功時は200(Ack)。処理失敗時は5xx(Pub/Subが自動リトライ)。
    """
    _verify_pubsub_oidc_token(request)  # 修正5

    envelope = await request.json()
    event = _parse_event(envelope)

    if not idempotency_store.mark_if_new(event.message_id, ttl=_IDEMPOTENCY_TTL_SECONDS):  # 修正4
        # 既に処理済み(再配信) → 200を返してAckし、これ以上のリトライを止める
        return Response(status_code=200)

    process_cancellation(event.order_id)  # 在庫解放・クーポン失効・通知メール送信
    return Response(status_code=200)

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
デッドレターポリシー未設定、失敗メッセージが無限リトライされ続けるmax_delivery_attempts=5で専用トピックへ隔離
順序保証未設定、キャンセル→返金の順序が逆転しうるenable_message_ordering=true + ordering_key=order_id
Ackデッドラインデフォルト10秒(実測処理時間12〜18秒に対し不足)60秒に引き上げ、処理完了前の再送を防止
重複配信対策Exactly-once未設定、アプリ側も冪等性チェックなしExactly-once配信 + message_idベースの冪等性チェック(二重防御)
Pushエンドポイントの認証OIDCトークン未検証、URLを知れば誰でも送信可能OIDCトークン検証 + サービスアカウント一致確認
リトライ間隔デフォルト(即時に近い)、downstream不調時に再送殺到指数バックオフ(最小10秒〜最大600秒)
可観測性デッドレター滞留・処理失敗率を監視する手段がないCloud Monitoringでデッドレターキュー滞留を監視

確認コマンド

# 1. デッドレターポリシーが設定されていることを確認
gcloud pubsub subscriptions describe order-cancelled-sub \
  --format="value(deadLetterPolicy.deadLetterTopic,deadLetterPolicy.maxDeliveryAttempts)"
# Expected: projects/mops-prod/topics/order-cancelled-dlq  5

# 2. メッセージ順序保証が有効なことを確認
gcloud pubsub subscriptions describe order-cancelled-sub \
  --format="value(enableMessageOrdering)"
# Expected: True

# 3. 冪等性チェックにより同一message_idの二重処理がスキップされることを確認
python -c "
from order_cancellation_push_handler import idempotency_store
print(idempotency_store.mark_if_new('msg-001'))  # Expected: True (初回)
print(idempotency_store.mark_if_new('msg-001'))  # Expected: False (2回目はスキップ)
"

# 4. OIDCトークンなしのリクエストが403で拒否されることを確認
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  https://order-cancellation-service-xxxx.a.run.app/pubsub/push \
  -H "Content-Type: application/json" -d '{"message":{"data":"e30=","messageId":"1"}}'
# Expected: 403

# 5. デッドレターキューの滞留アラートが設定されていることを確認
gcloud alpha monitoring policies list --filter="displayName:order-cancelled-dlq" \
  --format="value(displayName,enabled)"
# Expected: order-cancelled-dlq unacked message count  True

ポイント解説

カテゴリ A

1 リストに対するin判定は要素数に比例した線形探索になる(Ch9)
flash_sale_skusをfrozensetに変換するだけで判定が平均O(1)になり、二重ループ全体がO(n×m)からO(n+m)へ改善する。コレクションの「型」を用途に合わせて選ぶこと自体が重要な設計判断であるという点がこの章の核心。
2 生のコレクション(list/tuple)を外部にそのまま返すと、カプセル化が崩れる(Ch9)
get_items()が内部のlistを返していたため、呼び出し元がコレクションの整合性を無視して自由に書き換えられた。ファーストクラスコレクションはコレクションを「値」として扱い、変更は常に新しいインスタンスを返すことで防御する。
3 コレクションに対する操作は、コレクション自身の責務として集約すべき(Ch9)
find_flash_sale_cancelled_itemsやcount_high_value_itemsのような「コレクションを受け取って何かを返す」関数が外部に散らばると、同じコレクションに対する操作ルール(重複除去など)がそれぞれの関数で異なってしまうリスクがある。CancelledItemsにメソッドとして持たせることで、操作の一貫性が保証される。

カテゴリ B

4 Push配信のAckデッドラインは「処理が実際にどれだけ時間がかかるか」を実測した上で設定する(修正3)
デフォルト値のまま運用すると、正常な処理が「タイムアウトによる失敗」として扱われ、システムが意図せず二重処理を引き起こす。
5 at-least-once配信が前提のPub/Subでは、Exactly-once配信を有効化してもアプリケーション側の冪等性チェックを省略してはいけない(修正4)
インフラ側の保証とアプリケーション側の保証は異なるレイヤーの防御であり、両方揃って初めて「実質的に重複が起きない」状態になる。
6 Pushエンドポイントは「URLを知っている人なら誰でも叩ける」ことを前提に、OIDCトークンで送信元を検証する(修正5)
Pub/SubのPush配信はCloud RunのIAM認証を経由しない構成にすることも多く、アプリケーション層での検証が必須になる。
7 デッドレターキューは「設定するだけ」では不十分で、滞留を監視して初めて意味を持つ(修正7)
監視がなければ、poison messageが黙って積み上がっていることに誰も気づけない。

実務への応用

  • ファーストクラスコレクションパターン(CancelledItemsのように、生のlistを渡さずコレクション自身に操作メソッドを持たせる設計)は、注文明細・カート内商品・クーポン適用対象リストなど、MOpsチームが扱う「複数件のドメインオブジェクトの集まり」全般に横展開できる: 「このリストに対する操作が複数箇所に重複していないか」をコードレビューの観点に加えると発見しやすい
  • set/frozensetによるO(1)照合への置き換えは、キャンペーン対象SKU判定・許可リスト(allowlist)チェック・重複排除など「大量データに対する所属判定」を伴う処理全般に適用できる: 特にセール時のようにデータ件数が跳ね上がる局面で効果が顕著になる
  • Pub/SubのAckデッドラインは、downstream処理の実測レイテンシ(p99)を基準に設定し、処理時間が変化するたびに見直す運用にする: 処理時間が伸びた場合にAckデッドラインを追随させないと、負荷増加時に二重処理という別の障害を誘発する
  • デッドレターキュー + アプリ側冪等性チェックの組み合わせは、Cloud Tasks・Eventarcなど他のat-least-once配信基盤全般で同じ設計パターンとして再利用できる: 決済確定・在庫解放・通知送信など「二重実行が事故につながる処理」には常にこの二重防御を検討する

今日のまとめ

カテゴリAでは、リストの二重ループでSKU照合していたキャンセル明細処理を、frozensetによるO(n+m)照合と、生のlistを外部に晒さないCancelledItemsというファーストクラスコレクションに置き換えた(第9章)。

カテゴリBでは、そのイベントを配信するorder-cancellation-serviceのPub/Sub Push受信を、デッドレターポリシー・順序保証・Ackデッドライン延長・Exactly-once配信・OIDC認証・指数バックオフ・監視という7つの観点で堅牢化した。両者に共通するのは「境界(コレクションの外部公開、メッセージ配信の信頼性)を曖昧にしたまま処理を書くと、想定していない経路から状態が壊される」という視点である。

次のステップ

  • 発展問題: CancelledItemsに「同一注文内で数量(qty)が同じSKUをマージする」機能を、不変性を崩さずに追加する(with_merged_duplicates()のような新しいインスタンスを返すメソッドとして設計せよ)
  • 発展問題: Push型サブスクリプションではなくPull型(Cloud Run Jobs + StreamingPull)に変更した場合、Ackデッドライン管理・冪等性チェックの設計はどう変わるかを比較検討する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第9章(コレクション)、Google Cloud公式ドキュメント「Pub/Sub のデッドレタートピック」「Push サブスクリプションの認証」、Pub/Sub Exactly-once配信ガイド

自己評価(あとで記入)

自分の回答

気づき・メモ