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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user