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:
@@ -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.
|
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**
|
**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.
|
Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel.
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ actor ICloudFileManager {
|
|||||||
if let error = coordError ?? writeError { throw error }
|
if let error = coordError ?? writeError { throw error }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eviction-safe read: materializes an evicted file (bounded wait) before the
|
/// Eviction-safe, conflict-safe read: resolves any iCloud conflict versions,
|
||||||
/// coordinated read, so callers never decode a dataless placeholder. Throws on
|
/// materializes an evicted file (bounded wait), then does the coordinated
|
||||||
/// download timeout rather than skipping — a silently dropped evicted file
|
/// read — so callers never decode a dataless placeholder or a stale conflict
|
||||||
/// looks to the user like the record was deleted.
|
/// 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 {
|
func read(relativePath: String) async throws -> Data {
|
||||||
|
resolveConflictsIfAny(at: documentsURL.appendingPathComponent(relativePath))
|
||||||
try await ensureDownloaded(relativePath: relativePath)
|
try await ensureDownloaded(relativePath: relativePath)
|
||||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
||||||
var coordError: NSError?
|
var coordError: NSError?
|
||||||
@@ -156,6 +158,52 @@ actor ICloudFileManager {
|
|||||||
return components.joined(separator: "/")
|
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
|
// MARK: - Eviction
|
||||||
|
|
||||||
/// Triggers a download for an evicted file and polls until it materializes.
|
/// Triggers a download for an evicted file and polls until it materializes.
|
||||||
|
|||||||
Reference in New Issue
Block a user