From 0d2f55f4e340c7863e31021aef42a75292b61f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Thu, 23 Apr 2026 18:44:44 -0700 Subject: [PATCH] Move DB compaction to CompactionView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce CompactionView and CompactionLoadingView to schedule and run Ndb compaction on a background task before showing the main UI. Replace the direct compaction calls in ContentView and make CompactionView the app's launch entry so startup compaction presents a loading UI and avoids blocking the main thread. Changelog-Fixed: Fixed startup crash when app performs long-running compaction procedure Closes: #3726 Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 16 ++ damus/ContentView.swift | 9 - .../Features/Compaction/CompactionView.swift | 179 ++++++++++++++++++ damus/damusApp.swift | 2 +- damusTests/NdbCompactionTests.swift | 16 +- nostrdb/Ndb+Compaction.swift | 48 ++++- 6 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 damus/Features/Compaction/CompactionView.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 0d1bf2a6..0002692a 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1755,6 +1755,9 @@ D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D78DB8582C1CE9CA00F0AB12 /* SwipeActions */; }; D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */; }; + D78E5FC52F9AF73C00E3EE91 /* CompactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */; }; + D78E5FC62F9AF7F700E3EE91 /* CompactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */; }; + D78E5FC72F9AF80100E3EE91 /* CompactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */; }; D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; @@ -2889,6 +2892,7 @@ D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = ""; }; D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = ""; }; D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = ""; }; + D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactionView.swift; sourceTree = ""; }; D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = ""; }; @@ -4253,6 +4257,7 @@ 5C78A7792E22FDFE00CF177D /* Features */ = { isa = PBXGroup; children = ( + D78E5FC32F9AF6EC00E3EE91 /* Compaction */, 5C8F97042EB45E39009399B1 /* Live */, D5C1AFC22E5DFF040092F72F /* ContactCard */, 5C78A7BC2E304D7400CF177D /* Translations */, @@ -5368,6 +5373,14 @@ path = Chat; sourceTree = ""; }; + D78E5FC32F9AF6EC00E3EE91 /* Compaction */ = { + isa = PBXGroup; + children = ( + D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */, + ); + path = Compaction; + sourceTree = ""; + }; D78F080A2D7F78B000FC6C75 /* WalletConnect */ = { isa = PBXGroup; children = ( @@ -6311,6 +6324,7 @@ D7DB1FDE2D5A78CE00CF06DA /* NIP44.swift in Sources */, B51C1CEB2B55A60A00E312A9 /* MuteDurationMenu.swift in Sources */, 4CB88389296AF99A00DC99E7 /* EventDetailBar.swift in Sources */, + D78E5FC52F9AF73C00E3EE91 /* CompactionView.swift in Sources */, 4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */, 5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */, 4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */, @@ -7053,6 +7067,7 @@ D7F4F0BA2F03689300B61683 /* DeveloperSettingsView.swift in Sources */, 82D6FC722CD99F7900C925F4 /* EULAView.swift in Sources */, 82D6FC732CD99F7900C925F4 /* RepostsView.swift in Sources */, + D78E5FC62F9AF7F700E3EE91 /* CompactionView.swift in Sources */, 82D6FC742CD99F7900C925F4 /* Launch.storyboard in Sources */, 82D6FC752CD99F7900C925F4 /* QRCodeView.swift in Sources */, 82D6FC762CD99F7900C925F4 /* RelayFilterView.swift in Sources */, @@ -7462,6 +7477,7 @@ D73E5F452C6A97F5007EB227 /* AddRelayView.swift in Sources */, D73E5F472C6A97F5007EB227 /* BookmarksView.swift in Sources */, D73E5F482C6A97F5007EB227 /* CarouselView.swift in Sources */, + D78E5FC72F9AF80100E3EE91 /* CompactionView.swift in Sources */, D73E5F492C6A97F5007EB227 /* ConfigView.swift in Sources */, D733F9E12D92C1D900317B11 /* SubscriptionManager.swift in Sources */, D73E5F4A2C6A97F5007EB227 /* CreateAccountView.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index cb753fa0..4649c043 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -142,15 +142,6 @@ struct ContentView: View { let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init(keypair: Keypair, appDelegate: AppDelegate?) { - // Schedule automatic compaction based on the user's configured interval, then - // run compaction if it was previously requested (either manually or by the scheduler). - // Both calls run before opening the main Ndb instance so they work on an idle database. - // This also gets run here instead of `connect` because we should anticipate this to add a few seconds of delay in worst case scenarios. - // If we were to add this in the `connect` function, parallel functions that depend on `damus_state!` could cause crashes in the app. - // By placing this here, we only delay the splash screen a bit - Ndb.schedule_auto_compact_if_needed() - Ndb.compact_if_needed() - self.keypair = keypair self.appDelegate = appDelegate } diff --git a/damus/Features/Compaction/CompactionView.swift b/damus/Features/Compaction/CompactionView.swift new file mode 100644 index 00000000..c69f6a82 --- /dev/null +++ b/damus/Features/Compaction/CompactionView.swift @@ -0,0 +1,179 @@ +// +// CompactionView.swift +// damus +// + +import SwiftUI + +/// A view that handles database compaction on a background thread before showing the main content. +/// +/// This view sits between `MainView` and `ContentView` to prevent the app from being killed by +/// the watchdog due to long-running compaction operations blocking the main thread during startup. +/// +/// ## Implementation +/// +/// When compaction is needed, this view: +/// 1. Shows a loading screen with progress information +/// 2. Performs the compaction on a background thread +/// 3. Transitions to `ContentView` once compaction completes +/// 4. Requires explicit user acknowledgement before continuing if compaction fails +/// +/// The compaction process is performed before `Ndb` is opened for normal use, ensuring the +/// database is in a consistent state before the main app logic begins. +struct CompactionView: View { + let keypair: Keypair + let appDelegate: AppDelegate? + + @State private var isCompacting: Bool = false + @State private var compactionComplete: Bool = false + @State private var compactionError: String? = nil + @State private var hasStartedCompactionFlow: Bool = false + + var body: some View { + Group { + if compactionComplete { + ContentView(keypair: keypair, appDelegate: appDelegate) + } else { + CompactionLoadingView( + isCompacting: isCompacting, + error: compactionError, + continueAfterError: continueAfterError + ) + } + } + .onAppear { + guard !hasStartedCompactionFlow else { return } + hasStartedCompactionFlow = true + performCompactionIfNeeded() + } + } + + /// Checks if compaction is needed and performs it on a background thread. + /// + /// This function: + /// - Schedules auto-compaction based on the configured interval + /// - Checks if compaction was previously requested + /// - If compaction is needed, performs it on a background thread with a loading UI + /// - If no compaction is needed, immediately transitions to the main content + private func performCompactionIfNeeded() { + Ndb.schedule_auto_compact_if_needed() + + let needsCompaction = UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key) + guard needsCompaction else { + compactionComplete = true + return + } + + isCompacting = true + + Task.detached(priority: .userInitiated) { + do { + try Ndb.compact_if_needed() + + await MainActor.run { + isCompacting = false + compactionComplete = true + } + } catch { + await MainActor.run { + isCompacting = false + compactionError = error.localizedDescription + } + } + } + } + + /// Continues to the main content after the user acknowledges a compaction error. + private func continueAfterError() { + compactionComplete = true + } +} + +/// A loading view displayed during database compaction. +/// +/// Shows a spinner and informative text to the user while the compaction process runs. +/// If compaction fails, it shows the error and requires explicit acknowledgement before continuing. +struct CompactionLoadingView: View { + let isCompacting: Bool + let error: String? + let continueAfterError: () -> Void + + var body: some View { + ZStack { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + + VStack(spacing: 20) { + Image("icon") + .resizable() + .frame(width: 80, height: 80) + .cornerRadius(16) + + if isCompacting { + ProgressView() + .scaleEffect(1.5) + .padding() + + Text("Optimizing Database", comment: "Title shown during database compaction") + .font(.headline) + .foregroundColor(.primary) + + Text("This may take a moment…", comment: "Subtitle shown during database compaction") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + if let error { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Compaction Warning", comment: "Title shown when database compaction encounters an error") + .font(.headline) + .foregroundColor(.primary) + + Text(error) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Text("The app can continue, but some storage may not have been freed.", comment: "Message shown when compaction fails but app can continue after acknowledgement") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(action: continueAfterError) { + Text("Continue", comment: "Button title used to continue into the app after acknowledging a compaction error") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + .padding(.horizontal) + } + } + } + .padding() + } + } +} + +#Preview { + CompactionLoadingView( + isCompacting: true, + error: nil, + continueAfterError: {} + ) +} + +#Preview("With Error") { + CompactionLoadingView( + isCompacting: false, + error: "Failed to compact database", + continueAfterError: {} + ) +} \ No newline at end of file diff --git a/damus/damusApp.swift b/damus/damusApp.swift index 59d3a3b7..12709152 100644 --- a/damus/damusApp.swift +++ b/damus/damusApp.swift @@ -28,7 +28,7 @@ struct MainView: View { var body: some View { Group { if let kp = keypair, !needs_setup { - ContentView(keypair: kp, appDelegate: appDelegate) + CompactionView(keypair: kp, appDelegate: appDelegate) .environmentObject(orientationTracker) } else { SetupView() diff --git a/damusTests/NdbCompactionTests.swift b/damusTests/NdbCompactionTests.swift index a92f43e0..1f3ebff3 100644 --- a/damusTests/NdbCompactionTests.swift +++ b/damusTests/NdbCompactionTests.swift @@ -56,8 +56,8 @@ final class NdbCompactionTests: XCTestCase { // Given: the flag is false (set in setUp) let dbPath = testDirectory.path - // When - Ndb.compact_if_needed(db_path: dbPath) + // When / Then + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath)) // Then: no temp directory was created, no files were touched let tempPath = "\(dbPath)/ndb_compact_temp" @@ -79,8 +79,8 @@ final class NdbCompactionTests: XCTestCase { let emptyPath = testDirectory.appendingPathComponent("empty_db").path try? FileManager.default.createDirectory(atPath: emptyPath, withIntermediateDirectories: true) - // When - Ndb.compact_if_needed(db_path: emptyPath) + // When / Then + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: emptyPath)) // Then: flag is cleared, no temp directory left over XCTAssertFalse( @@ -112,8 +112,8 @@ final class NdbCompactionTests: XCTestCase { Ndb.set_compact_on_next_launch() - // When - Ndb.compact_if_needed(db_path: dbPath) + // When / Then + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath)) // Then: the flag is cleared XCTAssertFalse( @@ -157,7 +157,7 @@ final class NdbCompactionTests: XCTestCase { // When: compact_if_needed runs Ndb.set_compact_on_next_launch() - Ndb.compact_if_needed(db_path: dbPath) + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath)) // Then: lock.mdb should NOT exist (it was deleted during compaction) XCTAssertFalse( @@ -279,7 +279,7 @@ final class NdbCompactionTests: XCTestCase { Ndb.set_compact_on_next_launch() let beforeCompact = Date() - Ndb.compact_if_needed(db_path: dbPath) + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath)) let afterCompact = Date() guard let lastDate = Ndb.get_last_compact_date() else { diff --git a/nostrdb/Ndb+Compaction.swift b/nostrdb/Ndb+Compaction.swift index 983285d7..c75ec079 100644 --- a/nostrdb/Ndb+Compaction.swift +++ b/nostrdb/Ndb+Compaction.swift @@ -38,6 +38,37 @@ enum AutoCompactSchedule: String, CaseIterable, Equatable { } extension Ndb { + /// Errors that can occur while compacting the database. + enum CompactionError: LocalizedError { + case missingDatabasePath + case createTempDirectoryFailed(underlyingError: Error) + case openDatabaseFailed(path: String) + case snapshotFailed(underlyingError: Error) + case compactedFileMissingOrEmpty(path: String) + case replaceDatabaseFailed(underlyingError: Error) + case postReplaceSizeMismatch(expected: Int, actual: Int) + + /// A user-presentable description of the compaction failure. + var errorDescription: String? { + switch self { + case .missingDatabasePath: + return "Could not determine the database path." + case .createTempDirectoryFailed(let underlyingError): + return "Failed to create the temporary compaction directory: \(underlyingError.localizedDescription)" + case .openDatabaseFailed(let path): + return "Failed to open the database for compaction at path: \(path)" + case .snapshotFailed(let underlyingError): + return "Failed to create a compacted database snapshot: \(underlyingError.localizedDescription)" + case .compactedFileMissingOrEmpty(let path): + return "The compacted database file is missing or empty at path: \(path)" + case .replaceDatabaseFailed(let underlyingError): + return "Failed to replace the original database with the compacted copy: \(underlyingError.localizedDescription)" + case .postReplaceSizeMismatch(let expected, let actual): + return "The compacted database size verification failed. Expected \(expected) bytes, got \(actual) bytes." + } + } + } + /// Makes a compacted copy of the database in a separate directory. /// /// This uses `mdb_env_copy2` with `MDB_CP_COMPACT` (flag = 0x01), which omits free pages @@ -133,12 +164,13 @@ extension Ndb { /// /// - Parameter db_path: Override the database directory path. Pass `nil` (default) to use /// `Ndb.db_path`. Mainly useful for testing. - static func compact_if_needed(db_path: String? = nil) { + /// - Throws: `CompactionError` when compaction was requested but could not be completed. + static func compact_if_needed(db_path: String? = nil) throws { guard UserDefaults.standard.bool(forKey: compact_on_next_launch_key) else { return } guard let path = db_path ?? Self.db_path else { Log.error("compact_if_needed: could not determine db path", for: .storage) - return + throw CompactionError.missingDatabasePath } guard db_file_exists(path: path) else { @@ -158,14 +190,14 @@ extension Ndb { try FileManager.default.createDirectory(atPath: tempPath, withIntermediateDirectories: true) } catch { Log.error("compact_if_needed: failed to create temp dir: %@", for: .storage, String(describing: error)) - return + throw CompactionError.createTempDirectoryFailed(underlyingError: error) } // Open a temporary Ndb instance just to drive the compaction. guard let tempNdb = Ndb(path: path) else { Log.error("compact_if_needed: failed to open ndb for compaction", for: .storage) try? FileManager.default.removeItem(atPath: tempPath) - return + throw CompactionError.openDatabaseFailed(path: path) } // Ensure the temporary Ndb is closed regardless of how this function exits. defer { tempNdb.close() } @@ -175,7 +207,7 @@ extension Ndb { } catch { Log.error("compact_if_needed: compaction failed: %@", for: .storage, String(describing: error)) try? FileManager.default.removeItem(atPath: tempPath) - return + throw CompactionError.snapshotFailed(underlyingError: error) } tempNdb.close() @@ -192,7 +224,7 @@ extension Ndb { guard compactedSize > 0 else { Log.error("compact_if_needed: compacted file is missing or empty — aborting", for: .storage) try? FileManager.default.removeItem(atPath: tempPath) - return + throw CompactionError.compactedFileMissingOrEmpty(path: compactedDataMdb.path) } // Delete the stale lock.mdb BEFORE replacing data.mdb. @@ -213,7 +245,7 @@ extension Ndb { } catch { Log.error("compact_if_needed: failed to replace db file: %@", for: .storage, String(describing: error)) try? FileManager.default.removeItem(atPath: tempPath) - return + throw CompactionError.replaceDatabaseFailed(underlyingError: error) } // Post-replace sanity check: verify the destination file exists with the expected size. @@ -221,7 +253,7 @@ extension Ndb { if finalSize != compactedSize { Log.error("compact_if_needed: post-replace size mismatch — expected %d, got %d", for: .storage, compactedSize, finalSize) try? FileManager.default.removeItem(atPath: tempPath) - return + throw CompactionError.postReplaceSizeMismatch(expected: compactedSize, actual: finalSize) } Log.info("NostrDB compacted successfully", for: .storage)