冒頭まとめ

GCP の 409 Conflict は、2つの異なる区分に対応します。エラー区分の定義ファイルを見ると、1つは作ろうとしたものが既に存在する場合、もう1つは同時実行の衝突で処理が中断された場合です。

この2つは、対処が正反対です。

前者は、状態が既に望みどおりになっている可能性があります。作成の操作を繰り返す自動化では、2回目以降は必ずこのエラーになります。異常として止めるのではなく、既にあるものを使う分岐を持つのが正しい作りです。

後者は、他の処理と衝突しました。定義には、この区分をどう扱うべきかが明示されています。実装する側への指針として、失敗した呼び出しだけを再試行してよいのが 503 の区分、上位の処理からやり直すべきなのがこの区分、系の状態が明示的に直されるまで再試行すべきでないのが 400 の区分、と3つが並べて説明されています。例として挙げられているのは、値を確認してから書き換える処理が失敗した場合で、読み取りから書き込みまでの一連の流れをやり直すべきだ、とされています。

つまり、同じ要求をそのまま送り直すのは、この区分に対しては誤った対処です。読み取りからやり直す必要があります。

したがって、409 を見たときに最初に読むべきは status の値です。ここで、待つのか、既存を使うのか、処理全体をやり直すのかが決まります。

エラーの概要

応答の形は他のエラーと共通です。既に存在する場合は次のようになります。

{
  "error": {
    "code": 409,
    "message": "The resource 'projects/my-project/zones/asia-northeast1-a/instances/my-vm' already exists",
    "status": "ALREADY_EXISTS"
  }
}

message に、既に存在する対象の完全な名前が入ります。自分が作ろうとした名前と同じであることを確認できます。

同時実行の衝突の場合は、区分名が変わります。

{
  "error": {
    "code": 409,
    "message": "Aborted due to cross-transaction contention.",
    "status": "ABORTED"
  }
}

こちらは対象の名前ではなく、衝突の理由が書かれます。同時に走っている処理があった、という趣旨の文言です。

コマンド行の道具からは、簡潔な形で表示されます。作成の操作を繰り返した場合、既に存在する旨がそのまま出ます。

まず最初に:status で3方向に振り分ける

第一に、status の値を読みます。ALREADY_EXISTS なら既に存在します。ABORTED なら同時実行の衝突です。

第二に、ALREADY_EXISTS であれば、既存のものが自分の望む状態かを確認します。同じ設定であれば、そのまま使えます。違えば、更新の操作に切り替えます。

第三に、ABORTED であれば、読み取りからやり直します。同じ要求の送り直しではありません。

第四に、FAILED_PRECONDITION が返っている場合は 409 ではなく 400 です。状態が整うまで待つ必要があります(GCP の 400 の記事)。混同しやすい3つですが、区分名で確実に分かれます。

よくある原因と解決手順

原因1:既に存在するものを作ろうとしている

最も多い形です。自動化を2回実行した、あるいは前回の実行が途中まで進んでいた場合に起きます。

Before(重複を異常として止める):

gcloud compute instances create my-vm --zone=asia-northeast1-b || exit 1
# → 2回目以降は必ず止まる

After(既に存在する場合は既存を使う):

if gcloud compute instances describe my-vm --zone=asia-northeast1-b > /dev/null 2>&1; then
  echo "既存のものを使う"
else
  gcloud compute instances create my-vm --zone=asia-northeast1-b
fi

ただし、確認と作成の間に他の処理が割り込む余地は残ります。より確実なのは、作成を試みて、既に存在する場合を正常系として扱う形です。

status=$(gcloud compute instances create my-vm --zone=asia-northeast1-b 2>&1) || {
  case "$status" in
    *already*exists*) echo "既存のものを使う" ;;
    *) echo "$status"; exit 1 ;;
  esac
}

なお、衝突の相手が自分とは限らない種類があります。Cloud Storage のバケット名は、全利用者で共有される1つの名前空間の中で一意である必要があり、他の誰かが使っている名前でも 409 になります。公式文書に、名前が使用中であれば 409 Conflict で作成が失敗すると明記されています。この場合は文言で見分けられます。自分の既存のバケットとの衝突なら「You already own this bucket」、他の利用者が使用中なら「Sorry, that name is not available」という趣旨の文言が返ります。後者では「既存を使う」分岐は成立せず、別の名前を選ぶしかありません。共有の名前空間を持つ種類では、望みの名前を必ず取得できる前提で設計しないことが大切です。

