冪等性の設計〜等値性の判定場所をユースケース駆動で決める

data-rigistry開発の前回記事からだいぶ間が空いてしまったが、久しぶりに時間をとれたので冪等性について考えてみる。

はじめに

資源計算に利用するデータを登録するためのサービスdata-registryを作っている。

下記は前回の記事。

上の記事では初期設計をやってみたのだが、反省点がいくつかあった:

  • 要件定義が雑だった
  • スコープを決めていなかった
  • 進め方の戦略がなかった

そこで本記事では、前回の反省を活かし、「取り組むスコープを決めた上で」「ユースケース駆動で要件・仕様を吟味」する「という方法で進める」ことにする。

本記事で扱う問題のスコープは下記の通り:

  • 等値性の定義
  • ユースケースの整理
  • 冪等性キーの生成場所と等値性の判定場所の決定

冪等性が必要な理由

冪等性が必要な本質的な理由は2つ:

  1. クライアント実装の簡潔性

    • 再試行時に「同じリクエストを送ってもいい」という保証があれば、クライアント側で「送信完了か確認不可か」を区別する必要がない
    • 再試行戦略が単純になる
  2. 並行処理時の安全性

    • 複数プロセスが同じデータを登録する場合、競合を避けて結果の予測可能性を確保
    • 「どのRevisionが実際に採用されるのか」が明確になる

特に、意図的な再試行ではなく、並行リクエストやネットワークエラーによる自動再試行への対策のために導入する。

等値性の定義

サービスの冪等なふるまいを設計していくにあたり、まずはどんなときに冪等性が保証されるべきか: つまりdata-registryが扱うペイロードの等値性について考えてみる。

このサービスの性質を考えれば、登録対象のデータそのものの等値性が考慮される必要があることは自明。 また、等値性を考えるうえでのヒントとして、既存のシステム要件 SR004 を考慮するとよい:

SR004 新しい版は、既存の版を0個または1個参照しなければならない

データ登録要求の仕様は決まっていないものの、新しい版を紐づける先の parentRevision の値については、少なくとも等値性に関わる情報と考えてよさそう。

上記から、data-registryにおける等値性の定義は下記の通りとする:

等値とみなすリクエスト = hash(parentRevisionId, hash(data))が同じリクエスト

ユースケースの明確化

ペイロードの等値性をひとまず定義できたところで、続いて冪等性に関するユースケースをまとめていく。

リクエスト処理時のユースケース

まず、data-registryのクライアント側で発生する機能的なユースケースを考えてみる。

並行リクエストを冪等に処理する

  • 概要: 複数のプロセスから並行で同一の内容のリクエストが届いた場合に呼び出される
  • 期待: リクエストが一つだけ届いた場合と同様の状態になる
  • アクター: プロセスA、プロセスB
  • メインフロー:
    • 1: プロセスAが、システムにデータの登録を要求する
    • 2: プロセスBが、システムにデータの登録を要求する
    • 3: システムは、プロセスAに要求されたデータを永続化する
    • 4: システムは、永続化が成功した旨のメッセージをプロセスAに返却する
    • 5: システムは、プロセスBの要求がプロセスAと同一であることを検知する
    • 6: システムは、永続化はせずに、プロセスBに対してプロセスAと同一の内容を返却する

ネットワークエラーによる再試行で冪等性を保証する

  • 概要: クライアントがリクエストを送信したが、ネットワークエラーによってレスポンスが返らず、クライアント側でタイムアウト後に同じリクエストを再送する
  • 期待: リクエストが一度だけ送信された場合と同じ状態になる
  • アクター: クライアント、システム
  • メインフロー:
    • 1: クライアントが、識別可能な形式でシステムにデータの登録を要求する
    • 2: システムは、リクエストを受け取り処理を開始する
    • 3: システムは、新しいRevisionを作成する
    • 4: ネットワークエラーが発生し、レスポンスがクライアントに到達しない
    • 5: クライアントがタイムアウトし、同じ内容で再度リクエストを送信する
    • 6: システムは、該当するリクエストが既に処理されていることを認識する
    • 7: システムは、既に登録済みのRevisionをクライアントに返却する(新たな登録は行わない)

データの内容は同じだが異なる親Revisionに対して登録する

  • 概要: データの内容は同じだが、異なる親Revisionに対して登録要求が届く
  • 期待: 各要求は異なるものとして扱われ、別のRevisionが作成される
  • アクター: プロセスA、プロセスB
  • メインフロー:
    • 1: プロセスAが、親Revision-1を指定してデータを登録する
    • 2: システムは、(親Revision-1, データ) に対応するRevisionを作成する
    • 3: プロセスBが、親Revision-2を指定して同じデータを登録する
    • 4: システムは、これら2つのリクエストが異なることを認識する(親が異なるため)
    • 5: システムは、(親Revision-2, データ) に対応する別のRevisionを作成する(二重登録ではなく、論理的に異なる履歴)

