DynamoDBで時間経過解放のロックを実装する

# 概要
並列稼働しているシステムでロックを実装する時に、そのロック解放を明示的に解放するのではなく時間経過で行いたい場合に、DynamoDBの条件付きPutと有効期限 (TTL) を利用できると考えた。その検討・検証をまとめる。

※ DynamoDBへのデータ更新における楽観的ロックの話ではない

# 実装したい処理

- 同一のアプリケーションがアプリA・アプリBの2点に冗長化されて配置されており、Active-Activeに稼働しているシステムがある。
- その両方に同じデータが配信され、先に受け取った側で処理を行う。
- データは処理が完了するまで複数回にわたって配信され、1つのデータに対して1回のみ処理を行う。1つのデータに対する処理の重複実行はNG。そのために排他制御 (ロック) が必要。
- ロックには制限時間を設定する。制限時間を超えるとロックを解放し、そのデータに対する処理の再試行が許可されるようになる。
 
上記の仕様を、DynamoDBの「**条件付き書き込み (条件付きPut)**」と「**有効期限 (TTL)**」を使って実現できると考えた。

この処理をDynamoDBを絡めて図にすると以下の通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/687d3c81-0375-6cbb-35e8-0e1cffacb626.png)

# 条件付きPutによるロック処理とは
DynamoDBのPut処理は、通常、テーブル内に同一のキー (パーティションキー, ソートキーの組み合わせ) を持つ項目があると上書き処理となる。

既に同一のキーが存在した場合に上書きしないよう中断させたい際は「条件付きPut」を使う。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html#Expressions.ConditionExpressions.PreventingOverwrites

条件付きPutの実行時に、指定した条件がtrueの時にのみ書き込みが成功し、falseの時は中断され書き込み失敗となる。

その条件式に `attribute_not_exists()` を使うことで、キーが存在していた場合は条件がfalseとなりPutが中断し、`ConditionalCheckFailedException` エラーが返る。

これを応用し、条件付きPutで「ロック機構」を実現することができる。

- 項目追加成功 (競合していない)  = そのまま項目が追加されロック獲得成功
- 項目追加失敗 (競合している) = エラーが返りロック獲得失敗

この時、「ロックの解放」は「対象の項目を削除」と対応付けられるが、今回は、このロックの解放を明示的な項目削除によって解放するのではなく、ロックに時間制限を設けてタイムアウトしたら解放されるようにしたい。

# TTLによる削除タイミング
このロック解放のタイムアウトをDynamoDBの「TTL機能」で実現できると考えた。

しかし、TTLの仕様として、TTLを超えた項目は即時削除されるのではなく「48 時間以内に削除」となっている。

> 通常、TTL は期限が切れた項目を期限切れから 48 時間以内に削除します。
> 
> 引用:  https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/howitworks-ttl.html

実際は、平常時は10〜15分ほどで削除されるよう。 
参考: https://note.com/taro1212/n/n03b60ed03304 

つまり、**TTL属性の値に設定された時間と実際の削除には大きなディレイ**があり、今回実装したいロックのタイムアウトでこのTTLをそのまま使うと、適切なタイミングで解放がされないようになってしまう。

# TTLを超えた項目に対する条件付きPut
前述した通り、TTLによる削除は設定した時間になったら即時削除されるのではなく削除されるまでには時間がかかる仕様となっている。

とはいえ、削除はされていなくともTTLを超えたことは検知されている可能性もあり、その場合はPut処理においては「既に削除されているもの = 存在しないもの」という判定になっていてもおかしくはない。

## 検証
「TTLを超えた、かつ、削除されていない」項目に対して条件付きPutの挙動を確認する。

テーブルスキーマ

- パーティションキー: `primary_id`
- TTL属性: `expired_at`

### 実行
事前に項目 (`primary_id` = 1) を追加し、TTLを超えた状態でCLIで実行。

条件式 = `attribute_not_exists(primary_id)`

```bash
aws dynamodb put-item --table-name テーブル名 \
    --item '{"primary_id": {"S": "1"}, "expired_at": {"N": "有効期限"}}' \
    --condition-expression 'attribute_not_exists(primary_id)'
```

### 結果
期待通りとはいかず、やはり**テーブル内に残っている以上、Put処理においても存在していることとなり、条件エラーとなった。**

```txt
An error occurred (ConditionalCheckFailedException) when calling the PutItem operation: The conditional request failed
```

# TTLを使わずに条件付きPutのみで実装
前述の通り、TTL機能を使ったロック解放は難しいことがわかった。

ここで、条件を改良することによってTTL機能を使わずに実現できるかどうか検討した。

条件付きPutに指定する条件はその時のテーブルの状態について評価がされる。
ここまでのように`attribute_not_exists()` を使った存在チェックだけでなく、同じキーを持つ既存の項目のTTL属性の値に対する条件を追加することもできる。

これを使い、項目が削除されることでロックの解放を実現するのではなく、**ロック取得時に既存のロックが有効かどうか**の評価を追加することで、判定可能となる。

条件 = **ロックが存在していない or 既存のロックが期限切れ**

パターンごとの挙動は以下の図の通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/8a442d46-bcaa-2bff-19d4-d6a8b0121391.png)

補足: もちろん、必要のないロック自体は削除されるべきであるためTTL自体は設定しておく。

## 検証
### 実行
事前に項目 (`primary_id` = 1) を追加し、TTLを超えた状態でCLIで実行。

条件式 = `attribute_not_exists(primary_id) OR expired_at < :timestamp`

```bash
aws dynamodb put-item --table-name テーブル名 \
    --item '{"primary_id": {"S": "1"}, "expired_at": {"N": "有効期限"}}' \
    --condition-expression 'attribute_not_exists(primary_id) OR expired_at < :timestamp' \
    --expression-attribute-values '{":timestamp": {"N": "現在時刻"}}'
```

### 結果
期待通り書き込みが成功した。また、TTL内の場合は `ConditionalCheckFailedException` エラーが発生した。

# 付録
テーブル `$TABLE_NAME`に、「有効期限 = 現在時刻 + 5分」の項目を「同一キーの項目が存在しない OR 有効期限 < 現在時刻」の条件式で条件付きPutを行うワンライナー (AWS CloudShellで動作)

```bash
aws dynamodb put-item --table-name $TABLE_NAME --item '{"primary_id": {"S": "1"}, "expired_at": {"N": "'$(date -d '5 minutes' +%s)'"}}' --condition-expression 'attribute_not_exists(primary_id) OR expired_at < :timestamp' --expression-attribute-values '{":timestamp": {"N": "'$(date +%s)'"}}'
```

# まとめ
時間経過によって解放されるロックを、DynamoDBで実現できないかの検討についてまとめた。条件付きPutを使うことで、明示的な削除処理を入れることなく有効期限を持つロックを実現することができた。

TTL機能はやはり、あくまで時間経過により消しても良いデータが残留し続けてしまうことを防ぐために使うもので、今回のようにTTLに設定した時間ちょうどに削除して欲しいような要件では使えないということを改めて実感した。