perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -129,9 +129,35 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
let store = ConnectionStore.shared
|
||||
guard let conn = store.savedConnection else { return }
|
||||
|
||||
// ── Step 1: Phone count ───────────────────────────────────────────
|
||||
// PHFetchResult.count is O(1) after the initial index build — fast on main actor.
|
||||
let filter = store.backupFilter
|
||||
|
||||
// ── Fast path: use local caches when both are warm ────────────────
|
||||
// Skips NAS connection + PHFetchResult batch enumeration entirely.
|
||||
// Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data.
|
||||
let manifestCacheIsStale = await NASManifestCache.shared.isStale
|
||||
let localIndexCount = await LocalPhotoIndex.shared.totalCount
|
||||
|
||||
if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
|
||||
let index = await Task.detached(priority: .utility) {
|
||||
ManifestIndex(manifest: cached)
|
||||
}.value
|
||||
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
||||
let phoneTotal = await LocalPhotoIndex.shared.totalCount
|
||||
var updated = BackupStatusSnapshot()
|
||||
updated.phoneTotal = phoneTotal
|
||||
updated.alreadySafe = min(safe, phoneTotal)
|
||||
updated.nasArchiveTotal = index.totalCount
|
||||
updated.connectionState = .connected
|
||||
updated.lastCheckedAt = Date()
|
||||
snapshot = updated
|
||||
saveSnapshot()
|
||||
log.info("Reconcile (cached): \(phoneTotal) phone, \(safe) safe, \(index.totalCount) NAS")
|
||||
return
|
||||
}
|
||||
|
||||
// ── Full path: connect to NAS and download fresh manifest ─────────
|
||||
|
||||
// Step 1: Phone count (O(1) PHFetchResult.count)
|
||||
let fetchResult = photoService.fetchResultForReconciliation(filter: filter)
|
||||
let phoneTotal = fetchResult.count
|
||||
log.info("PHFetchResult count: \(phoneTotal)")
|
||||
@@ -159,27 +185,29 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
try Task.checkCancellation()
|
||||
|
||||
// ── Step 3: Load ManifestIndex ────────────────────────────────────
|
||||
// Download suspends main actor (good). JSON decode + Set<String> construction
|
||||
// is CPU-bound O(N) — runs in a background task so touch/animation remain smooth.
|
||||
let spManifest = signposter.beginInterval("ManifestLoad")
|
||||
let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)"
|
||||
let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn)
|
||||
signposter.endInterval("ManifestLoad", spManifest,
|
||||
"\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)")
|
||||
log.info("Manifest index built — \(index.totalCount) NAS entries, filename-only=\(index.isFilenameOnly)")
|
||||
log.info("Manifest index built — \(index.totalCount) NAS entries")
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
// ── Step 4: Count safe assets — off main actor ────────────────────
|
||||
// Batch enumeration of PHFetchResult is CPU-bound (up to N/150 batches).
|
||||
// Running it in a detached task keeps the main thread free for the UI
|
||||
// while the count is in progress. Task.yield() inside countSafe lets other
|
||||
// async work interleave between batches.
|
||||
// ── Step 4: Count safe assets ─────────────────────────────────────
|
||||
// Prefer LocalPhotoIndex (pure in-memory, no CoreData) if populated;
|
||||
// otherwise fall back to batch PHFetchResult enumeration.
|
||||
let spCount = signposter.beginInterval("CountSafe")
|
||||
let safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in
|
||||
guard let self else { throw CancellationError() }
|
||||
return try await self.countSafe(in: fetchResult, against: index)
|
||||
}.value
|
||||
let localCount = await LocalPhotoIndex.shared.totalCount
|
||||
let safe: Int
|
||||
if localCount > 0 {
|
||||
safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
||||
} else {
|
||||
safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in
|
||||
guard let self else { throw CancellationError() }
|
||||
return try await self.countSafe(in: fetchResult, against: index)
|
||||
}.value
|
||||
}
|
||||
signposter.endInterval("CountSafe", spCount, "\(safe)/\(phoneTotal) safe")
|
||||
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
|
||||
|
||||
@@ -212,6 +240,8 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
}.value
|
||||
if let (index, manifest) = decoded {
|
||||
lastManifest = manifest
|
||||
// Persist to local cache so future launches skip the NAS round-trip.
|
||||
Task { await NASManifestCache.shared.update(manifest) }
|
||||
return index
|
||||
}
|
||||
} catch {
|
||||
@@ -346,6 +376,20 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
|
||||
extension BackupStatusService: PHPhotoLibraryChangeObserver {
|
||||
nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) {
|
||||
// Update LocalPhotoIndex incrementally — avoids full rebuild on every change.
|
||||
// We need the fetch result for change details; rebuild from scratch if unavailable.
|
||||
Task(priority: .utility) {
|
||||
let opts = PHFetchOptions()
|
||||
let allAssets = PHAsset.fetchAssets(with: opts)
|
||||
if let details = changeInstance.changeDetails(for: allAssets) {
|
||||
await LocalPhotoIndex.shared.apply(
|
||||
inserted: details.insertedObjects,
|
||||
removed: details.removedObjects,
|
||||
changed: details.changedObjects
|
||||
)
|
||||
await LocalPhotoIndex.shared.flush()
|
||||
}
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.debounceTask?.cancel()
|
||||
|
||||
Reference in New Issue
Block a user