DynamoDB で map 型の属性の要素を適切に更新したい

# はじめに

DynamoDB をバックエンドのストレージとして利用するアプリケーションにおいて、

- DynamoDB テーブル上のそれぞれの項目のある属性において map 型のデータ [^1] を保持したい
- また、その map の特定の要素を追加・更新したい
- ただし、その項目や属性は追加・更新の操作をしようとした段階ではまだ存在していない可能性がある
- 加えて、更新操作は複数のアプリケーションのプロセスから同時並列的に行われうる
- なお、一度追加された属性 (!= 要素) の削除操作は行われないものとする
- 要素の追加・更新は後勝ち (最後に追加・更新された値を正しいもの) とする 

という状況下で、いい感じに map に対する要素の追加・更新操作したい場合に、どのような実装をすればいいのかを DynamoDB 弱者なりに毎回調べてしまっている気がするので、ここにメモを残すことにします。


# うまくいかない操作方法 :weary: 

どうすればいいのかを知る前に、「これではうまくいかないよ…」という方法を確認しておきます。


## `SET 属性名.要素名 = 値` の更新式で単純に追加・更新しようとする

map 型のデータの特定の要素を更新する場合は [更新式](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.Modifying.html) を使うのがセオリーであると仮定して、まずは単純なケースから試していきます。

Java コードで表現すると、次のようになるでしょう。

```java:単純な更新式
AmazonDynamoDBClient client = new AmazonDynamoDBClient();
client.setRegion(Region.getRegion(Regions.AP_NORTHEAST_1));

String pk = "test-key";
Map<String, AttributeValue> key = Collections.singletonMap(pkName, new AttributeValue().withS(pk));

AttributeValue attrValue = new AttributeValue().withS("foo");

// ---

UpdateItemRequest request = new UpdateItemRequest()
        .withTableName(tableName)
        .withKey(key)
        .withUpdateExpression("SET #attr.#field = :val")
        .addExpressionAttributeNamesEntry("#attr", "attr1")
        .addExpressionAttributeNamesEntry("#field", "field1")
        .addExpressionAttributeValuesEntry(":val", attrValue);
client.updateItem(request);
```

上記のように、単に `SET 属性名.要素名 = 値` とした場合、属性がすでに存在する場合は問題なく追加・更新できるのですが、 **属性がまだ存在しない場合** に次の例外が発生してしまいます。

```
com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException: The document path provided in the update expression is invalid for update (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: xxx)
```

これより、`SET 属性名.要素名 = 値` とするときは、その属性がすでに存在することが前提条件になることがわかります。


## `SET 属性名 = if_not_exists(属性名, map型の値), 属性名.要素名 = 値` の更新式で操作を試みる

`SET 属性名.要素名 = 値` がすでに属性が存在することを前提とするのであれば、関数 `if_not_exists()` を用いて `SET 属性名 = if_not_exists(属性名, map型の値), 属性名.要素名 = 値` のように、属性がある場合とない場合の両方を同時に考慮すればいいのではないか？ と思うかもしれません。

具体的には、次のようなコードになります。

```java:if_not_exists()を利用してみる
UpdateItemRequest request = new UpdateItemRequest()
        .withTableName(tableName)
        .withKey(key)
        .withUpdateExpression("SET #attr = if_not_exists(#attr, :map), #attr.#field = :val")
        .addExpressionAttributeNamesEntry("#attr", "attr1")
        .addExpressionAttributeNamesEntry("#field", "field1")
        .addExpressionAttributeValuesEntry(":map", new AttributeValue().addMEntry("field1", attrValue))
        .addExpressionAttributeValuesEntry(":val", attrValue);
client.updateItem(request);
```

こちらを実際に実行してみるとわかりますが、残念なことに、常に次の例外を発生させてしまいます。

```
com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException: Invalid UpdateExpression: Two document paths overlap with each other; must remove or rewrite one of these paths; path one: [attr1], path two: [attr1, field1] (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: xxx)
```

エラーメッセージから分かるとおり、 **二つの更新操作 (`#attr = if_not_exists(#attr, :map)` と `#attr.#field = :val`) のドキュメントパスが重なり合っている場合は一回の操作では更新ができない** ようです。


## `SET 属性名.要素名 = 値` に失敗したら `SET 属性名 = map型の値` でリトライする

「属性が存在しない場合に属性を追加する」操作と「属性が存在する場合に要素を追加・更新する」操作はドキュメントパス的に重なり合う操作であるため、これらは物理的に別々の操作に分けざるを得ません。

というわけで、次のように二つの更新操作で処理を構成してみることにします。

1. 最初の更新操作では、属性がすでに存在すると仮定して要素の追加・更新を試みる
2. 上記に失敗したら、属性を追加する

具体的なコードは次のようになります。

```java:二つの更新操作に分ける
try {
    // まずは、属性が存在する前提で要素の更新を試みる
    UpdateItemRequest request1 = new UpdateItemRequest()
            .withTableName(tableName)
            .withKey(key)
            .withUpdateExpression("SET #attr.#field = :val")
            .withConditionExpression("attribute_exists(#attr)")
            .addExpressionAttributeNamesEntry("#attr", "attr1")
            .addExpressionAttributeNamesEntry("#field", "field1")
            .addExpressionAttributeValuesEntry(":val", attrValue);
    client.updateItem(request1);

} catch (ConditionalCheckFailedException e) {

    // 属性が存在しなかったら、属性そのものを追加する
    UpdateItemRequest request2 = new UpdateItemRequest()
            .withTableName(tableName)
            .withKey(key)
            .withUpdateExpression("SET #attr = :map")
            .addExpressionAttributeNamesEntry("#attr", "attr1")
            .addExpressionAttributeValuesEntry(":map", new AttributeValue().addMEntry("field1", attrValue));
    client.updateItem(request2);
}
```

