diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 876cfd8..ec92267 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -99,16 +99,22 @@ final class BackupEngine: ObservableObject { let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let spManifest = signposter.beginInterval("ManifestLoad") let manifestData = try? await transfer.downloadData(at: manifestPath) - let index: ManifestIndex = await Task.detached(priority: .userInitiated) { + let (index, loadedManifest): (ManifestIndex, BackupManifest?) = await Task.detached(priority: .userInitiated) { guard let data = manifestData, - let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) - else { return ManifestIndex(nasListing: []) } - return ManifestIndex(manifest: manifest) + let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) + else { return (ManifestIndex(nasListing: []), nil) } + return (ManifestIndex(manifest: manifest), manifest) }.value signposter.endInterval("ManifestLoad", spManifest, "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") logger.info("Manifest: \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") + // Seed the local cache so BackupStatusService's fast-path reconcile reflects + // the current NAS state before the upload loop begins. + if let m = loadedManifest { + await NASManifestCache.shared.update(m) + } + try Task.checkCancellation() // ── 4. Build pending queue off the main actor ────────────────────── @@ -156,6 +162,8 @@ final class BackupEngine: ObservableObject { let spUpload = signposter.beginInterval("UploadLoop", "\(pendingAssets.count) files") + let maxRetries = 3 + for (assetIdx, asset) in pendingAssets.enumerated() { if isCancelled { break } while case .paused = job.status { @@ -164,7 +172,6 @@ final class BackupEngine: ObservableObject { let remotePath = "\(connection.remotePath)/\(asset.filename)" - // Mark as uploading if assetIdx < queueItems.count { queueItems[assetIdx].status = .uploading } @@ -180,60 +187,87 @@ final class BackupEngine: ObservableObject { continue } - do { - let spFile = signposter.beginInterval("UploadFile", - "\(asset.filename, privacy: .public)") - let localURL = try await photoService.exportAsset(asset) - defer { try? FileManager.default.removeItem(at: localURL) } + var uploadSucceeded = false + var lastUploadError: Error? - let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) - .flatMap { Int64($0) } ?? 0 - let bytesSoFar = totalBytes - let capturedIdx = assetIdx + for attempt in 0.. 0 { + // Exponential backoff: 2s, 4s before retries 2 and 3 + let backoffNs = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000) + try? await Task.sleep(nanoseconds: backoffNs) + if assetIdx < queueItems.count { + queueItems[assetIdx].retryCount = attempt + queueItems[assetIdx].status = .uploading + } + logger.info("Retry \(attempt)/\(maxRetries - 1) for \(asset.filename, privacy: .public)") } - try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, _ in - let speed = speedTracker.update(bytesSent: sent) - guard throttle.shouldUpdate() else { return } - Task { @MainActor [weak self] in - guard let self else { return } - self.job.updateProgress( - fileName: asset.filename, - fileSize: fileSize, - bytesTransferred: bytesSoFar + sent, - speed: speed - ) - if capturedIdx < self.queueItems.count { - self.queueItems[capturedIdx].bytesUploaded = sent - self.queueItems[capturedIdx].speedBytesPerSec = speed + do { + let spFile = signposter.beginInterval("UploadFile", + "\(asset.filename, privacy: .public)") + let localURL = try await photoService.exportAsset(asset) + defer { try? FileManager.default.removeItem(at: localURL) } + + let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) + .flatMap { Int64($0) } ?? 0 + let bytesSoFar = totalBytes + let capturedIdx = assetIdx + + if capturedIdx < queueItems.count { + queueItems[capturedIdx].totalBytes = fileSize + queueItems[capturedIdx].bytesUploaded = 0 + } + + try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, _ in + let speed = speedTracker.update(bytesSent: sent) + guard throttle.shouldUpdate() else { return } + Task { @MainActor [weak self] in + guard let self else { return } + self.job.updateProgress( + fileName: asset.filename, + fileSize: fileSize, + bytesTransferred: bytesSoFar + sent, + speed: speed + ) + if capturedIdx < self.queueItems.count { + self.queueItems[capturedIdx].bytesUploaded = sent + self.queueItems[capturedIdx].speedBytesPerSec = speed + } } } + + signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes") + totalBytes += fileSize + job.fileCompleted(skipped: false) + uploaded += 1 + uploadSucceeded = true + + if assetIdx < queueItems.count { + queueItems[assetIdx].status = .uploaded + queueItems[assetIdx].bytesUploaded = fileSize + queueItems[assetIdx].completedAt = Date() + } + + manifestEntries.append(ManifestEntry( + localIdentifier: asset.localIdentifier, + filename: asset.filename, + creationDate: asset.creationDate, + fileSize: fileSize, + remotePath: remotePath, + uploadedAt: Date() + )) + } catch { + lastUploadError = error + logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") } - signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes") - totalBytes += fileSize - job.fileCompleted(skipped: false) - uploaded += 1 + if uploadSucceeded { break } + } - if assetIdx < queueItems.count { - queueItems[assetIdx].status = .uploaded - queueItems[assetIdx].bytesUploaded = fileSize - queueItems[assetIdx].completedAt = Date() - } - - manifestEntries.append(ManifestEntry( - localIdentifier: asset.localIdentifier, - filename: asset.filename, - creationDate: asset.creationDate, - fileSize: fileSize, - remotePath: remotePath, - uploadedAt: Date() - )) - } catch { - logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") + if !uploadSucceeded, let error = lastUploadError { + logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") job.fileFailed() failed += 1 if assetIdx < queueItems.count { diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index e431d45..ddda135 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -68,7 +68,8 @@ final class BackupStatusService: NSObject, ObservableObject { func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) - snapshot.nasArchiveTotal += uploaded + // nasArchiveTotal is NOT updated optimistically — writeManifest will update + // NASManifestCache with the full merged count, and refresh(force: true) reads it. saveSnapshot() log.info("Post-backup optimistic update: +\(uploaded) safe") Task { @@ -234,7 +235,7 @@ final class BackupStatusService: NSObject, ObservableObject { let data = try await transfer.downloadData(at: path) // Decode and build index off main actor — JSONDecoder is not cheap for large manifests. let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) { - guard let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) + guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) else { return nil } return (ManifestIndex(manifest: manifest), manifest) }.value @@ -332,23 +333,29 @@ final class BackupStatusService: NSObject, ObservableObject { let existingData = try? await transfer.downloadData(at: manifestPath) // Merge + JSON encode off main actor — can be slow for large manifests. - let encoded: Data? = await Task.detached(priority: .utility) { + let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) { var manifest: BackupManifest if let data = existingData, - let existing = try? JSONDecoder().decode(BackupManifest.self, from: data) { + let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { manifest = existing } else { manifest = BackupManifest() } manifest.merge(entries: entries) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return try? encoder.encode(manifest) + guard let encoded = try? JSONEncoder.kisani.encode(manifest) else { return nil } + return (encoded, manifest) }.value - guard let encoded else { return } + + guard let (encoded, mergedManifest) = result else { return } try await transfer.writeData(encoded, to: manifestPath) - log.info("Manifest written — \(entries.count) new entries") + + // Update local cache immediately — the next reconcile's fast path will read + // this fresh cache instead of re-downloading from NAS, preventing the stale- + // cache bug where nasArchiveTotal resets to the pre-backup count. + await NASManifestCache.shared.update(mergedManifest) + lastManifest = mergedManifest + log.info("Manifest written — \(entries.count) new entries, total \(mergedManifest.entries.count)") } catch { log.error("Manifest write failed (non-fatal): \(error.localizedDescription)") }