Make reads eviction-safe: download offloaded iCloud files instead of losing them
Wires the previously-dead ensureDownloaded into every read (it now polls, bounded 30s, instead of fire-and-forget) and fixes the deeper hole: the directory listing skipped hidden files, so evicted records — whose only local trace is a hidden .<name>.json.icloud placeholder — never appeared in reconcile at all and their cache entities were pruned as deleted. Also: reconcile no longer prunes entities whose file failed to read this pass (an eviction-download timeout is not a deletion); tombstone IDs come from stub filenames so an evicted stub still vetoes resurrection; the stale-file stub check is placeholder-aware; read failures log loudly instead of try?-skipping. Verified with a harness compiling the real ICloudFileManager against a local temp directory (placeholder mapping, dedup, tombstone derivation, soft-delete round trip). Real eviction/download behavior needs a device pass. Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
This commit is contained in:
@@ -24,6 +24,14 @@ final class SyncEngine {
|
||||
/// this to push fresh state to the watch.
|
||||
var onCacheChanged: (() -> Void)?
|
||||
|
||||
/// Called when a completed workout is persisted. The HealthKit writer uses this to
|
||||
/// schedule an estimated Health workout if nothing recorded it.
|
||||
var onWorkoutCompleted: ((WorkoutDocument) -> Void)?
|
||||
|
||||
/// Called when a workout carrying watch-recorded metrics is persisted, so the
|
||||
/// writer can cancel any pending phone estimate for it.
|
||||
var onWatchMetricsArrived: ((String) -> Void)?
|
||||
|
||||
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
||||
private let modelContainer: ModelContainer
|
||||
private var fileManager: ICloudFileManager?
|
||||
@@ -193,6 +201,10 @@ final class SyncEngine {
|
||||
guard let fm = fileManager else { return }
|
||||
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
|
||||
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
|
||||
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
|
||||
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
|
||||
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
|
||||
if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) }
|
||||
}
|
||||
|
||||
func delete(split: Split) async {
|
||||
@@ -217,15 +229,24 @@ final class SyncEngine {
|
||||
|
||||
private func importFile(relativePath: String) async {
|
||||
guard let fm = fileManager else { return }
|
||||
guard let data = try? await fm.read(relativePath: relativePath) else { return }
|
||||
let data: Data
|
||||
do {
|
||||
data = try await fm.read(relativePath: relativePath)
|
||||
} catch {
|
||||
// Includes eviction-download timeouts — log loudly, never silently
|
||||
// treat an unreadable file as absent. The next monitor event or
|
||||
// reconcile retries it.
|
||||
log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if relativePath.hasPrefix("Splits/") {
|
||||
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
|
||||
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
|
||||
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return }
|
||||
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
|
||||
} else if relativePath.hasPrefix("Workouts/") {
|
||||
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
|
||||
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
|
||||
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return }
|
||||
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
||||
}
|
||||
}
|
||||
@@ -238,14 +259,29 @@ final class SyncEngine {
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
let tombstoned = Set(await fm.listTombstones().map(\.id))
|
||||
// IDs from stub filenames, not stub contents — an evicted stub must
|
||||
// still count as a tombstone or the deleted record resurrects.
|
||||
let tombstoned = await fm.listTombstoneIDs()
|
||||
let dataFiles = await fm.listDataFiles()
|
||||
|
||||
var liveSplitIDs = Set<String>()
|
||||
var liveWorkoutIDs = Set<String>()
|
||||
var unreadablePaths = Set<String>()
|
||||
|
||||
for path in dataFiles {
|
||||
guard let data = try? await fm.read(relativePath: path) else { continue }
|
||||
let data: Data
|
||||
do {
|
||||
data = try await fm.read(relativePath: path)
|
||||
} catch {
|
||||
// A failed read (eviction-download timeout, coordination error)
|
||||
// is not proof the record is gone — remember the path so the
|
||||
// prune below keeps its cache entity. Skipping silently here is
|
||||
// how evicted records used to vanish on storage-constrained
|
||||
// devices.
|
||||
log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
|
||||
unreadablePaths.insert(path)
|
||||
continue
|
||||
}
|
||||
if path.hasPrefix("Splits/") {
|
||||
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
|
||||
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
|
||||
@@ -259,12 +295,18 @@ final class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Prune cache entities no longer backed by a live file.
|
||||
// Prune cache entities no longer backed by a live file — but never for
|
||||
// a path that failed to read this pass (it may just be un-downloadable
|
||||
// right now; pruning would make an eviction look like a deletion).
|
||||
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||
for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||
for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) {
|
||||
context.delete(s)
|
||||
}
|
||||
}
|
||||
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
||||
for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
|
||||
for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) {
|
||||
context.delete(w)
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
onCacheChanged?()
|
||||
|
||||
Reference in New Issue
Block a user