fix: NAS Archive always shows real directory count, not manifest entry count

- NASManifestCache: add directoryCount field (Int?) stored independently from
  manifest.entries.count; add setDirectoryCount(_:) and addToDirectoryCount(_:);
  update(_:) now preserves existing directoryCount so manifest refreshes never
  reset the real folder count to zero
- BackupEngine step 3: switch from "NAS if non-empty, else cache" to
  max(NAS, cache) by entry count — a 1-entry NAS manifest no longer overwrites
  a 264-entry cache, preventing history loss and the Already Safe = 1 regression
- BackupEngine post-upload: call addToDirectoryCount(uploaded) so NAS Archive
  increments correctly (7422 → 7423) without requiring a full directory rescan
- BackupStatusService.buildManifestIndex: call setDirectoryCount(dirCount) in
  both the validity-check path (manifest ≤ 1 entry) and the bootstrap path so
  the real folder count survives across reconcile cycles
- BackupStatusService.writeManifest: same max(NAS, cache) base-selection logic
  to fix Already Safe = 1 after backup ends (merge base was 1-entry NAS instead
  of 264-entry cache)
- BackupStatusService.reconcile (fast + full path): use max(index.totalCount,
  directoryCount) for nasArchiveTotal so Gallery and stats strip reflect the
  real NAS folder count, not just Kisani manifest entries

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 18:03:56 +03:00
parent 00e4ecc506
commit 0acf111d32
3 changed files with 89 additions and 32 deletions

View File

