DynamoDBトランザクションの使い方

業務でDynamoDBを使ってきましたが、先日初めて、特定の処理を厳密に実行する必要がでてきたため初めて利用しました。

簡単に利用方法を備忘録として残します。

# DynamoDBトランザクションとは？

その名の通り、DynamoDBでトランザクション処理が実行できる機能です。

複数の更新系の処理(PUT, UPDATE, DELETE)、あるいは参照系の処理(GET)をトランザクション内で順次実行し、そのうち１つでも処理が失敗すれば全ての処理をロールバックします。

## 利用した背景

弊社で作成しているオンラインガチャサービスにて、ガチャの利用上限回数に達した場合はガチャを利用できなくする（ガチャを排出しない）必要がでてきたため、ガチャの抽選（ガチャ結果データの作成）とガチャ利用回数の記録を厳密に記録する必要があったためトランザクションを利用しました。

## 利用例

今回はNode.jsで利用したのでNode.jsでの利用例になります。

また、書き込みトランザクションのみ利用しています。

### **要件**

- あるガチャを利用した結果を`gacha_results`テーブルに記録する
- そのガチャの利用回数と利用上限数が`gacha_draw_limit`テーブルに存在するので、現在の利用回数が上限回数より小さければ、利用回数を１インクリメントする

今回は `TransactWriteItem` コマンドを利用します。

TransactWirteItemコマンドは `TransactItems` オプションでトランザクション内で実行する処理を複数記述します。

まずはガチャの抽選結果を作成する処理を書きましょう。

作成（上書きも含む）はPutで記述します。

処理の書き方は通常のPutCommandと同じです。

```jsx
const transactItems = [
  {
    Put: {
      TableName: 'gacha_results',
      Item: {
        uid: resultUid,
        ...resultData,
      },
      ConditionExpression: 'attribute_not_exists(uid)',
    }
  }
];
```

uidが同じもの(過去の結果など)に上書きしたくないので、条件付き書き込みを利用しています。

次はコンテンツ利用回数のインクリメント処理です。

[アトミックカウンタ](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.AtomicCounters)を使います。

こちらも条件付き更新を利用して、現在の利用回数(total_count)が利用上限回数(limit_count)より小さい時のみインクリメントが成功するようにしています。

```jsx
transactItems.push(
  {
    Update: {
      TableName: 'content_limit',
      Key: { uid: `${content.uid}_${result.id}` },
      UpdateExpression: 'SET total_count = total_count + :increment',
      ConditionExpression: 'attribute_not_exists(limit_count) OR total_count < limit_count',
      ExpressionAttributeValues: { ':increment': 1 },
    },
  }
);
```

処理全体は以下のようになりました。

```jsx
export const ddb = DynamoDBDocumentClient.from(
  new DynamoDBClient({
    region: 'ap-northeast-1',
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY,
      secretAccessKey: process.env.AWS_SECRET_KEY,
    },
    maxAttempts: 3,
  });
);

await ddb.send(
  new TransactWriteCommand(
    TransactItems: [
      {
        Put: {
          TableName: 'content_results',
          Item: {
            uid: resultUid,
            ...resultData,
          },
          ConditionExpression: 'attribute_not_exists(uid)',
        },
      },
      {
        Update: {
          TableName: 'content_limit',
          Key: { uid: `${content.uid}_${result.id}` },
          UpdateExpression: 'SET total_count = total_count + :increment',
          ConditionExpression: 'attribute_not_exists(limit_count) OR total_count < limit_count',
          ExpressionAttributeValues: { ':increment': 1 },
        },
      }
    ],
    ClientRequestToken: '...'
  )
);
```

これで、ConditionExpressionが満たされないなどの理由でどちらかの処理が失敗すると全ての処理がロールバックされるようになりました。

### **条件が満たされなかった場合**

`TransactionCanceledException` エラーが発生します。

どの処理で失敗したか、など詳細は確認できないようです。

```jsx
TransactionCanceledException: Transaction cancelled, please refer cancellation reasons for specific reasons [None, TransactionConflict]
```

### ConditionCheckについて

Put / Update / Delete 以外にも、 `ConditionCheck` という項目をtransactItemsに含めることができます。

何か処理を実行するわけではなく、Transactionで扱う項目以外の項目の存在や値の条件チェックなどができるようです。

当然チェックが失敗すればトランザクションはロールバックされます。

```jsx
export interface ConditionCheck {
    /**
     * <p>The primary key of the item to be checked. Each element consists of an attribute name
     *             and a value for that attribute.</p>
     */
    Key: Record<string, AttributeValue> | undefined;
    /**
     * <p>Name of the table for the check item request.</p>
     */
    TableName: string | undefined;
    /**
     * <p>A condition that must be satisfied in order for a conditional update to
     *             succeed. For more information, see <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html">Condition expressions</a> in the <i>Amazon DynamoDB Developer
     *                     Guide</i>.</p>
     */
    ConditionExpression: string | undefined;
    /**
     * <p>One or more substitution tokens for attribute names in an expression. For more information, see
     *             <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html">Expression attribute names</a>
     *             in the <i>Amazon DynamoDB Developer Guide</i>.</p>
     */
    ExpressionAttributeNames?: Record<string, string>;
    /**
     * <p>One or more values that can be substituted in an expression. For more information, see <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html">Condition expressions</a> in the <i>Amazon DynamoDB Developer Guide</i>.</p>
     */
    ExpressionAttributeValues?: Record<string, AttributeValue>;
    /**
     * <p>Use <code>ReturnValuesOnConditionCheckFailure</code> to get the item attributes if the
     *                 <code>ConditionCheck</code> condition fails. For
     *                 <code>ReturnValuesOnConditionCheckFailure</code>, the valid values are: NONE and
     *             ALL_OLD.</p>
     */
    ReturnValuesOnConditionCheckFailure?: ReturnValuesOnConditionCheckFailure | string;
}
```

### ClientRequestTokenについて

こちらは `TransactWriteItem` のオプションとして指定できます。

トランザクションリクエストを冪等にするためにリクエストごとに一意なトークンを指定します。

ネットワークなどの問題などで同じリクエストが再試行されたり複数回送信された場合に、同じリクエストトークンを持つリクエストは２度目以降は実行されません。

つまり同じリクエストトークンを持つリクエストの重複実行を回避してくれるものです。

## 参考

**Amazon DynamoDB Transactions: 仕組み**

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/transaction-apis.html