From b18a2aad143ef01a532548d96dd741a9f5c3b358 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 18 May 2026 00:25:45 +0300 Subject: [PATCH] fix: remove NAS directory healing (inflated archive count) and record skipped files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory healing step added every media file in the NAS folder as a manifest orphan — including files from other devices — inflating NAS Archive to 7421 and polluting the manifest with unrelated entries. Removed entirely. Root cause of the loop: when BackupEngine calls fileExists and skips a file already on NAS, it never added that file to manifestEntries. The file existed on NAS but not in the manifest, so every reconcile still showed it as pending, triggering a new backup on every app open. Fix: skipped files are now added to manifestEntries with their localIdentifier so they are written to the manifest at the end of the run (and at checkpoints). Next reconcile sees them as safe via localIdentifier match and needBackup decrements. Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Services/BackupEngine.swift | 81 +++++++++++-------------------------- 1 file changed, 24 insertions(+), 57 deletions(-) diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index ae0625f..b46474f 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -92,76 +92,33 @@ final class BackupEngine: ObservableObject { try Task.checkCancellation() - // ── 3. Load manifest + heal orphaned files ───────────────────────── - // After any interrupted backup, files may sit on NAS without a manifest - // entry. We list the directory and merge any untracked files so the - // gallery and dedup logic reflect actual NAS contents. + // ── 3. Load manifest ─────────────────────────────────────────────── let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let spManifest = signposter.beginInterval("ManifestLoad") let manifestData = try? await transfer.downloadData(at: manifestPath) - let nasItems = (try? await transfer.listDirectory(at: connection.remotePath)) ?? [] - - // Decode + heal off main actor (CPU-bound work). - struct ManifestLoadResult { var index: ManifestIndex; var manifest: BackupManifest; var orphanCount: Int } - var loadResult: ManifestLoadResult = await Task.detached(priority: .userInitiated) { - var manifest: BackupManifest - if let data = manifestData, - let decoded = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { - manifest = decoded - } else { - manifest = BackupManifest.buildFromNASListing(nasItems) - return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: 0) - } - - // Heal: find NAS files not tracked in the manifest (orphans from - // backups that were interrupted before the manifest write step). - let knownFilenames = Set(manifest.entries.map { $0.filename.lowercased() }) - let mediaExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp", - "tiff","tif","raw","dng","cr2","nef","arw", - "mp4","mov","m4v","avi","mkv"] - let orphans: [ManifestEntry] = nasItems.compactMap { item in - guard !item.isDirectory, - item.name != BackupManifest.remoteFilename, - !item.name.hasPrefix("."), - mediaExts.contains((item.name as NSString).pathExtension.lowercased()), - !knownFilenames.contains(item.name.lowercased()) else { return nil } - return ManifestEntry( - localIdentifier: "", - filename: item.name, - creationDate: item.modifiedDate, - fileSize: item.size, - remotePath: item.path, - uploadedAt: item.modifiedDate ?? Date() - ) - } - if !orphans.isEmpty { manifest.merge(entries: orphans) } - return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: orphans.count) + let (baseIndex, baseManifest): (ManifestIndex, BackupManifest) = await Task.detached(priority: .userInitiated) { + guard let data = manifestData, + let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) + else { return (ManifestIndex(nasListing: []), BackupManifest()) } + return (ManifestIndex(manifest: manifest), manifest) }.value - signposter.endInterval("ManifestLoad", spManifest, - "\(loadResult.index.totalCount) entries, orphans=\(loadResult.orphanCount)") - logger.info("Manifest: \(loadResult.index.totalCount) entries (\(loadResult.orphanCount) orphans healed)") + signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries") + logger.info("Manifest: \(baseIndex.totalCount) entries") - // If the manifest gained entries from healing, write it back to NAS now - // so the gallery reflects actual NAS contents even before uploads start. - if loadResult.orphanCount > 0, let encoded = try? JSONEncoder.kisani.encode(loadResult.manifest) { - try? await transfer.writeData(encoded, to: manifestPath) - } - - // Always seed local cache with the current (healed) state. - await NASManifestCache.shared.update(loadResult.manifest) + // Seed local cache so BackupStatusService fast-path sees current NAS state. + await NASManifestCache.shared.update(baseManifest) try Task.checkCancellation() // ── 4. Build pending queue off the main actor ────────────────────── let spFilter = signposter.beginInterval("BuildPendingQueue") - let currentIndex = loadResult.index let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) { assets.filter { asset in - !currentIndex.matches(localIdentifier: asset.localIdentifier) && - !(currentIndex.isFilenameOnly && currentIndex.matches(filename: asset.filename)) && - !currentIndex.matchesUnclaimed(filename: asset.filename) + !baseIndex.matches(localIdentifier: asset.localIdentifier) && + !(baseIndex.isFilenameOnly && baseIndex.matches(filename: asset.filename)) && + !baseIndex.matchesUnclaimed(filename: asset.filename) } }.value signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending") @@ -222,6 +179,16 @@ final class BackupEngine: ObservableObject { queueItems[assetIdx].status = .skipped queueItems[assetIdx].completedAt = Date() } + // Record in manifest even though we didn't upload — the file is on NAS. + // Without this, skipped files stay "pending" forever and trigger backup loops. + manifestEntries.append(ManifestEntry( + localIdentifier: asset.localIdentifier, + filename: asset.filename, + creationDate: asset.creationDate, + fileSize: 0, + remotePath: remotePath, + uploadedAt: Date() + )) logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)") continue } @@ -302,7 +269,7 @@ final class BackupEngine: ObservableObject { // Uses the same open connection so no extra connect/disconnect overhead. if uploaded - lastCheckpointAt >= checkpointInterval { lastCheckpointAt = uploaded - var checkpoint = loadResult.manifest + var checkpoint = baseManifest checkpoint.merge(entries: manifestEntries) if let encoded = try? JSONEncoder.kisani.encode(checkpoint) { try? await transfer.writeData(encoded, to: manifestPath)