複数の言語で実装されたクライアントからのリクエストを処理する

  • 概要:Go、Python、JavaScript など複数の言語でクライアントが実装される。システム全体で、同じ(親Revision, データ)に対しては同じ識別キーが割り当たる必要がある
  • 期待: 実装言語に関わらず、同じ仕様に従えば同じキーが生成される
  • アクター:言語Aで実装されたクライアント、言語Bで実装されたクライアント、サーバー
  • メインフロー:
    • 1: 言語Aのクライアントが、ある仕様に基づいて識別キーを生成する
    • 2: 言語Bのクライアントが、同じ仕様に基づいて、同じ(親Revision, データ)に対して識別キーを生成する

運用時のユースケース

識別キー計算仕様を変更する

  • 概要: 運用中にセキュリティ向上などの理由で、識別キーの計算方法(例:ハッシュアルゴリズム)を変更する
  • 期待: 全ての識別キー計算方法を更新できる
  • 前提: デプロイによるコンテナ入れ替え時の新旧クライアント混在の影響は考えない
  • 識別キーをクライアント側で生成する場合:
    • アクター: システム管理者、クライアントAの開発者、クライアントBの開発者
    • メインフロー:
      • 1: システム管理者は、クライアントAの開発者とクライアントBの開発者に対して識別キーの計算方法の更新を要求する
      • 2: クライアントAの開発者は、クライアントAの識別キー計算方法を更新する
      • 3: クライアントAの開発者は、システムに対して新しいクライアントAのデプロイを要求する
      • 4: システムは、新しいクライアントAをデプロイする
      • 5: クライアントBの開発者は、クライアントBの識別キー計算方法を更新する
      • 6: クライアントBの開発者は、システムに対して新しいクライアントAのデプロイを要求する
      • 7: システムは、新しいクライアントBをデプロイする
  • 識別キーをサーバー側で生成する場合:
    • アクター: システム管理者、サーバー開発者
    • メインフロー:
      • 1: システム管理者は、サーバー開発者に対して識別キーの計算方法の更新を要求する
      • 2: サーバー開発者は、サーバー内における識別キー計算方法を更新する
      • 3: サーバー開発者は、システムに対して新しいサーバーのデプロイを要求する
      • 4: システムは、新しいサーバーをデプロイする

複数の識別キー計算仕様に対応する

  • 概要: 運用中にセキュリティ向上などの理由で、識別キーの計算方法(例:ハッシュアルゴリズム)を変更する必要が出た場合、システムが円滑に移行できる
  • 前提: クライアントの実装言語は一つ(組み合わせ爆発回避のため)
  • 期待: 計画的な移行により、冪等性を失わずに仕様を変更できる
  • 識別キーをクライアント側で生成する場合:
    • サーバーメンテナンス(停止)期間を設けない場合:
      • 前提: クライアントの識別キーの計算方法は実装済みで、デプロイ待ちの状態
      • アクター: サーバー開発者、サーバー、クライアント開発者、エンドユーザーA、エンドユーザーB、ハッシュアルゴリズムAを使う古いクライアント、ハッシュアルゴリズムBを使う新しいクライアント、
      • メインフロー:
        • 1: サーバー開発者は、新旧2種類のハッシュアルゴリズムに対応するようにサーバを変更する
        • 2: サーバー開発者は、システムに対して新しいサーバーのデプロイを要求する
        • 3: システムは、新しいサーバーをデプロイする
        • 4: サーバー開発者は、クライアント開発者に識別キーの計算方法を更新したクライアントをデプロイしても良い旨を連絡する
        • 5: クライアント開発者は、システムに対して識別キーの計算方法を更新したクライアントのデプロイを要求する
        • 6: システムは、クライアントを入れ替え始める
        • 7: エンドユーザーAが、システムに対してデータの登録を要求する
        • 8: システムは、エンドユーザーAの要求を新しいクライアントに振り分ける
        • 9: 新しいクライアントは、サーバーに対してデータの登録を要求する
        • 10: サーバーは新しいクライアントからのデータを永続化し、永続化が成功した旨のメッセージを新しいクライアントに返却する
        • 11: エンドユーザーAは、データが登録されたことを確認する
        • 12: エンドユーザーBが、システムに対してエンドユーザーAと同一の内容で登録要求する
        • 13: システムは、エンドユーザーBの要求を古いクライアントに振り分ける
        • 14: 古いクライアントは、サーバーに対してデータの登録を要求する
        • 15: サーバーは、古いクライアントからの要求内容が処理済みの要求と同一と判定し、新しいクライアントに返却したものと同じメッセージを古いクライアントに返却する
        • 16: エンドユーザーBは、データが登録されたことを確認する
        • 17: システムは、クライアントの入れ替えを完了する
    • サーバーメンテナンス(停止)期間を設ける場合:
      • アクター: サーバー開発者、クライアント開発者
      • メインフロー:
        • 1: サーバー開発者は、システムに対してサーバーの停止を要求する
        • 2: システムは、サーバーを停止する
        • 4: サーバー開発者は、クライアント開発者に識別キーの計算方法を更新したクライアントをデプロイしても良い旨を連絡する
        • 5: クライアント開発者は、システムに対して識別キーの計算方法を更新したクライアントのデプロイを要求する
        • 6: システムは、クライアントを入れ替え始める
        • 7: システムは、クライアントの入れ替えを完了する
        • 8: クライアント開発者は、サーバー開発者に対してクライアントのデプロイが完了した旨を連絡する
        • 9: サーバー開発者はシステムに対してサーバーの再稼働を要求する
        • 10: システムは、サーバーを再稼働させる
  • 識別キーをサーバー側で生成する場合:
    • メインフロー:
      • 1: サーバー開発者は、サーバー内の識別キー計算方法を更新する
      • 2: サーバー開発者は、識別キーの計算方法が更新されたサーバーのデプロイをシステムに要求する
      • 3: システムは新しいサーバーをデプロイする

