Fix NAS Archive reset and Already Safe regression

BackupEngine — manifest loading never poisons NASManifestCache:
  Before: if the NAS manifest download failed for any reason,
  BackupManifest() (0 entries) was written to NASManifestCache.
  Every subsequent fast-path reconcile read the poisoned cache and
  set nasArchiveTotal = 0, making every asset appear as needing backup.
  Checkpoint writes then set the cache to 1 entry (first upload), which
  is why NAS Archive flipped from 7422 to 1 when starting a backup.

  After: NAS > cache > empty. If NAS download fails or returns an empty
  manifest, the existing cache is used as the authoritative base and is
  never overwritten. Only a non-empty NAS manifest replaces the cache.

BackupView — live counters stay on persistent totals:
  - nasArchiveDisplayCount: snapshot.nasArchiveTotal + uploadedFiles during
    active backup so the NAS Archive card grows as uploads are confirmed
    instead of jumping to the session count
  - alreadySafeDisplayCount: adds both uploadedFiles AND skippedFiles —
    skipped means the file was already on NAS (fileExists=true) but not
    yet in the manifest, so it is safe and should be counted immediately
  - notBackedUpDisplayCount already derived from alreadySafeDisplayCount
    so it correctly shrinks as uploads and skips are confirmed

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 17:24:56 +03:00
parent ae22d7a739
commit 00e4ecc506
2 changed files with 44 additions and 12 deletions

View File

@@ -97,18 +97,35 @@ final class BackupEngine: ObservableObject {
let spManifest = signposter.beginInterval("ManifestLoad")
let manifestData = try? await transfer.downloadData(at: manifestPath)
let (baseIndex, baseManifest): (ManifestIndex, BackupManifest) = await Task.detached(priority: .userInitiated) {
guard let data = manifestData,
let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
else { return (ManifestIndex(nasListing: []), BackupManifest()) }
return (ManifestIndex(manifest: manifest), manifest)
// Decode NAS manifest off the main actor.
let nasManifest: BackupManifest? = await Task.detached(priority: .userInitiated) {
guard let data = manifestData else { return nil }
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
}.value
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
logger.info("Manifest: \(baseIndex.totalCount) entries")
// Authoritative base precedence: NAS local cache empty.
// CRITICAL: never overwrite a populated cache with an empty/missing NAS result.
// If the download fails or the manifest file doesn't exist yet, writing
// BackupManifest() to NASManifestCache destroys accumulated history and
// causes NAS Archive to drop to 0, making every asset appear as needing backup.
let baseManifest: BackupManifest
if let nas = nasManifest, !nas.entries.isEmpty {
baseManifest = nas
await NASManifestCache.shared.update(nas)
logger.info("Manifest loaded from NAS — \(nas.entries.count) entries")
} else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty {
baseManifest = cached
logger.info("Manifest from cache — \(cached.entries.count) entries (NAS: \(nasManifest == nil ? "download failed" : "file empty"))")
} else {
// Genuinely first backup no manifest anywhere.
baseManifest = nasManifest ?? BackupManifest()
logger.info("Manifest: starting fresh — \(baseManifest.entries.count) entries")
}
let baseIndex = ManifestIndex(manifest: baseManifest)
// Seed local cache so BackupStatusService fast-path sees current NAS state.
await NASManifestCache.shared.update(baseManifest)
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
try Task.checkCancellation()