@@ -104,19 +104,22 @@ final class BackupEngine: ObservableObject {
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
}.value }.value
// Authoritative base precedence: NAS local cache empty. // Use the manifest with more entries as the authoritative base.
// CRITICAL: never overwrite a populated cache with an empty/missing NAS result. // CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264),
// If the download fails or the manifest file doesn't exist yet, writing // the cache preserves accumulated history and must win. Overwriting the cache with a
// BackupManifest() to NASManifestCache destroys accumulated history and // smaller NAS manifest destroys history and causes every asset to re-upload.
// causes NAS Archive to drop to 0, making every asset appear as needing backup. let cachedManifest = await NASManifestCache.shared.manifest
let cacheCount = cachedManifest?.entries.count ?? 0
let nasCount = nasManifest?.entries.count ?? 0
let baseManifest: BackupManifest let baseManifest: BackupManifest
if let nas = nasManifest, !nas.entries.isEmpty { if nasCount > 0, nasCount >= cacheCount {
baseManifest = nas baseManifest = nasManifest!
await NASManifestCache.shared.update(nas) await NASManifestCache.shared.update(nasManifest!)
logger.info("Manifest loaded from NAS — \(nas.entries.count) entries") logger.info("Manifest from NAS — \(nasCount) entries")
} else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty { } else if cacheCount > 0 {
baseManifest = cached baseManifest = cachedManifest!
logger.info("Manifest from cache — \(cached.entries.count) entries (NAS: \(nasManifest == nil ? "download failed" : "file empty"))") logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))")
} else { } else {
// Genuinely first backup no manifest anywhere. // Genuinely first backup no manifest anywhere.
baseManifest = nasManifest ?? BackupManifest() baseManifest = nasManifest ?? BackupManifest()
@@ -320,6 +323,11 @@ final class BackupEngine: ObservableObject {
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed") signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
// Keep directoryCount current without a full rescan.
if uploaded > 0 {
await NASManifestCache.shared.addToDirectoryCount(uploaded)
}
let duration = Date().timeIntervalSince(startDate) let duration = Date().timeIntervalSince(startDate)
let result = BackupResult( let result = BackupResult(
uploadedCount: uploaded, uploadedCount: uploaded,

View File

@@ -153,15 +153,17 @@ final class BackupStatusService: NSObject, ObservableObject {
}.value }.value
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter) let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
let nasTotal = max(index.totalCount, dirCount)
var updated = BackupStatusSnapshot() var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal) updated.alreadySafe = min(safe, phoneTotal)
updated.nasArchiveTotal = index.totalCount updated.nasArchiveTotal = nasTotal
updated.connectionState = .connected updated.connectionState = .connected
updated.lastCheckedAt = Date() updated.lastCheckedAt = Date()
snapshot = updated snapshot = updated
saveSnapshot() saveSnapshot()
log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))") log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))")
return return
} }
@@ -186,13 +188,12 @@ final class BackupStatusService: NSObject, ObservableObject {
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts") log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
refreshError = reason refreshError = reason
// Restore last-known NAS archive count from manifest cache so the dashboard // Restore last-known NAS archive count so the dashboard
// doesn't show 0 when the NAS is temporarily offline. // doesn't show 0 when the NAS is temporarily offline.
if let cached = await NASManifestCache.shared.manifest { let cachedCount = (await NASManifestCache.shared.manifest).map { ManifestIndex(manifest: $0).totalCount } ?? 0
let cachedCount = ManifestIndex(manifest: cached).totalCount let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount) snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount, cachedDirCount)
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache") log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) (manifest=\(cachedCount) dir=\(cachedDirCount))")
}
snapshot.phoneTotal = phoneTotal snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
snapshot.connectionState = .offline snapshot.connectionState = .offline
@@ -232,14 +233,17 @@ final class BackupStatusService: NSObject, ObservableObject {
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
// Step 5: Commit // Step 5: Commit
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
let nasTotal = max(index.totalCount, dirCount)
var updated = BackupStatusSnapshot() var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal) updated.alreadySafe = min(safe, phoneTotal)
updated.nasArchiveTotal = index.totalCount updated.nasArchiveTotal = nasTotal
updated.connectionState = .connected updated.connectionState = .connected
updated.lastCheckedAt = Date() updated.lastCheckedAt = Date()
snapshot = updated snapshot = updated
saveSnapshot() saveSnapshot()
log.info("Reconcile (full): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))")
// Invariant check alreadySafe + needBackup must always equal phoneTotal // Invariant check alreadySafe + needBackup must always equal phoneTotal
let inv = updated.alreadySafe + updated.needBackup let inv = updated.alreadySafe + updated.needBackup
@@ -281,6 +285,7 @@ final class BackupStatusService: NSObject, ObservableObject {
}.count }.count
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max") log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
if dirCount > index.totalCount { if dirCount > index.totalCount {
await NASManifestCache.shared.setDirectoryCount(dirCount)
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount) return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
} }
} }
@@ -294,9 +299,11 @@ final class BackupStatusService: NSObject, ObservableObject {
// Bootstrap: list NAS directory and build filename-only index in background. // Bootstrap: list NAS directory and build filename-only index in background.
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)") log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)")
return await Task.detached(priority: .utility) { let bootstrapIndex = await Task.detached(priority: .utility) {
ManifestIndex(nasListing: items) ManifestIndex(nasListing: items)
}.value }.value
await NASManifestCache.shared.setDirectoryCount(bootstrapIndex.totalCount)
return bootstrapIndex
} }
/// Enumerates a PHFetchResult in batches using autoreleasepool. /// Enumerates a PHFetchResult in batches using autoreleasepool.
@@ -384,13 +391,18 @@ final class BackupStatusService: NSObject, ObservableObject {
let existingData = try? await transfer.downloadData(at: manifestPath) let existingData = try? await transfer.downloadData(at: manifestPath)
// Merge + JSON encode off main actor can be slow for large manifests. // Merge + JSON encode off main actor can be slow for large manifests.
// Use the manifest with more entries as the merge base so history is never lost
// if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264).
let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) { let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) {
let nasManifest = existingData.flatMap { try? JSONDecoder.kisani.decode(BackupManifest.self, from: $0) }
let nasCount = nasManifest?.entries.count ?? 0
let cacheCount = cacheBase?.entries.count ?? 0
var manifest: BackupManifest var manifest: BackupManifest
if let data = existingData, if nasCount > 0, nasCount >= cacheCount {
let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { manifest = nasManifest! // NAS at least as current authoritative
manifest = existing // NAS is authoritative when readable } else if cacheCount > 0 {
} else if let base = cacheBase { manifest = cacheBase! // Cache has more history preserve it
manifest = base // NAS download failed preserve history from cache
} else { } else {
manifest = BackupManifest() manifest = BackupManifest()
} }

View File

@@ -19,33 +19,70 @@ actor NASManifestCache {
private struct CachedEntry: Codable { private struct CachedEntry: Codable {
var manifest: BackupManifest var manifest: BackupManifest
var savedAt: Date 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() { private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
diskURL = caches.appendingPathComponent("kisani_manifest_cache.json") diskURL = caches.appendingPathComponent("kisani_manifest_cache.json")
// Load from disk immediately so the first `manifest` access is instant.
if let data = try? Data(contentsOf: diskURL), if let data = try? Data(contentsOf: diskURL),
let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) { let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) {
cached = entry cached = entry
log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk") 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. /// Returns the cached manifest without any I/O. Never nil after first reconcile.
var manifest: BackupManifest? { cached?.manifest } 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. /// True when the cache is absent or older than the stale threshold.
var isStale: Bool { var isStale: Bool {
guard let c = cached else { return true } guard let c = cached else { return true }
return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval
} }
/// Persist a freshly-downloaded manifest. Call after every successful NAS download. /// Persist a freshly-downloaded manifest. Preserves any existing directoryCount.
func update(_ manifest: BackupManifest) { func update(_ manifest: BackupManifest) {
let entry = CachedEntry(manifest: manifest, savedAt: Date()) cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount)
cached = entry
log.info("ManifestCache: updated — \(manifest.entries.count) entries") 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 Task.detached(priority: .utility) { [entry, url = diskURL] in
guard let data = try? JSONEncoder.kisani.encode(entry) else { return } guard let data = try? JSONEncoder.kisani.encode(entry) else { return }
try? data.write(to: url, options: .atomic) try? data.write(to: url, options: .atomic)