🎉

DynamoDBの「アップサート」と「条件付き書き込み」を一度にできるのか?できらぁ!

2021/09/14に公開

DynamoDBに関するクラメソさんの以下の記事について。
※記事中に当記事のリンクを張っていただいたようです。ありがとうございます!

https://dev.classmethod.jp/articles/boto3-dynamodb-upsert-and-conditional-update/

おんなじことをやりたくてググったら上記の記事がヒットしまして、やりたいこととしては記事から引用すると、

このとき、Lambda側のステートデータの書き込み処理の実装は以下のように行う必要があります。

  • キーがあれば更新、なければ新規作成をする(アップサート)
  • キーの更新は、新規データのdeviceTimaStamp属性の値が既存データより小さければ行わない(条件付き書き込み)

これらのうち後者の条件を満たすためにUpdateItemを行う際にConditionExpressionを指定することで、条件に一致しない書き込みの際にはConditionalCheckFailedExceptionが発生する、という機能を記事中で用いています。記事中ではConditionExpressionで「新規データのdeviceTimaStamp属性の値が既存データより大きい」ことを書き込み条件にしています。

そして、

  1. UpdateItemで指定したPartitionKeyが存在しない場合にConditionalCheckFailedException発生する
  2. 上記の理由からUpdateItemだけでは「アップサート」と「条件付き書き込み」を一度に実現できず、GetItemを併用したり、例外をキャッチして処理する必要がある

という結論に達しているようです。

PartitionKeyが存在しない場合にConditionalCheckFailedException発生する

https://www.alexdebrie.com/posts/dynamodb-condition-expressions/?utm_source=pocket_mylist

上記記事によれば、DynamoDBの条件付き書き込みにおいては、

  1. まず指定されたプライマリキーに応じて最大1個のアイテムを取得
  2. 1で取得したitemに対して」指定されたConditionのチェック(Conditionが複数なら複数チェック)
  3. Conditionを満たしていれば書き込み、そうでなければConditionalCheckFailedExceptionが発生

となっています。
クラメソさん記事の「UpdateItemで指定したPartitionKeyが存在しない場合にConditionalCheckFailedException発生する」に関しては、上記手順1の「プライマリキーでitemを取得」で一致するキーがないためにitemが取得できておらず、空のデータ(?)に対して存在しない属性の比較をしたためにエラーになった、と考えられます。

このとき「手順1でitemが取れない時点でエラーになるのでは?」とも思えますが、Conditionチェックでは「主キーが存在しなければ書き込み」という条件もつけれるので、手順1でitemが取得できなくてもConditionチェックは行われるはずです。

「アップサート」と「条件付き書き込み」を一度にできないのか?

主題に入りますが、本当に「アップサート」と「条件付き書き込み」を一度にできないのか?

前述の手順2では「複数Conditionが指定されていれば複数チェック」としているので、もしかしたら複数条件指定すればできるのでは?と思い以下のような条件を付けてみました

"(#deviceTimeStamp < :device_time_stamp) or (attribute_not_exists(deviceId))"

ようするに、

  • タイムスタンプが更新後の方が大きいか
  • プライマリキーが存在しないか

をOR条件で指定しました。
これでプライマリキーが存在する場合はCondition条件に基づいて更新されますし、プライマリキーのデータが存在しない場合は後者の条件で新規にデータが登録されるはずです。

やってみよう。

1. データ未登録状態で属性を比較する

記事中と同様に#deviceTimeStamp < :device_time_stampでConditionExpressionを設定します。

> aws dynamodb get-item --table-name device_state \
    --key '{"device_id":{"S":"id_000001"}}'
# no response
> aws dynamodb update-item --table-name device_state \
    --key '{"device_id":{"S":"id_000001"}}' \
    --update-expression "SET #deviceTimeStamp = :device_time_stamp, #state = :state" \
    --expression-attribute-values '{":device_time_stamp": {"S": "2021.01.01T00:00:00Z"}, ":state": {"BOOL": true}}' \
    --expression-attribute-names '{"#deviceTimeStamp": "deviceTimeStamp", "#state": "state"' \
    --condition-expression "#deviceTimeStamp < :device_time_stamp" \
    --return-values ALL_NEW
An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation: The conditional request failed

記事と同様エラーになりますね。

2. データ未登録状態で「属性比較 or PKが存在しない」を条件にする

