🗂

「NSFileVersionの最新バージョンは必ずしも更新日が最新ではない」仮説

2023/08/10に公開

概要・仮説

UIDocumentをiCloud Documentsとして保存しており複数のデバイスで同時に保存が同期された場合はコンフリクトを起こします。
UIDocument.documentStateinConflictかどうかをチェックすることでコンフリクト状態かどうか分かるため、この状態のときにコンフリクト解決を行います。

この際の戦略としては

  • マージ戦略
  • 後勝ち(Last Writers Win)戦略
  • ユーザーにコンフリクト解決ハンドラを提示する戦略

等がありますが、最もポピュラーなコンフリクト解決が後勝ち戦略であると思います。

このシーンで登場するのがNSFileVersionオブジェクトです。これは1つのオブジェクトがある一時点でのドキュメントのスナップショットを表現しており、各種属性を突き合わせてどのようにコンフリクト解決すべきかを決定していきます。普通に考えるとNSFileVersion.currentVersionOfItem(at:)を見れば最新バージョンのドキュメントが手に入るので、それを常に勝者にする解決を行えば良いように考えるのですが、どうやらコンフリクトが起こった場合は必ずしも最新の(=更新日が新しい)バージョンが選択されている訳ではないのではないかという仮説を立てました。

NSFileVersionのドキュメントをよく見ると

However, additional file versions may be created in cases where two different computers attempt to save the file to the cloud at the same time. In that case, one file is chosen as the current version and any other versions are tagged as being in conflict with the original. Conflict versions are reported to the appropriate file presenter objects and should be resolved as soon as possible so that the corresponding files can be removed from the cloud.

と記載があります。簡単に要約すると「同時に変更があった場合は片方を最新バージョンとして選択し、片方をコンフリクトバージョンとしてタグ付けする」と言っているのですが、重要なのは同時に変更がある場合は変更日が新しいを最新バージョンとして選択すると言っていないことです。

同様にResolving Document Version ConflictsのLarning About Document Version Conflitsの2段落目を見ていくと下記のように記載があります。

You learn about the conflicting versions of a document through two class methods of the NSFileVersion class. The currentVersionOfItemAtURL: method returns an NSFileVersion object representing what’s referred to as the current file; the current file is chosen by iCloud on some basis as the current “conflict winner” and is the same across all devices. (以下略)

注目するのはthe current file is chosen by iCloud **on some basis** as the current "conflict winner"と書いてあるところです。ここで「何らかの根拠・基準に基づいて」と言っており、iCloud独自の内部的なcurrentVersion選択基準があることを仄めかしています。

これを踏まえて、公式のコンフリクト解決サンプルの実装を見ていきます。NSFileVersion.currentVersionOfItem(at:)で最新バージョンを取得しているのに、わざわざ他の全てのバージョンを取得してmodificationDateを比較して勝者を決める設計になっています。

// Make the latest version current and remove the others.
//
private func pickLatestVersion(for documentURL: URL) -> Bool {
    guard let versionsInConflict = NSFileVersion.unresolvedConflictVersionsOfItem(at: documentURL),
          let currentVersion = NSFileVersion.currentVersionOfItem(at: documentURL) else {
        return false
    }
    var shouldRevert = false
    var winner = currentVersion
    for version in versionsInConflict {
        if let date1 = version.modificationDate, let date2 = winner.modificationDate,
           date1 > date2 {
            winner = version
        }
    }
    if winner != currentVersion {
        do {
            try winner.replaceItem(at: documentURL)
            shouldRevert = true
        } catch {
            print("Failed to replace version: \(error)")
        }
    }
    do {
        try NSFileVersion.removeOtherVersionsOfItem(at: documentURL)
    } catch {
        print("Failed to remove other versions: \(error)")
    }
    return shouldRevert
}

(https://developer.apple.com/documentation/uikit/documents_data_and_pasteboard/synchronizing_documents_in_the_icloud_environment, DetailViewController+Conflict.swift, pickLatestVersion(for:))

currentVersionOfItemが必ず最新日付のバージョンを返すならこのような実装は不要のはずです。詳しい意図はコード本体に記載されていませんが、上記を合わせて考えると、どうも競合発生時にNSFileVersionが最新とマークするのは何らかの理由で決められ、更新日でマークされる訳ではないという仮説が強まりました。

実装のススメ

「最新バージョン以外を削除する」という実装だと下記のようにシンプルに実装すれば良いはずです。

do {
    let currentVersion = NSFileVersion.currentVersionOfItem(at: documentURL)
    try NSFileVersion.removeOtherVersionsOfItem(at: documentURL)
    currentVersion?.isResolved = true
} catch {
    print("error")
}

ただし、この記事で仮説立てたように「最新バージョンとしてマークされたFIleVersionが更新日が最も新しいFIleVersionではない可能性がある」として考え、後勝ち戦略を正しく実装しようとするなら、公式サンプルと同じように modificationDateの最も新しいものを勝者とする実装を行った方が良いでしょう。

検証歓迎

検証がかなり困難なので自分で仮説の検証を出来ていません。もし検証出来た方、もしくはドキュメント周りの扱いのエキスパートでご存じの方などいらっしゃいましたらコメント頂けると助かります。

GitHubで編集を提案

Discussion