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

@@ -68,6 +68,39 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
return assets
}
/// Returns a raw PHFetchResult for memory-safe reconciliation.
/// No PhotoAsset array is created caller enumerates in batches.
func fetchResultForReconciliation(filter: BackupFilter) -> PHFetchResult<PHAsset> {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
var subpredicates: [NSPredicate] = []
var mediaTypes: [PHAssetMediaType] = []
if filter.includePhotos { mediaTypes.append(.image) }
if filter.includeVideos { mediaTypes.append(.video) }
if !mediaTypes.isEmpty {
subpredicates.append(NSPredicate(
format: "mediaType IN %@",
mediaTypes.map(\.rawValue)
))
}
if !filter.includeScreenshots {
subpredicates.append(NSPredicate(
format: "NOT ((mediaSubtype & %d) != 0)",
PHAssetMediaSubtype.photoScreenshot.rawValue
))
}
if subpredicates.count > 1 {
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
} else {
options.predicate = subpredicates.first
}
return PHAsset.fetchAssets(with: options)
}
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
let fetchResult = PHAsset.fetchAssets(
withLocalIdentifiers: [asset.localIdentifier],