fix: use NAS directory filenames for already-safe matching when manifest is sparse

When Kisani's manifest has 0–1 entries but the NAS directory contains thousands of
files uploaded by other tools, reconcile previously showed Already Safe = 1 because
countSafe only matched against the tiny manifest.

The validity check in buildManifestIndex now:
- Stores the filtered NAS filenames in NASManifestCache (directoryFilenames field)
- Returns a ManifestIndex(nasListing:) for filename-based matching against all NAS files

The fast-path reconcile now:
- Detects a sparse manifest (entries ≤ 1, dirCount > 1)
- Rebuilds ManifestIndex(nasFilenames:) from the cached filenames for O(1) filename matching
- Falls back to full NAS reconcile if cached filenames aren't available yet

Adds ManifestIndex.init(nasFilenames:) for fast-path reconstruction from cached strings.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 21:07:00 +03:00
parent 809929e0ed
commit 2384a74517
4 changed files with 68 additions and 15 deletions

View File

@@ -159,13 +159,27 @@ final class BackupStatusService: NSObject, ObservableObject {
let manifestCacheIsStale = await NASManifestCache.shared.isStale
let localIndexCount = await LocalPhotoIndex.shared.totalCount
if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
let index = await Task.detached(priority: .utility) {
ManifestIndex(manifest: cached)
}.value
fastPath: if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
let index: ManifestIndex
if cached.entries.count <= 1 && dirCount > 1 {
// Manifest is sparse vs. actual NAS directory. Use cached filenames for
// filename-based matching so countSafe reflects real NAS coverage.
// If filenames aren't cached yet, fall through to a full NAS reconcile.
guard let filenames = await NASManifestCache.shared.directoryFilenames else {
log.info("Reconcile (cached): sparse manifest, no cached filenames — falling to full reconcile")
break fastPath
}
index = await Task.detached(priority: .utility) {
ManifestIndex(nasFilenames: filenames)
}.value
} else {
index = await Task.detached(priority: .utility) {
ManifestIndex(manifest: cached)
}.value
}
let safe = await LocalPhotoIndex.shared.countSafe(against: index, 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()
updated.phoneTotal = phoneTotal
@@ -295,17 +309,21 @@ final class BackupStatusService: NSObject, ObservableObject {
Task { await NASManifestCache.shared.update(manifest) }
// Validity check: if the manifest has 1 entries it may be corrupt or newly
// created. Scan the directory to get the real file count for the NAS Archive
// display but do NOT add directory files as orphan manifest entries.
// created. Scan the directory to get the real NAS contents. When the directory
// is larger than the manifest, return a filename-based index so countSafe
// matches against the actual NAS files, not just the sparse manifest entries.
if index.totalCount <= 1 {
let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? []
let dirCount = items.filter {
let filteredItems = items.filter {
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
}.count
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
}
let dirCount = filteredItems.count
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — filename matching")
if dirCount > index.totalCount {
await NASManifestCache.shared.setDirectoryCount(dirCount)
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name })
return await Task.detached(priority: .utility) {
ManifestIndex(nasListing: items)
}.value
}
}
return index

View File

@@ -26,6 +26,10 @@ actor NASManifestCache {
/// 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?
/// Filtered NAS filenames from the last validity-check or bootstrap scan.
/// Present only when the manifest was found sparse vs. actual NAS contents.
/// Used by the fast-path reconcile to do filename matching without hitting NAS.
var directoryFilenames: [String]?
}
private init() {
@@ -45,6 +49,10 @@ actor NASManifestCache {
/// nil until the first full reconcile completes.
var directoryCount: Int? { cached?.directoryCount }
/// Cached NAS filenames from the last validity-check scan.
/// nil until the first validity-check reconcile completes with a sparse manifest.
var directoryFilenames: [String]? { cached?.directoryFilenames }
/// True when the cache is absent or older than the stale threshold.
var isStale: Bool {
guard let c = cached else { return true }
@@ -70,6 +78,22 @@ actor NASManifestCache {
saveToDisk()
}
/// Store filtered NAS filenames from a validity-check or bootstrap scan.
/// Also updates directoryCount atomically from the filename array length.
func setDirectoryFilenames(_ filenames: [String]) {
let count = filenames.count
if var entry = cached {
entry.directoryCount = count
entry.directoryFilenames = filenames
cached = entry
} else {
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast,
directoryCount: count, directoryFilenames: filenames)
}
log.info("ManifestCache: directoryFilenames=\(count)")
saveToDisk()
}
/// Increment the stored directory count after a successful upload batch.
func addToDirectoryCount(_ delta: Int) {
guard delta > 0 else { return }