最初の更新操作において、属性が存在していることを検証する [条件式](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.SpecifyingConditions.html) `attribute_exists(属性名)` を設定することで、属性が存在しなかった場合に `ConditionalCheckFailedException` の例外が発生するようになります。もしこの例外が発生したならば、改めて属性を追加する操作をすれば OK、となります。

ただし、DynamoDB テーブル上の同じ項目に対して、複数のプロセスから同時並列的に、更新操作が試みられた場合はこの限りではありません。特に、一つ目の更新操作で属性が存在しないがために失敗したにも関わらず、他のプロセスによって属性の追加が行われた後に二つ目の更新操作 (属性の追加) が行われてしまうと、他プロセスによって追加された属性の要素が失われてしまうことになります。


# うまくいく方法 :relaxed: 

## 要素の追加 → 属性の追加 → 要素の追加、と３回の更新操作を試みる

結局のところ、他のプロセスによる更新操作が同時並列的に行われることを考慮すれば先の方法でも問題ないわけなので、次のように最大３回の更新操作が行われるような処理を構成すればいいわけです。

1. 最初の更新操作では、要素の追加・更新を試みる
    - 属性が存在することを前提条件とする
2. 上記に失敗したら、属性の追加を試みる
    - 属性が存在しないことを前提条件とする
3. さらに上記にも失敗したならば、(属性が存在していることに相当するので) 要素の追加・更新だけをする

具体的なコードは以下のとおり。

```java:３回の更新操作を試みる
try {
    // まずは、属性が存在する前提で要素の更新を試みる
    UpdateItemRequest request1 = new UpdateItemRequest()
            .withTableName(tableName)
            .withKey(key)
            .withUpdateExpression("SET #attr.#field = :val")
            .withConditionExpression("attribute_exists(#attr)")
            .addExpressionAttributeNamesEntry("#attr", "attr1")
            .addExpressionAttributeNamesEntry("#field", "field1")
            .addExpressionAttributeValuesEntry(":val", attrValue);
    client.updateItem(request1);

} catch (ConditionalCheckFailedException e) {

    try {
        // 属性が存在しなかったら、属性そのものの追加を試みる
        UpdateItemRequest request2 = new UpdateItemRequest()
                .withTableName(tableName)
                .withKey(key)
                .withUpdateExpression("SET #attr = :map")
                .withConditionExpression("attribute_not_exists(#attr)")
                .addExpressionAttributeNamesEntry("#attr", "attr1")
                .addExpressionAttributeValuesEntry(":map", new AttributeValue().addMEntry("field1", attrValue));
        client.updateItem(request2);

    } catch (ConditionalCheckFailedException e2) {

        // やっぱり属性が存在していたら、単に要素を更新する
        UpdateItemRequest request3 = new UpdateItemRequest()
                .withTableName(tableName)
                .withKey(key)
                .withUpdateExpression("SET #attr.#field = :val")
                .addExpressionAttributeNamesEntry("#attr", "attr1")
                .addExpressionAttributeNamesEntry("#field", "field1")
                .addExpressionAttributeValuesEntry(":val", attrValue);
        client.updateItem(request3);
    }
}
```

コードはかなり冗長になってしまいますが、これにより確実に要素を追加・更新できるようになります。

なおこの方法では、最大３回の更新操作が発生し得ますが、同時並列的な操作の確率が十分に小さければ

- 属性が存在しない場合の更新操作は概ね２回
- 属性が存在する場合は常に１回

となります。属性が存在する項目に対する要素の追加操作が主であれば、こちらのやり方を採用するのがよいでしょう。一方で、属性が存在しない項目に対する要素の追加操作が主であれば、次に挙げるやり方を採用した方がいいかと思われます。


## `SET 属性名 = map型の値` → `SET 属性名.要素名 = 値` の順に操作する

前述した方法が最初の処理で属性が存在することを仮定していたのに対し、こちらの方法では *属性が存在しないこと* を仮定した処理となっています。

```java:最大２回の更新操作
try {
    UpdateItemRequest request1 = new UpdateItemRequest()
            .withTableName(tableName)
            .withKey(key)
            .withUpdateExpression("SET #attr = :map")
            .withConditionExpression("attribute_not_exists(#attr)")
            .addExpressionAttributeNamesEntry("#attr", "attr1")
            .addExpressionAttributeValuesEntry(":map", new AttributeValue().addMEntry("field1", attrValue));
    client.updateItem(request1);

} catch (ConditionalCheckFailedException e) {

    UpdateItemRequest request2 = new UpdateItemRequest()
            .withTableName(tableName)
            .withKey(key)
            .withUpdateExpression("SET #attr.#field = :val")
            .addExpressionAttributeNamesEntry("#attr", "attr1")
            .addExpressionAttributeNamesEntry("#field", "field1")
            .addExpressionAttributeValuesEntry(":val", attrValue);
    client.updateItem(request2);
}
```

この方法だと、

- 属性が存在しない場合の更新操作は常に１回
- 属性が存在する場合は常に２回

となるため、前者のケースが大半となる更新パターンに向いた処理と言えるでしょう。


-----

[^1]: RDB で言うなれば、複数のレコード and/or 別テーブルで、縦持ちして持つべきデータを (横持ちにするわけでもなく) 一つのレコードの一つのカラムに複数のデータを無理矢理突っ込むことに相当するので、設計的にイケてないという見方もできるのですが、時にはそのような設計にした方がよろしいこともあるわけです