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:
@@ -428,14 +428,29 @@ struct BackupView: View {
|
|||||||
// Invariant: alreadySafe + needBackup == phoneTotal always.
|
// Invariant: alreadySafe + needBackup == phoneTotal always.
|
||||||
// During active backup show optimistic count (only newly uploaded, not skipped —
|
// During active backup show optimistic count (only newly uploaded, not skipped —
|
||||||
// skipped files are already counted inside snapshot.alreadySafe from the last reconcile).
|
// skipped files are already counted inside snapshot.alreadySafe from the last reconcile).
|
||||||
|
// Live NAS Archive: grows by 1 with each confirmed upload this session.
|
||||||
|
// Skipped files (already on NAS) are NOT added — if nasArchiveTotal came from
|
||||||
|
// a directory scan they're already counted; if from manifest they weren't tracked
|
||||||
|
// but they're present on disk regardless.
|
||||||
|
private var nasArchiveDisplayCount: Int {
|
||||||
|
let base = statusService.snapshot.nasArchiveTotal
|
||||||
|
if engine.job.status.isActive {
|
||||||
|
return base + engine.job.uploadedFiles
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
private var alreadySafeDisplayCount: Int {
|
private var alreadySafeDisplayCount: Int {
|
||||||
let total = statusService.snapshot.phoneTotal
|
let total = statusService.snapshot.phoneTotal
|
||||||
guard total > 0 else { return 0 }
|
guard total > 0 else { return 0 }
|
||||||
let base = statusService.snapshot.alreadySafe
|
let base = statusService.snapshot.alreadySafe
|
||||||
if engine.job.status.isActive {
|
if engine.job.status.isActive {
|
||||||
return min(base + engine.job.uploadedFiles, total)
|
// Uploaded = newly confirmed on NAS this session.
|
||||||
|
// Skipped = fileExists returned true — already on NAS but wasn't in manifest.
|
||||||
|
// Both are now confirmed safe; count them both optimistically.
|
||||||
|
return min(base + engine.job.uploadedFiles + engine.job.skippedFiles, total)
|
||||||
}
|
}
|
||||||
return min(base, total) // enforce invariant at all times
|
return min(base, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var notBackedUpDisplayCount: Int {
|
private var notBackedUpDisplayCount: Int {
|
||||||
@@ -451,7 +466,7 @@ struct BackupView: View {
|
|||||||
VStack(spacing: 7) {
|
VStack(spacing: 7) {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
compactStat(
|
compactStat(
|
||||||
"\(statusService.snapshot.nasArchiveTotal)",
|
"\(nasArchiveDisplayCount)",
|
||||||
label: "NAS Archive",
|
label: "NAS Archive",
|
||||||
color: AppTheme.interactive
|
color: AppTheme.interactive
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,18 +97,35 @@ final class BackupEngine: ObservableObject {
|
|||||||
let spManifest = signposter.beginInterval("ManifestLoad")
|
let spManifest = signposter.beginInterval("ManifestLoad")
|
||||||
|
|
||||||
let manifestData = try? await transfer.downloadData(at: manifestPath)
|
let manifestData = try? await transfer.downloadData(at: manifestPath)
|
||||||
let (baseIndex, baseManifest): (ManifestIndex, BackupManifest) = await Task.detached(priority: .userInitiated) {
|
|
||||||
guard let data = manifestData,
|
// Decode NAS manifest off the main actor.
|
||||||
let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
let nasManifest: BackupManifest? = await Task.detached(priority: .userInitiated) {
|
||||||
else { return (ManifestIndex(nasListing: []), BackupManifest()) }
|
guard let data = manifestData else { return nil }
|
||||||
return (ManifestIndex(manifest: manifest), manifest)
|
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
|
// Authoritative base precedence: NAS → local cache → empty.
|
||||||
logger.info("Manifest: \(baseIndex.totalCount) entries")
|
// 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.
|
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
|
||||||
await NASManifestCache.shared.update(baseManifest)
|
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
|
||||||
|
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user