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
+2
View File
@@ -2,6 +2,8 @@
Fixed workouts and splits vanishing from the app when iPhone storage ran low: when iOS offloads their files to iCloud to free up space, Workouts now downloads them back automatically instead of treating them as gone.
When the same workout or split is edited on two devices around the same time, all devices now settle on the most recent change instead of some keeping an older version.
**June 2026**
Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel.
+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.