また、構成を宣言する道具を使っている場合、このエラーは状態の管理と実物がずれている合図です。実物があるのに管理側に記録が無い、という状況なので、取り込みの操作で揃えます(Terraform の 409 の記事)。

原因2:同時実行の衝突で中断された

statusABORTED の場合です。前述のとおり、定義には上位の処理からやり直すべきと書かれています。

典型は、値を読み取り、それをもとに計算し、書き戻す一連の処理です。読み取ってから書き戻すまでの間に他の処理が同じ値を変更すると、このエラーになります。

Before(書き込みだけを再試行する):

value = read_value()          # 1回だけ読む
for i in range(3):
    try:
        write_value(value + 1)
        break
    except Aborted:
        time.sleep(1)         # 古い値のまま送り直している

After(読み取りからやり直す):

for i in range(3):
    try:
        value = read_value()      # 毎回読み直す
        write_value(value + 1)
        break
    except Aborted:
        time.sleep(2 ** i)

古い値のまま送り直しても、同じ衝突が繰り返されるだけです。定義が「上位の処理からやり直す」と述べているのは、この構造を指しています。

なお、この処理を自分で書く必要がない場合もあります。多くの窓口には、一連の処理をまとめて扱う仕組みが用意されており、衝突時の再実行を内部で行います。Firestore の公式文書には、競合を操作の遅延または失敗で解決すること、クライアントライブラリが競合による失敗を自動で再試行すること、そして一定回数で諦めた場合にこのエラーが表面化することが書かれています。つまり、自動の再試行が付いていても、同じ対象への集中が続く限り最終的にはこのエラーが届きます。届いた時点で必要なのは再試行を増やすことではなく、同じ対象に書き込みが集中しない形へ処理を変えることです。

原因3:削除の直後に同じ名前で作っている

削除の処理が完了する前に、同じ名前で作成しようとした場合です。削除は要求を受け付けた時点では終わっておらず、実際に消えるまでに時間がかかります。

# 削除の操作が完了したかを確認する
gcloud compute operations list --filter="status!=DONE"

# 対象が実際に消えたかを確認する
gcloud compute instances list --filter="name=my-vm"

一覧から消えたことを確認してから作成してください。決め打ちの秒数で待つ方式は、遅れたときに失敗します。

なお、種類によっては、削除後も一定期間その名前を使えないことがあります。この場合、待っても解消しません。名前の再利用に関する制限は種類ごとに違うため、該当する文書を確認してください。

原因4:更新の操作で世代の指定が古い

対象の更新に際して、現在の世代を指定する仕組みがある場合です。指定した世代が現在のものと違うと、衝突として扱われます。

これは意図された仕組みです。誰かの変更を知らずに上書きすることを防いでいます。

対処は、最新の状態を取得し直してから、その世代を指定して更新することです。原因2と同じ構造なので、読み取りからやり直す形になります。

# 現在の状態と世代を取得する
gcloud <サービス> describe <対象> --format="yaml(etag, name)"

# 取得した値を使って更新する

ただし、前提条件の失敗をどの状態コードで返すかはサービスによって違います。409 ではなく 412 や、400(FAILED_PRECONDITION)として返る場合もあるため、ここでも区分名と状態コードを確認してから対処を選んでください。

原因5:同じ処理が並行して走っている

自動化の中で、同じ対象に対する操作が同時に複数走っている場合です。片方が成功し、もう片方がこのエラーを受けます。

この形は、再試行しても根本的には解決しません。並行して走らないよう、実行の重複を防ぐ仕組みを入れるのが正しい対処です。

まず、同じ時刻に同じ操作が複数記録されていないかを確認してください。

gcloud logging read \
  'protoPayload.methodName="<操作名>" AND protoPayload.resourceName="<対象>"' \
  --limit=20 --format="value(timestamp, protoPayload.authenticationInfo.principalEmail)"

同じ時刻に複数の実行主体が現れていれば、この形です。

補足:似ているが別のもの

対象の状態が操作を許さない場合は 400 で、区分としては前提条件の失敗です。定義の指針では、系の状態が明示的に直されるまで再試行すべきでない、とされています(GCP の 400 の記事)。409 の同時実行の衝突とは、やり直してよいかどうかが違います。

一時的に処理できない場合は 503 です。定義の指針では、失敗した呼び出しだけを再試行してよい、とされています(GCP の 503 の記事)。

この3つは定義の中で並べて説明されており、再試行の粒度で区別されています。呼び出しだけをやり直すのが 503、処理の流れごとやり直すのが 409、状態を直すまでやり直さないのが 400、という並びです。

