Merge pull request #3728 from danieldaquino/gh-3726
Move DB compaction to CompactionView
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; };
|
||||
D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = "<group>"; };
|
||||
D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactionView.swift; sourceTree = "<group>"; };
|
||||
D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
|
||||
D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; };
|
||||
D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = "<group>"; };
|
||||
@@ -4253,6 +4257,7 @@
|
||||
5C78A7792E22FDFE00CF177D /* Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D78E5FC32F9AF6EC00E3EE91 /* Compaction */,
|
||||
5C8F97042EB45E39009399B1 /* Live */,
|
||||
D5C1AFC22E5DFF040092F72F /* ContactCard */,
|
||||
5C78A7BC2E304D7400CF177D /* Translations */,
|
||||
@@ -5368,6 +5373,14 @@
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D78E5FC32F9AF6EC00E3EE91 /* Compaction */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D78E5FC42F9AF70200E3EE91 /* CompactionView.swift */,
|
||||
);
|
||||
path = Compaction;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user