Couchbase Mobile機能解説：データ同期における競合解決②（Couchbase Liteの競合解決）

## はじめに

https://qiita.com/yoshiyuki_kono/items/6e65310d9e6869f5eaaa

本稿は、上記記事の続編です。ここでは、Couchbase Liteの競合解決にフォーカスして、解説します。

以下は、Couchbase Liteにおける競合解決の概念図です。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176567/0cfcbc65-eeb7-0fd8-680d-ff3d7246820e.png)

競合解決において、競合するリビジョン(rev1Aとrev1B)の中から「勝者」を選択し、勝者は新しいリビジョン(rev2)としてリビジョンツリーに追加されます。

## Couchbase Lite 同時実行制御ポリシー

Couchbase Lite 同時実行制御ポリシーでは、最後の書き込みが常に勝ちます。

これは、`saveDocument`を同時実行制御引数を指定せずに呼び出す場合に適応されるポリシーです。

たとえば、データベース内のドキュメントが、アプリケーションがドキュメントを読み保存するまでの間に、そのドキュメントが、レプリケーションやリモートサーバーからのデータフェッチなどの外部トリガーの結果として、アプリ内の別のスレッドによって更新される可能性があります。この場合、更新は成功します。つまり、ドキュメントを読んでから更新するまでの間にドキュメントに加えられた変更はすべて上書きされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176567/1855b359-58a2-3bb4-4e06-f755aaa389f9.png)


以下のように、ドキュメントを読み取った後に、データベース内のドキュメントが削除された場合でも、更新要求は成功します。これは、結果として、削除されたドキュメントが復活することを意味します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176567/74f1888f-e725-d300-10e3-8d3233a3621c.png)

## カスタム競合解決
多くの場合、「最後の書き込みが常に勝つ」というデフォルトのポリシーが機能すると考えられますが、ドキュメントの保存中に競合が発生した場合に通知を受け取るように指定することで、デフォルトの動作をオーバーライドできます。

これは、保存/更新APIコールのパラメーターとしてオプションの`ConcurrentControl`引数の値として`failOnConflict`を含めることで実行できます。
戻り値`false`は、ドキュメントの更新/保存がコンフリクト発生の結果として失敗したことを示します。

### カスタム競合解決の別の方法

ドキュメントを保存するときにカスタムの競合解決を実装するには、ConflictHandlerブロック（`database.save（MutableDocument、ConflictHandler)`）を使用してメソッドを呼び出します。

以下、その例です。

```java
Document doc = database.getDocument("xyz");
if (doc == null) { return; }
MutableDocument mutableDocument = doc.toMutable();
mutableDocument.setString("name", "apples");

database.save(
    mutableDocument,
    (newDoc, curDoc) -> { 
        if (curDoc == null) { return false; } 
        Map<String, Object> dataMap = curDoc.toMap();
        dataMap.putAll(newDoc.toMap()); 
        newDoc.setData(dataMap);
        return true; 
    }); 
```
競合をどのように処理するかは、アプリケーションの仕様によって異なります。

各パターンのSwiftでのプログラミング例を次に示します。

### オプション1： 競合するバージョンのドキュメントをマージして保存する





(以下は、以前の保存/更新APIコールのパラメーターとしてオプションの`ConcurrentControl`引数の値として`failOnConflict`を含めるケース)

```swift
do {
    var success = false
    repeat {
        if try db.saveDocument(docToSave, concurrencyControl: .failOnConflict) {
            // ドキュメントの保存が競合なしで成功。通常の処理を継続。
            success = true
        }
        else {
            // 競合が発生。
            // 現在保存されているドキュメントを取得する。
            if let currDoc = db.document(withID: docToSave.id) {
                // ドキュメントをマージする。
                docToSave = mergeDocument(newDoc: docToSave, currentDoc: currDoc)
            }
            else {
                // Doc was deleted. Handle appropriately
            }
        }
        
    } while (!success)
}
catch {
    print("Some other error in saving \(error)")
}
```

以下は、競合するリビジョンをマージするファンクションの例です。
どのように処理するかは、アプリケーションの仕様次第です。ここでは以下のポリシーを適用しています。

- 既に存在しているプロパティは無視し、存在していないプロパティのみ追加する。


```swift
func mergeDocument(newDoc: Document, currentDoc: Document) -> MutableDocument {
    // 現在保存されているドキュメントを取得する。
    let resolved = currentDoc.toMutable()
    
    // 存在していないプロパティを追加する。
    for key in newDoc.keys {
        if !resolved.contains(key) {
            resolved.setValue(newDoc.value(forKey: key), forKey: key)
        }
    }
    return resolved
}
```

### オプション2： (条件に応じて)強制上書き



このオプションは、現在保存されているドキュメントの内容を調べてから、`saveDocument`を使用して強制的に保存するかどうかを決定します。保存する前にドキュメントは再度更新されている可能性があるため、競合状態のリスクが発生する可能性があることに注意が必要です。