次に"(#deviceTimeStamp < :device_time_stamp) or (attribute_not_exists(device_id))"とOR条件でプライマリキーの存在チェックを追加します。

> aws dynamodb get-item --table-name device_state \
    --key '{"device_id":{"S":"id_000001"}}'
# no response
> aws dynamodb update-item --table-name device_state \
    --key '{"device_id":{"S":"id_000001"}}' \
    --update-expression "SET #deviceTimeStamp = :device_time_stamp, #state = :state" \
    --expression-attribute-values '{":device_time_stamp": {"S": "2021.01.01T00:00:00Z"}, ":state": {"BOOL": true}}' \
    --expression-attribute-names '{"#deviceTimeStamp": "deviceTimeStamp", "#state": "state"' \
    --condition-expression "(#deviceTimeStamp < :device_time_stamp) or (attribute_not_exists(device_id))" \
    --return-values ALL_NEW
{
    "Attributes": {
        "deviceTimeStamp": {
            "S": "2021.01.01T00:00:00Z"
        },
        "state": {
            "BOOL": true
        },
        "device_id": {
            "S": "id_000001"
        }
    }
}

書き込みに成功します。
もちろんこの後同じConditionExpressionで小さいdeviceTimeStampを指定するとエラーになります。

> aws dynamodb update-item --table-name device_state \
    --key '{"device_id":{"S":"id_000001"}}' \
    --update-expression "SET #deviceTimeStamp = :device_time_stamp, #state = :state" \
    --expression-attribute-values '{":device_time_stamp": {"S": "1998.01.01T00:00:00Z"}, ":state": {"BOOL": true}}' \
    --expression-attribute-names '{"#deviceTimeStamp": "deviceTimeStamp", "#state": "state"' \
    --condition-expression "(#deviceTimeStamp < :device_time_stamp) or (attribute_not_exists(device_id))" \
    --return-values ALL_NEW
An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation: The conditional request failed

まとめ

No. 既存キー プライマリキーの存在確認 既存タイムスタンプとの比較 書き込み結果
1 あり どちらでも 大きい 成功(DynamoDB上のデータが更新された)
2 あり どちらでも 小さい 失敗(ConditionalCheckFailedException発生)
3 なし なし - 失敗(ConditionalCheckFailedException発生)
4 なし あり - 成功(DynamoDB上のデータが更新された))

なお、

"(#deviceTimeStamp < :device_time_stamp) or (attribute_not_exists(deviceId))"
"(attribute_not_exists(deviceId)) or (#deviceTimeStamp < :device_time_stamp)"

と条件を逆にしても同じ結果になりました。どっちが先でも特に大差ないと思います。

クラメソさんの記事では「Boto3で」ということになっていますが、Boto3の場合ももちろん同様になります。
Boto3では以下のように記述できます。

import boto3
from boto3.dynamodb.conditions import Attr
from boto3.resources.base import ServiceResource
from botocore.exceptions import ClientError

dynamodb_resource = boto3.resource('dynamodb')

# ステートデータ
device_id = '<デバイスID>'
state = '<ステート>'
device_time_stamp = '<タイムスタンプ>'

# ステートデータ
device_id = '<デバイスID>'
state = '<ステート>'
device_time_stamp = '<タイムスタンプ>'

def upsate_state(device_id: str, state: bool, device_time_stamp: str, dynamodb_resource: ServiceResource) -> None:
    table = dynamodb_resource.Table('device_state')
    try:
        condition = Attr('deviceId').not_exists() | Attr('deviceTimeStamp').lt(device_time_stamp)
        # もしくは↓
        # condition = '(attribute_not_exists(deviceId)) or (#deviceTimeStamp < :device_time_stamp)'
        option = {
            'Key': {'deviceId': device_id},
            'ConditionExpression': condition,
            'UpdateExpression': 'set #deviceTimeStamp = :device_time_stamp, #state = :state',
            'ExpressionAttributeNames': {
                '#deviceTimeStamp': 'deviceTimeStamp',
                '#state': 'state'
            },
            'ExpressionAttributeValues': {
                ':device_time_stamp': device_time_stamp,
                ':state': state
            }
        }
        table.update_item(**option)
        return

    except ClientError as e:
        if e.response['Error']['Code'] != 'ConditionalCheckFailedException':
            raise
        print("old!!")

Discussion