From 809929e0ed16c11609db47daede2205b61a6ee49 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 18 May 2026 20:52:40 +0300 Subject: [PATCH] fix: update local manifest cache immediately after upload, before NAS write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the alreadySafe=1 loop: after a backup where all assets are collision-skipped (Object Name Collision), writeManifest() connects to the NAS to write the updated manifest. If that connection fails or times out, the cache is never updated with the 263 new entries, so the next refresh(force:true) reads the old 1-entry cache and computes alreadySafe=1 → needBackup=263 → loop. BackupEngine: after the upload loop, immediately merge all manifestEntries (uploaded + collision-skipped + fileExists-skipped) into baseManifest and call NASManifestCache.shared.update(). This happens before refreshAfterBackup so the local truth is correct regardless of whether the NAS write later succeeds. BackupStatusService.writeManifest: move NASManifestCache.shared.update() to before transfer.writeData(). The local cache is now updated as soon as the merged manifest is computed, decoupling local reconcile correctness from NAS write success. The NAS write remains best-effort and non-blocking. Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Services/BackupEngine.swift | 13 ++++++++++++- Services/BackupStatusService.swift | 8 +++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 32b3616..5902b00 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -366,7 +366,18 @@ final class BackupEngine: ObservableObject { signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed") - // Keep directoryCount current without a full rescan. + // Update local manifest cache immediately with every completed entry + // (uploaded + collision-skipped + fileExists-skipped). + // writeManifest() will persist this to the NAS, but even if that network + // write fails the next fast-path reconcile reads the correct counts here. + // Without this, a failed NAS write leaves the cache at 1 entry and the + // next reconcile computes alreadySafe = 1 instead of the real count. + if !manifestEntries.isEmpty { + var localMerged = baseManifest + localMerged.merge(entries: manifestEntries) + await NASManifestCache.shared.update(localMerged) + logger.info("Local cache updated — \(localMerged.entries.count) total entries") + } if uploaded > 0 { await NASManifestCache.shared.addToDirectoryCount(uploaded) } diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 6178193..54cb897 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -432,11 +432,13 @@ final class BackupStatusService: NSObject, ObservableObject { guard let (encoded, mergedManifest) = result else { return } - try await transfer.writeData(encoded, to: manifestPath) - - // Update local cache so fast-path reconcile reads fresh data. + // Update local cache BEFORE the NAS write — decouples reconcile correctness + // from NAS write success. If the write fails/times out, the next fast-path + // reconcile still reads the correct merged manifest from local cache. await NASManifestCache.shared.update(mergedManifest) lastManifest = mergedManifest + + try await transfer.writeData(encoded, to: manifestPath) log.info("writeManifest: done — \(entries.count) new, total=\(mergedManifest.entries.count)") } catch { log.error("writeManifest failed (non-fatal): \(error.localizedDescription)")