Make backup status reconciliation memory-safe

Replace full [PhotoAsset] array enumeration with PHFetchResult batch
enumeration (autoreleasepool + Task.yield per batch) to keep memory
bounded during reconciliation. Introduce ManifestIndex with two O(1)
Set<String> lookups (localIdentifier + lowercased filename) instead of
O(n×m) linear scans. Scope manifest Data+struct inside buildManifestIndex
so they are released by ARC before the comparison loop begins. Add
UIApplication.didReceiveMemoryWarningNotification handler to cancel
in-flight tasks under memory pressure. Separate debounceTask from
refreshTask to avoid cancelling long-running reconciles on every photo
library event. fetchResultForReconciliation returns PHFetchResult
directly — no [PhotoAsset] array is ever allocated during reconciliation.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 13:06:44 +03:00
parent 2225629dd8
commit 4a7ba9b332
3 changed files with 259 additions and 63 deletions

View File

@@ -1,4 +1,54 @@
import Foundation
import Photos
// MARK: ManifestIndex
// Lightweight comparison structure two Sets for O(1) lookups.
// Built from BackupManifest and then the manifest is released.
struct ManifestIndex {
let localIdentifiers: Set<String> // PHAsset.localIdentifier (primary key)
let lowercasedFilenames: Set<String> // filename fallback for legacy/bootstrap entries
let totalCount: Int // non-hidden file count for NAS Archive stat
var isFilenameOnly: Bool { localIdentifiers.isEmpty }
func matches(localIdentifier id: String) -> Bool {
!id.isEmpty && localIdentifiers.contains(id)
}
func matches(filename: String) -> Bool {
lowercasedFilenames.contains(filename.lowercased())
}
// Build from decoded manifest manifest released by caller after this returns
init(manifest: BackupManifest) {
var ids = Set<String>(minimumCapacity: manifest.entries.count)
var names = Set<String>(minimumCapacity: manifest.entries.count)
var count = 0
for entry in manifest.entries {
if !entry.filename.hasPrefix(".") { count += 1 }
if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) }
names.insert(entry.filename.lowercased())
}
self.localIdentifiers = ids
self.lowercasedFilenames = names
self.totalCount = count
}
// Build from NAS directory listing (bootstrap no localIdentifiers known)
init(nasListing items: [NASItem]) {
var names = Set<String>()
var count = 0
for item in items where !item.isDirectory && item.name != BackupManifest.remoteFilename {
if !item.name.hasPrefix(".") { count += 1 }
names.insert(item.name.lowercased())
}
self.localIdentifiers = []
self.lowercasedFilenames = names
self.totalCount = count
}
}
// MARK: ManifestEntry
struct ManifestEntry: Codable {
let localIdentifier: String // PHAsset.localIdentifier primary key