冪等性キーの生成場所と等値性の判定場所の決定

上で洗い出したユースケースをもとに、冪等性キーの生成場所と等値性の判定場所について考えていく。

冪等性キーの生成場所について、サーバー側・クライアント側それぞれを選択した場合のメリット・デメリットは下記の通り:

  • サーバー側で生成した場合
    • pros:
      • 冪等性キーの生成ロジックの更新などの運用タスクがサーバー側だけで完結する✓
    • cons:
      • 実際には等値と判定されるペイロードも、クライアント側で冪等処理されずサーバーにリクエストされる✗
        • 同値のペイロードが大量にリクエストされた場合にDoSとなりうる
          • 緩和条件:
            • エンドポイントをprivateに保った場合
            • リクエスト数が少ない場合
  • クライアント側で生成した場合
    • pros:
      • 同値のペイロードに対する処理を、サーバーにリクエストすることなくクライアント側で完結させられる(キャッシュを保持する場合)✓
    • cons:
      • 冪等性キーの生成ロジックやライブラリを更新する場合、クライアントの種数ぶんの更新タスクが発生する✗
        • クライアントの実装言語が複数ある場合、運用工数はさらに大きくなる
        • 緩和条件:
          • 冪等性キーの生成ロジックを共通サービスから取得した場合
            • 費用対効果が見合わない✗

上記から、両方法にはパフォーマンス上および運用上のメリット・デメリットがあることがわかった。

資源評価プラットフォームの性質を考慮すれば、そもそもリクエスト数はそれほど多くならないと考えられるため、サーバー側で生成する方法のデメリットである、リクエスト集中時のスループットの悪さはほとんど無視できると思われる。 加えて、将来的には資源計算の入力データだけでなく、出力を保存するユースケースも考えうる: つまりクライアント種が増える可能性も考えれば、サーバーで生成する方法の運用上のメリットが重要となる。 もちろん、出力を保存するサービスを専用の新サービスとして開発する構成も考えうるが、data-registryはその名前が示すように既に充分抽象化されているように思う。

上記を考え併せて、現時点では、冪等性キーはサーバー側で生成する方法を選ぶことにする。

冪等性キーがサーバー側で生成されるとなれば、等値性判定もサーバー側に集約するのが自然だろう。

まとめ

本記事では、前回の反省を活かして必要十分にまとめられたように思う。

今回の主役であった冪等性については、過去記事の時点でシステム要件 SR005 として定義してあった:

SR005: 同一の登録要求識別子を持つ登録要求に対しては同一の結果を返し、新たな版を重複して作成してはならない

本記事で下した冪等性キーの生成や等値性判定を行う場所についての意思決定は既存の SR005 には反映されないものの、アーキテクチャに関する意思決定としてdata-registryのADRとした。

現時点におけるdata-registry開発の進捗はこんな感じ:

  • API契約を書く
  • ルーティングの動作確認
  • db セットアップ
  • リポジトリ・ハンドラの実装
  • API基本設計
  • Revision生成ロジックの設計
    • 等値性の定義
    • 冪等性キー生成場所と等値性判定の場所の決定
    • 冪等処理の設計
  • データベース設計
  • リファクタリング
  • エラーハンドリングの追加

つづく