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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user