Resolve iCloud conflict versions before reads (last-writer-wins)

Ports QuickRabbit's resolveConflictsIfAny (R4.3) into the eviction-safe read
path: before every coordinated read, pick the NSFileVersion with the latest
modification date, promote it to the canonical file inside a coordinated
write, mark all conflicts resolved, and prune the losers. Without this,
conflict siblings from two-device edits (workout logs edited mid-session are
the risky case) accumulate silently and a read returns whichever version the
filesystem hands back.

Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
This commit is contained in:
2026-07-04 10:04:46 -04:00
parent 22ea502d2c
commit 61ab9359ee
2 changed files with 54 additions and 4 deletions
+52 -4
View File
@@ -34,11 +34,13 @@ actor ICloudFileManager {
if let error = coordError ?? writeError { throw error }
}
/// Eviction-safe read: materializes an evicted file (bounded wait) before the
/// coordinated read, so callers never decode a dataless placeholder. Throws on
/// download timeout rather than skipping a silently dropped evicted file
/// looks to the user like the record was deleted.
/// Eviction-safe, conflict-safe read: resolves any iCloud conflict versions,
/// materializes an evicted file (bounded wait), then does the coordinated
/// read so callers never decode a dataless placeholder or a stale conflict
/// sibling. Throws on download timeout rather than skipping a silently
/// dropped evicted file looks to the user like the record was deleted.
func read(relativePath: String) async throws -> Data {
resolveConflictsIfAny(at: documentsURL.appendingPathComponent(relativePath))
try await ensureDownloaded(relativePath: relativePath)
let fileURL = documentsURL.appendingPathComponent(relativePath)
var coordError: NSError?
@@ -156,6 +158,52 @@ actor ICloudFileManager {
return components.joined(separator: "/")
}
// MARK: - Conflict resolution
/// If iCloud Drive created conflict versions (concurrent writes from
/// different devices), pick whichever has the latest modification date,
/// promote it to the canonical file, and mark every conflict resolved.
/// Whole-file last-writer-wins by mod-date honest for one-file-per-record
/// data. Left unresolved, conflict siblings accumulate silently and a
/// coordinated read returns whichever version the filesystem hands back.
private func resolveConflictsIfAny(at fileURL: URL) {
let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL) ?? []
guard !conflicts.isEmpty else { return }
let currentDate = NSFileVersion.currentVersionOfItem(at: fileURL)?.modificationDate ?? .distantPast
var winnerDate = currentDate
var winnerVersion: NSFileVersion?
for version in conflicts {
if let date = version.modificationDate, date > winnerDate {
winnerDate = date
winnerVersion = version
}
}
// Promote the winner and prune the losers inside a coordinated write
// these are real file mutations (replaceItem / removeOtherVersions) and
// must not race a concurrent write or the iCloud daemon; NSFileVersion
// does not coordinate internally.
var coordError: NSError?
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in
if let winner = winnerVersion {
do {
try winner.replaceItem(at: url, options: [])
} catch {
print("[Sync] Conflict promotion failed for \(url.lastPathComponent): \(error)")
}
}
for version in conflicts {
version.isResolved = true
}
try? NSFileVersion.removeOtherVersionsOfItem(at: url)
}
if let coordError {
print("[Sync] Conflict coordination failed for \(fileURL.lastPathComponent): \(coordError)")
}
}
// MARK: - Eviction
/// Triggers a download for an evicted file and polls until it materializes.