fix: remove NAS directory healing (inflated archive count) and record skipped files

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 <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 00:25:45 +03:00
parent 2b2d869ae7
commit b18a2aad14

View File

@@ -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<String> = ["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)