```swift
do {
    if try db.saveDocument(docToSave, concurrencyControl: .failOnConflict) {
        // ドキュメントの保存が競合なしで成功。通常の処理を継続。
    }
    else {
        // 競合が発生。
        if let currDoc = db.document(withID: docToSave.id) {
            // 現在、保存されているドキュメントの内容を確認し（checkDocumentsの実装は割愛します）、
            // 条件に応じて、強制上書きを実行する。
　　　　　　　if checkDocuments(newDoc: docToSave, currentDoc: currDoc) {
               try db.saveDocument(docToSave)
            }
        }
        else {
            // ドキュメントが存在しない（削除されている）。
            // 強制上書きを実行する。
            try db.saveDocument(docToSave)
        }
    }
}
catch {
    print("Some other error in saving \(error)")
}
```

### オプション3： ドキュメント保存をスキップする
競合が発生している場合は、現在保存されているバージョンのドキュメントを保持する。

```swift
do {
    let docToSave = MutableDocument(id: "docId")
    if !(try db.saveDocument(docToSave, concurrencyControl: .failOnConflict)) {
        // 競合するドキュメントが存在している場合、保存をスキップして何もしない。
    }
    else {
        // ドキュメントの保存が競合なしで成功。通常の処理を継続。
    }
}
catch {
    print("Some other error in saving \(error)")
}
```

 
### オプション4： 同じIDでドキュメント作成することへの影響回避

デフォルトの競合解決ポリシーでは、最後の書き込みが常に優先されます。これは、同じドキュメントIDを持つドキュメントが、データベースにすでに存在している場合、ドキュメントを新規作成を意図した操作が、既存のドキュメントの更新（既存のドキュメントに対する新しいリビジョンの追加）となる場合があ流ことを意味します。

したがって、意図しない既存のドキュメントの更新が発生しないようにする場合は、デフォルトの競合解決ポリシーを使用しない（`ConcurrenyControl`引数を`failOnConflict`で指定する）必要があります。

これは、この場合、ドキュメントの現在の内容を調べる必要がないことを除いて、前に指定したオプション3と非常によく似ています。競合の失敗は、IDを持つドキュメントがすでに存在することを意味します。


```swift
do {
    let docToSave = MutableDocument(id: "docId")
    if !(try db.saveDocument(docToSave, concurrencyControl: .failOnConflict)) {
        // 同じドキュメントIDを持つドキュメントが既に存在している。
        // アプリケーション仕様に応じた処理を実装する（別のドキュメントIDに変更する等）。
    }
    else {
        // ドキュメントの保存が競合なしで成功。通常の処理を継続。
    }
}
catch {
    print("Some other error in saving \(error)")
}
```

### レプリケーション時のカスタム競合解決

カスタム競合解決を実装するIF[ConflictResolver](https://docs.couchbase.com/couchbase-lite/2.6/java.html#conflict-resolver)が提供されています。

### レプリケータ構成
実装されたカスタム競合リゾルバーは、レプリケーター構成オブジェクトに登録します。

```JAVA
ReplicatorConfiguration config = new ReplicatorConfiguration(database, target);
config.setConflictResolver(new LocalWinConflictResolver());

Replicator replication = new Replicator(config);
replication.start();
```

ConflictResolverを設定しない場合、デフォルトの競合解決が適用されます。

### マージ

```java
class MergeConflictResolver implements ConflictResolver {
    public Document resolve(Conflict conflict) {
        // ドキュメントをマージする（仕様に応じて実装）
        Map<String, Object> merge = conflict.getLocalDocument().toMap();
        merge.putAll(conflict.getRemoteDocument().toMap());
        return new MutableDocument(conflict.getDocumentId(), merge);
    }
}
```

この例では、ローカルドキュメントに対して、リモートドキュメントのプロパティをマージしています。
ドキュメント全体を置き換えるのと異なり、結果的に、ローカルドキュメントのトップレベルのプロパティにリモートドキュメントのプロパティに存在していないものがあった場合には、マージ後のドキュメントに保存されることになります。

### リモートWIN

```java
class RemoteWinConflictResolver implements ConflictResolver {
    public Document resolve(Conflict conflict) {
　　　　　// 現在、保存されているドキュメントの内容を確認し（checkDocumentsの実装は割愛します）、
        // 条件に応じて、強制上書きを実行する。
        if(checkDocuments(conflict.getLocalDocument(), conflict.getRemoteDocument()))  {
            return conflict.getRemoteDocument();
        }
    }
}
```

### ローカルWIN

```
// Using replConfig.setConflictResolver(new LocalWinConflictResolver());
@Suppress("unused")
object LocalWinsResolver : ConflictResolver {
    override fun resolve(conflict: Conflict) = conflict.localDocument
}
```

## 関連情報

https://blog.couchbase.com/document-conflicts-couchbase-mobile/