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:
@@ -75,6 +75,16 @@ struct ManifestIndex: Sendable {
|
|||||||
self.totalCount = overrideTotalCount
|
self.totalCount = overrideTotalCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild from cached NAS filenames (fast-path for sparse-manifest reconcile).
|
||||||
|
// Filenames are expected to already be filtered (no dirs, no hidden, no manifest file).
|
||||||
|
init(nasFilenames filenames: [String]) {
|
||||||
|
let names = Set(filenames.map { $0.lowercased() })
|
||||||
|
self.localIdentifiers = []
|
||||||
|
self.lowercasedFilenames = names
|
||||||
|
self.unclaimedFilenames = names
|
||||||
|
self.totalCount = names.count
|
||||||
|
}
|
||||||
|
|
||||||
// Build from NAS directory listing (bootstrap — no localIdentifiers known)
|
// Build from NAS directory listing (bootstrap — no localIdentifiers known)
|
||||||
init(nasListing items: [NASItem]) {
|
init(nasListing items: [NASItem]) {
|
||||||
var names = Set<String>()
|
var names = Set<String>()
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
||||||
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
||||||
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||||
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
E317DD286568B85367A66AD3 /* NASManifestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASManifestCache.swift; sourceTree = "<group>"; };
|
E317DD286568B85367A66AD3 /* NASManifestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASManifestCache.swift; sourceTree = "<group>"; };
|
||||||
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
||||||
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||||
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||||
EF4EDDA4B02ABF0F17BE86D5 /* BackupQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupQueueItem.swift; sourceTree = "<group>"; };
|
EF4EDDA4B02ABF0F17BE86D5 /* BackupQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupQueueItem.swift; sourceTree = "<group>"; };
|
||||||
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
@@ -463,7 +463,6 @@
|
|||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
009689A0ADEB4878A288991E = {
|
009689A0ADEB4878A288991E = {
|
||||||
DevelopmentTeam = "";
|
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
3362521ADE965E6BA3383045 = {
|
3362521ADE965E6BA3383045 = {
|
||||||
@@ -600,6 +599,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -779,6 +779,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
@@ -159,13 +159,27 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
let manifestCacheIsStale = await NASManifestCache.shared.isStale
|
let manifestCacheIsStale = await NASManifestCache.shared.isStale
|
||||||
let localIndexCount = await LocalPhotoIndex.shared.totalCount
|
let localIndexCount = await LocalPhotoIndex.shared.totalCount
|
||||||
|
|
||||||
if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
|
fastPath: if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
|
||||||
let index = await Task.detached(priority: .utility) {
|
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||||
ManifestIndex(manifest: cached)
|
let index: ManifestIndex
|
||||||
}.value
|
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 safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
||||||
let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
|
let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
|
||||||
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
|
|
||||||
let nasTotal = max(index.totalCount, dirCount)
|
let nasTotal = max(index.totalCount, dirCount)
|
||||||
var updated = BackupStatusSnapshot()
|
var updated = BackupStatusSnapshot()
|
||||||
updated.phoneTotal = phoneTotal
|
updated.phoneTotal = phoneTotal
|
||||||
@@ -295,17 +309,21 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
Task { await NASManifestCache.shared.update(manifest) }
|
Task { await NASManifestCache.shared.update(manifest) }
|
||||||
|
|
||||||
// Validity check: if the manifest has ≤ 1 entries it may be corrupt or newly
|
// 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
|
// created. Scan the directory to get the real NAS contents. When the directory
|
||||||
// display — but do NOT add directory files as orphan manifest entries.
|
// 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 {
|
if index.totalCount <= 1 {
|
||||||
let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? []
|
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
|
!$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 {
|
if dirCount > index.totalCount {
|
||||||
await NASManifestCache.shared.setDirectoryCount(dirCount)
|
await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name })
|
||||||
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
|
return await Task.detached(priority: .utility) {
|
||||||
|
ManifestIndex(nasListing: items)
|
||||||
|
}.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ actor NASManifestCache {
|
|||||||
/// Stored independently from manifest.entries.count — the folder may contain
|
/// Stored independently from manifest.entries.count — the folder may contain
|
||||||
/// files uploaded by other tools that are not in Kisani's manifest.
|
/// files uploaded by other tools that are not in Kisani's manifest.
|
||||||
var directoryCount: Int?
|
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() {
|
private init() {
|
||||||
@@ -45,6 +49,10 @@ actor NASManifestCache {
|
|||||||
/// nil until the first full reconcile completes.
|
/// nil until the first full reconcile completes.
|
||||||
var directoryCount: Int? { cached?.directoryCount }
|
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.
|
/// True when the cache is absent or older than the stale threshold.
|
||||||
var isStale: Bool {
|
var isStale: Bool {
|
||||||
guard let c = cached else { return true }
|
guard let c = cached else { return true }
|
||||||
@@ -70,6 +78,22 @@ actor NASManifestCache {
|
|||||||
saveToDisk()
|
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.
|
/// Increment the stored directory count after a successful upload batch.
|
||||||
func addToDirectoryCount(_ delta: Int) {
|
func addToDirectoryCount(_ delta: Int) {
|
||||||
guard delta > 0 else { return }
|
guard delta > 0 else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user