【AWS】【AppSync】Lamdaで更新処理の競合を解決した話


こちらはPOLアドベントカレンダー2021の11日目の記事です。前日の記事はこちら

はじめに

こんにちは、株式会社POLの上久保です。
ふるさと納税で沢山届いたトマトジュースを飲んでいるところを目撃されてからあだ名がリコピンになりました。
主に自社サービスのフロントエンドの開発を担当しており、最近ではReactやTypeScriptをよく書いています。
私の担当システムはバックエンドがAWS Amplifyで構成されているのですが、クラウド初心者の私にとっては知らないことが多く、勉強の日々が続いています。
ただ一度理解してしまえばAWSとても便利なんですよね、こんなに自動でやってくれていいのかと。

この記事では最近私が業務の中でAWS AppSyncについて調べたことや詰まったポイントなどをまとめてみました。
テックブログを書くのは人生で初めてなので少し緊張しますが、この記事が少しでも困っているエンジニアの方の助けになれば嬉しいです。(内容に関する指摘や質問がありましたら是非コメントしてください!)

AppSyncとは?

GraphQLさえ初めて触る私にとってはまずAppSyncの概念を理解するのはとても大変でした。
要はGraphQL APIを使いたい時にクライアントとデータベースの間に入って上手いことやってくれる存在なんですね。

AppSyncのアーキテクチャ

  • GraphQL Proxy クライアントからのリクエストを受け取り、GraphQLの操作(Query, Mutation, Subscription)を実行する
  • Resolver GraphQLとデータソースの間を繋ぐコネクタであり、データソースからのレスポンスとGraphQLのレスポンスの形式の差異埋める役割がある
  • DynamoDB, Lambdaなど認証が必要なさまざまなデータソースへの接続を自動で行う

事の発端

そもそも何でAppSyncについて調べたのかというと、サービス運用の部門の方から「データ更新時に不具合が発生しているから調べてほしい」と言われたのがきっかけでした。その不具合は複数人で同時にブラウザからシステムにアクセスしているときに発生するもので、DBで配列型のカラムの値が二重に登録されてしまうというものでした。このシステムではデータソースにはDynamoDBを、GraphQL APIはAmplifyで指定したAppSyncリソースを使っていたので、これを機にちゃんと調べてみようと思いました。

発生した不具合のイメージ

  1. ブラウザA,Bは同じWebアプリケーションにアクセスし、ある配列の値を取得しています。ブラウザBが配列の値を更新してDBにプッシュしました。

2.しかしブラウザAはブラウザBが行った変更を取得しないまま別の内容で配列を更新してしまいました。この時データの競合(Conflict)が発生します。そしてなぜか、両方のブラウザからの更新内容を取り込んでデータが2倍に増えてしまったのです!?
(しかもこれは全く中身が同じ配列同士の競合でも同じく2倍になるのです、ここハマるポイントだと思います)

そのときの僕

不具合の原因

意味不明すぎてちいかわになっていた私ですが、公式のドキュメントを読む限りどうやらそういう仕様らしいです。Automergeという名前がついているらしく、デフォルトで設定されている競合の解決戦略のようです。

Automerge戦略

  • 値がスカラーの場合、更新内容は拒否されサーバの内容が選択される
  • GraphQLとデータソースの型がともにList型の場合、更新内容リストとサーバの内容リストは連結され、重複した値は保持される(←これが起きている)
  • GraphQLがList型でデータソースの型がSet型の場合、更新内容セットとサーバの内容セットはユニオン(和集合)される

なるほど、ということはそもそもデータの型をListからSetに変えればいいじゃん、解決したわ〜と思ったのも束の間、残念ながら2021年12月現在GraphQLのスキーマ定義でSet型は定義できませんでした(完)。

AmplifyのIssuesを見ると、Set型欲しいよねと言っている人がいました、誰か実装して下さい...

解決策

Set型が使えないので他の解決策を考えていたところ、どうやら解決戦略を自分でLambda Functionで実装できるようでした!AWS最高です!早速実装してみましょう!

このLambdaはAppSyncのアーキテクチャでデータソースとして指定できるLambdaとは別物です。なので、GraphQLのスキーマやResolverには登場しません

※私の環境のAmplifyのバージョンは7.5.4です

1. 開発環境でamplify update apiを実行します。内容に従って進めていくとConflict resolution strategyを設定しますか?と出てくるので選択
2. アプリ全体のデフォルトの解決戦略を指定(今回はAutomergeのままで)
3. さらにモデル毎に細かく解決戦略を指定できる。今回は前述の配列を持つモデルのみCustom Lambdaを設定(この設定はデフォルトの戦略より優先される)

4. Lambda Functionは新規で作るか既存のものを使うか選択できます(ここでは新規作成を選択)

