Files
Kisani/Services/NASManifestCache.swift
Robin Kutesa 0e65a548f2 fix: NAS operation timeouts, fast-path warm-up, 30-min stale threshold
Three changes that together eliminate the "Checking…" stall and excessive NAS
rescanning without requiring a database migration:

NASManifestCache: extend stale threshold from 5 min to 30 min. directoryCount
is now updated incrementally after every upload, and PHPhotoLibraryChangeObserver
keeps LocalPhotoIndex current — a full NAS roundtrip every 5 min was redundant
and forced the expensive reconcile path far more often than necessary.

BackupStatusService: add withTimeout(_:work:) using a racing ThrowingTaskGroup
so NAS operations (SMB connect, manifest download, directory listing) fail fast
instead of hanging indefinitely when the NAS is slow or unreachable. Timeouts:
15s for connect, 12s for manifest download, 12s for directory listings. On
timeout, BackupError.timeout is caught separately to preserve cached counts
instead of marking connectionState = .offline.

BackupStatusService: call LocalPhotoIndex.shared.loadOrBuild() at the start of
performRefresh before checking localIndexCount. Without this, AppDelegate's
concurrent loadOrBuild() task races with the first onActive() trigger; if it
loses, localIndexCount == 0 and the fast path is bypassed, forcing a full NAS
roundtrip on every cold launch.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 20:41:49 +03:00

113 lines
4.3 KiB
Swift

import Foundation
import os.log
private let log = Logger(subsystem: "com.albert.nasbackup", category: "ManifestCache")
/// Persists a copy of the NAS backup manifest on-device so reconciliation can
/// skip the NAS network round-trip when the cache is still fresh.
///
/// Cache is updated after every successful manifest download and after every
/// completed backup. Stale threshold = 5 minutes. The actor serialises all
/// reads and writes so the cache is safe to use across tasks and threads.
actor NASManifestCache {
static let shared = NASManifestCache()
private var cached: CachedEntry?
private let diskURL: URL
// 30-minute window: directoryCount is updated incrementally after every upload,
// and PHPhotoLibraryChangeObserver keeps LocalPhotoIndex current. A full NAS
// roundtrip every 5 minutes was unnecessary and caused "Checking" stalls.
private static let staleInterval: TimeInterval = 30 * 60
private struct CachedEntry: Codable {
var manifest: BackupManifest
var savedAt: Date
/// Real NAS destination folder file count from the last listDirectory scan.
/// Stored independently from manifest.entries.count the folder may contain
/// files uploaded by other tools that are not in Kisani's manifest.
var directoryCount: Int?
}
private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
diskURL = caches.appendingPathComponent("kisani_manifest_cache.json")
if let data = try? Data(contentsOf: diskURL),
let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) {
cached = entry
log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk, dirCount=\(entry.directoryCount.map(String.init) ?? "nil")")
}
}
/// Returns the cached manifest without any I/O. Never nil after first reconcile.
var manifest: BackupManifest? { cached?.manifest }
/// Real NAS folder file count from the last listDirectory scan.
/// nil until the first full reconcile completes.
var directoryCount: Int? { cached?.directoryCount }
/// True when the cache is absent or older than the stale threshold.
var isStale: Bool {
guard let c = cached else { return true }
return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval
}
/// Persist a freshly-downloaded manifest. Preserves any existing directoryCount.
func update(_ manifest: BackupManifest) {
cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount)
log.info("ManifestCache: updated — \(manifest.entries.count) entries")
saveToDisk()
}
/// Store the real NAS destination folder file count (from listDirectory scan).
func setDirectoryCount(_ count: Int) {
if var entry = cached {
entry.directoryCount = count
cached = entry
} else {
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: count)
}
log.info("ManifestCache: directoryCount=\(count)")
saveToDisk()
}
/// Increment the stored directory count after a successful upload batch.
func addToDirectoryCount(_ delta: Int) {
guard delta > 0 else { return }
let next = (cached?.directoryCount ?? 0) + delta
if var entry = cached {
entry.directoryCount = next
cached = entry
} else {
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: next)
}
log.info("ManifestCache: directoryCount += \(delta)\(next)")
saveToDisk()
}
private func saveToDisk() {
guard let entry = cached else { return }
Task.detached(priority: .utility) { [entry, url = diskURL] in
guard let data = try? JSONEncoder.kisani.encode(entry) else { return }
try? data.write(to: url, options: .atomic)
}
}
}
// MARK: Shared encoder/decoder
extension JSONEncoder {
static let kisani: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
}
extension JSONDecoder {
static let kisani: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
}