対象が見つからない場合は 404 です(GCP の 404 の記事)。権限の不足は 403 です(GCP の 403 の記事)。上限の超過は 429 です(GCP の 429 の記事)。

切り分けの順序

  1. status の値を読む。ALREADY_EXISTSABORTED かで対処が分かれる。
  2. ALREADY_EXISTS なら、既存のものが望む状態かを確認する。同じなら使い、違えば更新に切り替える。
  3. 衝突の相手が他人の可能性がある種類(共有の名前空間)なら、文言で見分ける。他人の使用中なら別名にする。
  4. 作成を繰り返す自動化なら、重複を正常系として扱う分岐に直す。
  5. ABORTED なら、読み取りからやり直す。同じ要求の送り直しでは解決しない。
  6. 削除の直後なら、実際に消えたことを一覧で確認してから作成する。
  7. 世代の指定がある更新なら、最新を取得し直してから送る。区分名は 409 とは限らない。
  8. 同じ操作が並行して走っていないかを記録で確認する。走っていれば、重複を防ぐ仕組みを入れる。

確認コマンド集

# 1. 応答の status と対象名を取り出す
curl -sS -X POST -H "Authorization: Bearer $(gcloud auth print-access-token)" \
  -H "Content-Type: application/json" -d @request.json \
  "https://<サービス>.googleapis.com/v1/<資源>" | python3 -c "
import json,sys
d=json.load(sys.stdin)['error']
print(d['code'], d['status'])
print(d['message'])
"

# 2. 対象が既に存在するかを確認する
gcloud <サービス> list --filter="name=<対象名>" --format="table(name, status)"

# 3. 削除が完了したかを確認する
gcloud compute operations list --filter="status!=DONE"

# 4. 現在の世代を取得する(更新の前に)
gcloud <サービス> describe <対象> --format="yaml(etag, name)"

# 5. 同じ操作が並行して走っていないかを記録で確認する
gcloud logging read 'protoPayload.methodName="<操作名>"' \
  --limit=20 --format="value(timestamp, protoPayload.resourceName)"

# 6. 記録から 409 系を抽出する(6=既に存在, 10=中断)
gcloud logging read 'protoPayload.status.code=6 OR protoPayload.status.code=10' \
  --limit=50 --format="value(timestamp, protoPayload.methodName, protoPayload.status.message)"

Editor’s Note

409 に対応する2つの区分は、名前だけを見ると片方が地味に見えます。既に存在する、という状態はエラーというより事実の通知だからです。実際、この区分に対する正しい対処は、多くの場合「異常として扱わない」ことです。

一方、中断の区分には、定義の中に踏み込んだ説明が添えられています。実装する側が3つの区分を選び分けるための指針で、再試行の粒度によって区別されています。失敗した呼び出しだけをやり直せばよいのが 503、上位の処理からやり直すべきなのが 409、状態が直るまでやり直すべきでないのが 400。挙げられている例は、値を確認してから書き換える処理が失敗した場合で、読み取りから書き込みまでの流れを再開すべきだ、とされています。

中断の区分が、やり直しの衝突だけで起きるわけではないことを示す記録もあります。Firestore の利用者の報告(googleapis/nodejs-firestore Issue #1022)では、約6,000件の文書を並列で追加しただけで「ABORTED: Too much contention on these documents」が返っています。報告者自身、更新はしていないのになぜ競合するのかと戸惑っています。公式文書の説明——競合は操作の遅延または失敗で解決され、ライブラリの自動再試行が一定回数で諦めるとこのエラーが届く——を踏まえると、これは同じ場所への書き込みの集中そのものが競合になった形です。対処が「送り直す」ではなく「書き込みの形を変える」になる点で、定義の指針が示す方向とぴったり重なります。

逆に言えば、この指針を知らないと誤りやすいエラーでもあります。中断されたのだから再試行しよう、という判断は自然ですが、同じ要求を送り直しても同じ衝突を繰り返すだけです。古い値を握ったままやり直しているためです。

409 は、区分名を読むことの利点が最も分かりやすく出るエラーです。同じ数字の裏に、待つべきでない状況と、やり直すべき状況が同居しています。


免責事項:本記事の内容は、執筆時点の公開情報をもとに作成したものです。ソフトウェアの仕様は予告なく変更されることがあります。最新の情報は各ツールの公式サポートページをご確認ください。本記事の情報を利用した結果生じたいかなる損害についても、著者および運営者は責任を負いかねます。