5. こんな感じで自動で作られました。

6. 次にLambdaの中のindex.jsにロジックを実装していきます。Lambdaが受け取るリクエストの形式、渡すレスポンスの形式はそれぞれ以下のように指定されているので従う。

リクエスト形式

私は最初下記のリクエストの中身を一体どこでLambda関数に渡しているのか見つからず時間を溶かしてしまいました。実際はAppSync側が裏でよしなにやってくれるのでエンジニアの我々は気にしなくてOKです。

{
    "newItem": { ... }, // Mutationが成功した場合の項目
    "existingItem": {... }, // 現在DynamoDBテーブルに存在している項目
    "arguments": { ... },
    "resolver": { ... },
    "identity": { ... }
}

レスポンス形式

レスポンスの形式は開発者が選べる3つのオプションによって異なる、今回は更新時にテーブルの項目を上書きするようにRESOLVE戦略を選択する(つまり後勝ちになる)

{
    "action": "RESOLVE",
    "item": { ... } // この項目でデータベースの内容が置き換わる
}

index.jsの中身(テンプレの内容をちょっと変えただけ)

index.js
exports.handler = async (event, context, callback) => {
  // AWS上で中身のログを見るために記載
  console.log('Received event {}', JSON.stringify(event, 3));
  let action, item;
  switch (event.resolver.field) {
    case 'updateEvent':
      action = 'RESOLVE';
      item = event.newItem;
      break;
    default:
      throw new Error('Unknown Resolver');
  }
  return {
    action,
    item,
  };
};

7. amplify pushで変更した設定を反映して完了
CloudWtachで実際に競合が発生した時のログを見てみた(conflictColumnで競合が発生している例)

2021-12-02T05:37:14.981Z    xxxxx-xxxxx-xxxx-xxxxx  INFO    Received event {} {
    "newItem": {
        "date": "2021-12-06T15:00:00.000Z",
        "_lastChangedAt": 1638423424820,
        "conflictColumn": "2021-12-07T01:00:00.000Z",
        "__typename": "Event",
        "createdAt": "2021-12-02T05:36:36.396Z",
        "id": XXXX,
        "_version": 2,
        "updatedAt": "2021-12-02T05:37:13.998Z",
        ...
    },
    "existingItem": {
        "date": "2021-12-05T15:00:00.000Z",
        "_lastChangedAt": 1638423424820,
        "conflictColumn": "2021-12-06T01:00:00.000Z",
        "__typename": "Event",
        "createdAt": "2021-12-02T05:36:36.396Z",
        "id": XXXX,
        "_version": 2,
        "updatedAt": "2021-12-02T05:37:04.760Z",
        ...
    },
    "arguments": {...},
    "identity": {
        "claims": {...},
        "defaultAuthStrategy": "ALLOW",
        "groups": [...],
        "issuer": "https://cognito-idp.ap-northeast-1.amazonaws.com/XXXX",
        "sourceIp": [
            XXXX
        ],
        "sub": XXXX,
        "username": XXXX
    },
    "resolver": {
        "tableName": XXXX,
        "awsRegion": "ap-northeast-1",
        "parentType": "Mutation",
        "field": XXXX
    }
}

実装少なくて素敵! AppSync最強!!

気になったこと

AppSync便利だけど気になることもあります。一番気になったのが「Lambdaがどう呼び出されるのかはどこで記述されているのか」です。

下記の自動生成された設定がそれっぽい

/amplify/backend/api/XXXX/transform.conf.json
{
    "Version": 5,
    "ElasticsearchWarning": true,
    "ResolverConfig": {
        "project": {
            "ConflictHandler": "AUTOMERGE",
            "ConflictDetection": "VERSION"
        },
        "models": {
            "XXXX": {
                "ConflictHandler": "LAMBDA",
                "ConflictDetection": "VERSION",
                "LambdaConflictHandler": {
                    "name": "syncConflictHandler3a9498bc-${env}"
                }
            }
        }
    }
}

一方で、「Lambdaのレスポンス形式の指定はどこでされているのか」についてはコードを調べてもわかりませんでした...

おわりに

この記事ではAppSyncで競合の解決戦略をLambda Functionを用いてカスタムで実装方法について解説しました。個人的には解決戦略のためのロジックがフロントエンドやGraphQLのスキーマやAppSyncのResolverと完全に独立していて綺麗だなと思いました。また、AppSyncに限らず便利なマネージドサービスは構築時にエンジニアがとても楽できる反面、設定内容においてどこまでが自動でどこからが人力なのか見極める必要があり、自分のような初学者にとっては調査のハードルが上がってしまうんだろうなと感じました。

ここまで読んでいただきありがとうございました!
次回のアドベントカレンダーは若いのに頼れるエンジニアのナッツさんです!

参考資料