Files
Kisani/Services/BackupEngine.swift
Robin Kutesa fb6ecc10be fix: pending queue uses NAS directory filenames when manifest is sparse
BackupEngine was building pendingAssets from ManifestIndex(manifest:) which
only knows about files Kisani previously uploaded. When most NAS files were
uploaded by other tools, nearly all phone photos passed the pending filter
and "Back up again" would queue and attempt to re-upload everything.

Fix: step 3b builds a filterIndex from the cached NAS directory filenames
(or a fresh listDirectory scan when cache is unavailable) when the directory
has more files than the manifest. The filename-based filterIndex correctly
identifies only photos whose filenames are absent from the NAS, so "Back up
again" queues exactly the delta — not a full library re-upload.

The baseManifest/baseIndex are unchanged and still drive the manifest write.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 23:24:46 +03:00

537 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Photos
import os.log
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupEngine")
@MainActor
final class BackupEngine: ObservableObject {
static let shared = BackupEngine()
@Published private(set) var job: BackupJob = BackupJob()
@Published private(set) var queueItems: [BackupQueueItem] = []
private let photoService: PhotoLibraryProtocol
private let transferFactory: (NASProtocol) -> any NASTransferProtocol
private var activeTransfer: (any NASTransferProtocol)?
private var isCancelled = false
private static let queueCacheURL: URL = {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
return caches.appendingPathComponent("kisani_last_queue.json")
}()
private init(
photos: PhotoLibraryProtocol = PhotoLibraryService(),
transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in
switch proto {
case .smb: return SMBService()
case .sftp: return SFTPService()
}
}
) {
self.photoService = photos
self.transferFactory = transferFactory
// Load last session's queue so SyncView has data to show on next launch.
if let data = try? Data(contentsOf: Self.queueCacheURL),
let items = try? JSONDecoder.kisani.decode([BackupQueueItem].self, from: data) {
self.queueItems = items
}
}
static func testInstance(
transfer: any NASTransferProtocol,
photos: PhotoLibraryProtocol
) -> BackupEngine {
BackupEngine(photos: photos, transferFactory: { _ in transfer })
}
func run(
connection: NASConnection,
filter: BackupFilter,
triggeredByLAN: Bool = false
) async throws -> BackupResult {
isCancelled = false
let startDate = Date()
let store = ConnectionStore.shared
let lan = LANMonitor.shared
if !triggeredByLAN && lan.isOnCellular && !store.allowCellularBackup {
throw BackupError.networkUnavailable
}
let host = resolveHost(connection: connection, store: store, lan: lan)
// 1. Fetch assets off the main actor
// fetchAssets() enumerates every PHAsset and builds a [PhotoAsset] array.
// For a library of 10 k+ assets this is 100500 ms of synchronous work.
// Running it in a detached task keeps the main thread free for animations.
job.prepare()
let photoSvc = self.photoService
let spFetch = signposter.beginInterval("FetchAssets")
let assets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
photoSvc.fetchAssets(filter: filter)
}.value
signposter.endInterval("FetchAssets", spFetch, "\(assets.count) assets")
logger.info("Photo fetch: \(assets.count) assets in library")
try Task.checkCancellation()
// 2. Connect to NAS
// Network I/O suspends the main actor UI stays live the whole time.
let transfer = transferFactory(connection.nasProtocol)
let spConnect = signposter.beginInterval("NASConnect")
try await transfer.connect(
to: host, port: connection.port,
username: connection.username, password: connection.password
)
signposter.endInterval("NASConnect", spConnect)
activeTransfer = transfer
defer { transfer.disconnect(); activeTransfer = nil }
try Task.checkCancellation()
// 3. Load manifest
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
let spManifest = signposter.beginInterval("ManifestLoad")
let manifestData = try? await transfer.downloadData(at: manifestPath)
// 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
// Use the manifest with more entries as the authoritative base.
// CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264),
// the cache preserves accumulated history and must win. Overwriting the cache with a
// smaller NAS manifest destroys history and causes every asset to re-upload.
let cachedManifest = await NASManifestCache.shared.manifest
let cacheCount = cachedManifest?.entries.count ?? 0
let nasCount = nasManifest?.entries.count ?? 0
let baseManifest: BackupManifest
if nasCount > 0, nasCount >= cacheCount {
baseManifest = nasManifest!
await NASManifestCache.shared.update(nasManifest!)
logger.info("Manifest from NAS — \(nasCount) entries")
} else if cacheCount > 0 {
baseManifest = cachedManifest!
logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))")
} else {
// Genuinely first backup no manifest anywhere.
baseManifest = nasManifest ?? BackupManifest()
logger.info("Manifest: starting fresh — \(baseManifest.entries.count) entries")
}
let baseIndex = ManifestIndex(manifest: baseManifest)
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
// 3b. Build filter index
// When the NAS directory has more files than Kisani's manifest (e.g. files
// uploaded by other tools), a manifest-only index will misidentify those files
// as "not backed up" and re-upload them on every "Back up again" tap.
// Use the broader NAS directory listing as the filter when the manifest is sparse.
let filterIndex: ManifestIndex
let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0
if baseIndex.totalCount < cachedDirCount {
if let cachedFilenames = await NASManifestCache.shared.directoryFilenames,
cachedFilenames.count > baseIndex.totalCount {
filterIndex = await Task.detached(priority: .utility) {
ManifestIndex(nasFilenames: cachedFilenames)
}.value
logger.info("Filter index: \(cachedFilenames.count) cached NAS filenames (manifest=\(baseIndex.totalCount))")
} else {
// No cached filenames do a fresh listing (connection is already open)
let nasItems = (try? await transfer.listDirectory(at: connection.remotePath)) ?? []
let names = nasItems.compactMap { item -> String? in
guard !item.isDirectory, !item.name.hasPrefix("."),
item.name != BackupManifest.remoteFilename else { return nil }
return item.name
}
await NASManifestCache.shared.setDirectoryFilenames(names)
filterIndex = await Task.detached(priority: .utility) {
ManifestIndex(nasFilenames: names)
}.value
logger.info("Filter index: \(names.count) fresh NAS filenames (manifest=\(baseIndex.totalCount))")
}
} else {
filterIndex = baseIndex
}
try Task.checkCancellation()
// 4. Build pending queue off the main actor
let spFilter = signposter.beginInterval("BuildPendingQueue")
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
assets.filter { asset in
!filterIndex.matches(localIdentifier: asset.localIdentifier) &&
!(filterIndex.isFilenameOnly && filterIndex.matches(filename: asset.filename)) &&
!filterIndex.matchesUnclaimed(filename: asset.filename)
}
}.value
signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending")
logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total")
guard !pendingAssets.isEmpty else {
job.allAlreadySafe()
BackupStatusService.shared.refresh(force: false)
return BackupResult.empty(date: startDate)
}
// 5. Upload loop
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
NotificationService.notifyBackupStarted(count: pendingAssets.count)
// Build initial queue (all queued) for SyncView
queueItems = pendingAssets.map { asset in
BackupQueueItem(
id: asset.localIdentifier,
filename: asset.filename,
nasPath: "\(connection.remotePath)/\(asset.filename)",
status: .queued, bytesUploaded: 0, totalBytes: 0, speedBytesPerSec: 0,
error: nil, retryCount: 0, queuedAt: Date(), completedAt: nil
)
}
var uploaded = 0
var skipped = 0
var failed = 0
var totalBytes: Int64 = 0
var speedTracker = SpeedTracker()
var throttle = ProgressThrottle(hz: 8)
var manifestEntries: [ManifestEntry] = []
manifestEntries.reserveCapacity(pendingAssets.count)
var lastCheckpointAt = 0 // uploaded count at last manifest checkpoint write
let checkpointInterval = 25
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 {
try await Task.sleep(nanoseconds: 500_000_000)
}
let remotePath = "\(connection.remotePath)/\(asset.filename)"
if assetIdx < queueItems.count {
queueItems[assetIdx].status = .uploading
}
if (try? await transfer.fileExists(at: remotePath)) == true {
job.fileCompleted(skipped: true)
skipped += 1
if assetIdx < queueItems.count {
queueItems[assetIdx].status = .skipped
queueItems[assetIdx].completedAt = Date()
}
// Record in manifest even though we didn't upload the file is on NAS.
// Without this, skipped files stay "pending" forever and trigger backup loops.
manifestEntries.append(ManifestEntry(
localIdentifier: asset.localIdentifier,
filename: asset.filename,
creationDate: asset.creationDate,
fileSize: 0,
remotePath: remotePath,
uploadedAt: Date()
))
logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)")
continue
}
var uploadSucceeded = false
var lastUploadError: Error?
for attempt in 0..<maxRetries {
if isCancelled { break }
if attempt > 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)")
}
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()
))
// Checkpoint write every N uploads protects against interruption.
// Uses the same open connection so no extra connect/disconnect overhead.
if uploaded - lastCheckpointAt >= checkpointInterval {
lastCheckpointAt = uploaded
var checkpoint = baseManifest
checkpoint.merge(entries: manifestEntries)
if let encoded = try? JSONEncoder.kisani.encode(checkpoint) {
try? await transfer.writeData(encoded, to: manifestPath)
await NASManifestCache.shared.update(checkpoint)
logger.info("Manifest checkpoint: \(checkpoint.entries.count) total entries")
}
}
} catch {
// "Object Name Collision" (NTStatus 0xC0000035) means the file is already
// on the NAS. Retrying won't help treat it as safe immediately.
if isAlreadyExistsError(error) {
job.fileCompleted(skipped: true)
skipped += 1
if assetIdx < queueItems.count {
queueItems[assetIdx].status = .skipped
queueItems[assetIdx].completedAt = Date()
}
manifestEntries.append(ManifestEntry(
localIdentifier: asset.localIdentifier,
filename: asset.filename,
creationDate: asset.creationDate,
fileSize: 0,
remotePath: remotePath,
uploadedAt: Date()
))
logger.info("Collision — file already on NAS, marking safe: \(asset.filename, privacy: .public)")
uploadSucceeded = true
break
}
lastUploadError = error
logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(underlyingDescription(error), privacy: .public)")
}
if uploadSucceeded { break }
}
if !uploadSucceeded, let error = lastUploadError {
// Last-resort check: maybe the file landed on NAS despite the errors
// (e.g. fileExists threw transiently on the pre-check and the upload
// actually succeeded before the connection reset).
if (try? await transfer.fileExists(at: remotePath)) == true {
job.fileCompleted(skipped: true)
skipped += 1
if assetIdx < queueItems.count {
queueItems[assetIdx].status = .skipped
queueItems[assetIdx].completedAt = Date()
}
manifestEntries.append(ManifestEntry(
localIdentifier: asset.localIdentifier,
filename: asset.filename,
creationDate: asset.creationDate,
fileSize: 0,
remotePath: remotePath,
uploadedAt: Date()
))
logger.info("Post-retry check — file on NAS, marking safe: \(asset.filename, privacy: .public)")
} else {
let reason = underlyingDescription(error)
logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(reason, privacy: .public)")
job.fileFailed()
failed += 1
if assetIdx < queueItems.count {
queueItems[assetIdx].status = .failed
queueItems[assetIdx].completedAt = Date()
queueItems[assetIdx].error = BackupQueueItem.UploadError(
code: underlyingDomain(error),
message: reason,
timestamp: Date()
)
}
}
}
}
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
// Update local manifest cache immediately with every completed entry
// (uploaded + collision-skipped + fileExists-skipped).
// writeManifest() will persist this to the NAS, but even if that network
// write fails the next fast-path reconcile reads the correct counts here.
// Without this, a failed NAS write leaves the cache at 1 entry and the
// next reconcile computes alreadySafe = 1 instead of the real count.
if !manifestEntries.isEmpty {
var localMerged = baseManifest
localMerged.merge(entries: manifestEntries)
await NASManifestCache.shared.update(localMerged)
logger.info("Local cache updated — \(localMerged.entries.count) total entries")
}
if uploaded > 0 {
await NASManifestCache.shared.addToDirectoryCount(uploaded)
}
let duration = Date().timeIntervalSince(startDate)
let result = BackupResult(
uploadedCount: uploaded,
skippedCount: skipped,
failedCount: failed,
duration: duration,
totalBytes: totalBytes,
date: startDate
)
job.finish(result: result)
BackupStatusService.shared.refreshAfterBackup(entries: manifestEntries, connection: connection)
let entry = BackupHistoryEntry(result: result, nasHost: host, triggeredByLAN: triggeredByLAN)
store.appendHistoryEntry(entry)
// Notifications
if result.failedCount > 0 {
NotificationService.notifyBackupFailed(count: result.failedCount)
} else {
NotificationService.notifyBackupCompleted(
uploaded: result.uploadedCount,
total: BackupStatusService.shared.snapshot.phoneTotal
)
}
// Persist queue so SyncView shows last session on next launch
let snapshot = queueItems
Task.detached(priority: .utility) { [snapshot, url = Self.queueCacheURL] in
guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return }
try? data.write(to: url, options: .atomic)
}
return result
}
// Returns Tailscale host when off trusted LAN and tunnel is active.
private func resolveHost(connection: NASConnection, store: ConnectionStore, lan: LANMonitor) -> String {
let onTrustedLAN = store.trustedSSIDs.contains(lan.currentSSID ?? "")
guard !onTrustedLAN,
store.useTailscaleWhenRemote,
!store.tailscaleHost.isEmpty,
lan.isTailscaleActive else { return connection.host }
return store.tailscaleHost
}
func cancel() { isCancelled = true; job.cancel() }
func pause() { job.pause() }
func resume() { job.resume() }
func resolveSuccess() { job.resolveSuccess() }
}
// MARK: Error classification helpers
/// True when the error indicates the remote file already exists.
/// SMBClient surfaces this as ErrorResponse with NTStatus.objectNameCollision (0xC0000035),
/// whose localizedDescription is "Object Name Collision".
/// SFTP and POSIX use EEXIST (code 17) or the string "already exists".
private func isAlreadyExistsError(_ error: Error) -> Bool {
let base = unwrapUploadError(error)
let desc = base.localizedDescription.lowercased()
return desc.contains("object name collision") ||
desc.contains("already exist") ||
desc.contains("file exist") ||
(base as NSError).code == 17 // POSIX EEXIST
}
/// Returns the underlying transport error, unwrapping BackupError.uploadFailed if present.
private func unwrapUploadError(_ error: Error) -> Error {
if case let BackupError.uploadFailed(_, underlying) = error { return underlying }
return error
}
/// Human-readable description from the underlying transport error, not the wrapper.
private func underlyingDescription(_ error: Error) -> String {
unwrapUploadError(error).localizedDescription
}
/// NSError domain from the underlying transport error.
private func underlyingDomain(_ error: Error) -> String {
(unwrapUploadError(error) as NSError).domain
}
// MARK: SpeedTracker
private struct SpeedTracker {
private var lastBytes: Int64 = 0
private var lastTime: Date = Date()
mutating func update(bytesSent: Int64) -> Double {
let now = Date()
let elapsed = now.timeIntervalSince(lastTime)
guard elapsed > 0.1 else { return 0 }
let speed = Double(bytesSent - lastBytes) / elapsed
lastBytes = bytesSent
lastTime = now
return max(0, speed)
}
}
// MARK: ProgressThrottle
/// Limits the rate at which progress callbacks trigger @MainActor UI updates.
/// Without this, SMB/SFTP stacks fire hundreds of callbacks per second, causing
/// SwiftUI to redraw the full view tree on every tick.
private struct ProgressThrottle {
private var lastUpdate: Date = .distantPast
private let minInterval: TimeInterval
init(hz: Double = 8) { minInterval = 1.0 / max(1, hz) }
mutating func shouldUpdate() -> Bool {
let now = Date()
guard now.timeIntervalSince(lastUpdate) >= minInterval else { return false }
lastUpdate = now
return true
}
}