Report Compaction Progress and Improve UI

Add NdbCompactionProgress and Step with user-facing titles/details,
and a progress callback to Ndb.compact_if_needed to emit stage updates.
Refactor CompactionView to a single CompactionState and render linear
progress, step title/detail and percentage. Ensure UI updates happen on
the MainActor and surface errors/completion cleanly.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3730

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-04-27 10:09:59 -07:00
parent cae83e5399
commit d6654dff29
2 changed files with 272 additions and 64 deletions
+167 -59
View File
@@ -24,19 +24,45 @@ 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
/// The current state of the startup compaction flow.
enum CompactionState {
case idle
case compacting(progress: Double, stepTitle: String, stepDetail: String)
case failed(error: String)
case complete
/// The initial visible state shown when compaction begins.
static var initialCompactingState: CompactionState {
return .compacting(
progress: 0.05,
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Initial title shown during database compaction"),
stepDetail: NSLocalizedString("Checking the database and getting everything ready.", comment: "Initial detail shown during database compaction")
)
}
}
@State private var compactionState: CompactionState = .idle
@State private var hasStartedCompactionFlow: Bool = false
var body: some View {
Group {
if compactionComplete {
switch compactionState {
case .complete:
ContentView(keypair: keypair, appDelegate: appDelegate)
} else {
case .idle:
CompactionLoadingView(
isCompacting: isCompacting,
error: compactionError,
state: .compacting(
progress: 0.0,
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Title shown before database compaction starts"),
stepDetail: NSLocalizedString("Checking whether database optimization is needed.", comment: "Detail shown before database compaction starts")
),
continueAfterError: continueAfterError
)
case .compacting, .failed:
CompactionLoadingView(
state: compactionState,
continueAfterError: continueAfterError
)
}
@@ -60,24 +86,32 @@ struct CompactionView: View {
let needsCompaction = UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key)
guard needsCompaction else {
compactionComplete = true
compactionState = .complete
return
}
isCompacting = true
compactionState = CompactionState.initialCompactingState
Task.detached(priority: .userInitiated) {
do {
try Ndb.compact_if_needed()
try Ndb.compact_if_needed(
progress: { progress in
Task { @MainActor in
compactionState = .compacting(
progress: progress.fractionCompleted,
stepTitle: progress.step.title,
stepDetail: progress.step.detail
)
}
}
)
await MainActor.run {
isCompacting = false
compactionComplete = true
compactionState = .complete
}
} catch {
await MainActor.run {
isCompacting = false
compactionError = error.localizedDescription
compactionState = .failed(error: error.localizedDescription)
}
}
}
@@ -85,7 +119,7 @@ struct CompactionView: View {
/// Continues to the main content after the user acknowledges a compaction error.
private func continueAfterError() {
compactionComplete = true
compactionState = .complete
}
}
@@ -94,86 +128,160 @@ struct CompactionView: View {
/// 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 state: CompactionView.CompactionState
let continueAfterError: () -> Void
/// A percentage label for the current compaction progress.
private var progressPercentageText: String? {
guard case .compacting(let progress, _, _) = state else { return nil }
let percentage = Int((progress * 100).rounded())
return String(
format: NSLocalizedString("%d%% complete", comment: "Accessibility and status label showing database compaction progress percentage"),
percentage
)
}
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
.ignoresSafeArea()
LinearGradient(
colors: [
Color(uiColor: .systemBackground),
Color.accentColor.opacity(0.08)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 20) {
Image("icon")
.resizable()
.frame(width: 80, height: 80)
.cornerRadius(16)
if isCompacting {
ProgressView()
.scaleEffect(1.5)
.padding()
VStack(spacing: 28) {
VStack(spacing: 16) {
Image("icon")
.resizable()
.frame(width: 88, height: 88)
.cornerRadius(20)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
Text("Optimizing Database", comment: "Title shown during database compaction")
.font(.headline)
.font(.title2.weight(.semibold))
.foregroundColor(.primary)
Text("This may take a moment…", comment: "Subtitle shown during database compaction")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let progressPercentageText {
Text(progressPercentageText)
.font(.caption.weight(.semibold))
.foregroundColor(.accentColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color.accentColor.opacity(0.12))
)
}
}
if let error {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
switch state {
case .idle, .complete:
EmptyView()
case .compacting(let progress, let stepTitle, let stepDetail):
VStack(spacing: 20) {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(.linear)
.tint(.accentColor)
.scaleEffect(x: 1, y: 1.4, anchor: .center)
VStack(alignment: .leading, spacing: 14) {
Text(stepTitle)
.font(.headline)
.foregroundColor(.primary)
Text(stepDetail)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
.padding(20)
.frame(maxWidth: 420, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
)
Label(
NSLocalizedString("This can take a few minutes. Please keep the app open.", comment: "Subtitle shown during long-running database compaction"),
systemImage: "clock"
)
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
}
case .failed(let error):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 42))
.foregroundColor(.orange)
Text("Compaction Warning", comment: "Title shown when database compaction encounters an error")
.font(.headline)
.foregroundColor(.primary)
VStack(spacing: 8) {
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(error)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
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)
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(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
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(.top, 4)
}
.padding(20)
.frame(maxWidth: 420)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.orange.opacity(0.2), lineWidth: 1)
)
}
}
.padding()
.padding(.horizontal, 24)
.padding(.vertical, 32)
}
}
}
#Preview {
CompactionLoadingView(
isCompacting: true,
error: nil,
state: CompactionView.CompactionState.compacting(
progress: 0.55,
stepTitle: "Creating compacted snapshot",
stepDetail: "Copying data into a smaller optimized database file. This is usually the longest step."
),
continueAfterError: {}
)
}
#Preview("With Error") {
CompactionLoadingView(
isCompacting: false,
error: "Failed to compact database",
state: CompactionView.CompactionState.failed(error: "Failed to compact database"),
continueAfterError: {}
)
}
+103 -3
View File
@@ -5,6 +5,84 @@
import Foundation
/// A progress update emitted while database compaction advances through its major stages.
struct NdbCompactionProgress: Equatable {
/// A stable identifier for the current compaction stage.
enum Step: Int, CaseIterable, Equatable {
case preparing
case creatingTempDirectory
case openingDatabase
case creatingSnapshot
case validatingSnapshot
case replacingDatabase
case cleaningUp
case completed
/// The user-facing title for the current stage.
var title: String {
switch self {
case .preparing:
return NSLocalizedString("Preparing database compaction", comment: "Compaction progress stage title")
case .creatingTempDirectory:
return NSLocalizedString("Creating temporary workspace", comment: "Compaction progress stage title")
case .openingDatabase:
return NSLocalizedString("Opening database", comment: "Compaction progress stage title")
case .creatingSnapshot:
return NSLocalizedString("Creating compacted snapshot", comment: "Compaction progress stage title")
case .validatingSnapshot:
return NSLocalizedString("Validating compacted database", comment: "Compaction progress stage title")
case .replacingDatabase:
return NSLocalizedString("Replacing database files", comment: "Compaction progress stage title")
case .cleaningUp:
return NSLocalizedString("Cleaning up temporary files", comment: "Compaction progress stage title")
case .completed:
return NSLocalizedString("Finishing up", comment: "Compaction progress stage title")
}
}
/// A short user-facing description for the current stage.
var detail: String {
switch self {
case .preparing:
return NSLocalizedString("Checking the database and getting everything ready.", comment: "Compaction progress stage detail")
case .creatingTempDirectory:
return NSLocalizedString("Setting up a temporary location for the compacted copy.", comment: "Compaction progress stage detail")
case .openingDatabase:
return NSLocalizedString("Opening the existing database safely in the background.", comment: "Compaction progress stage detail")
case .creatingSnapshot:
return NSLocalizedString("Copying data into a smaller optimized database file. This is usually the longest step.", comment: "Compaction progress stage detail")
case .validatingSnapshot:
return NSLocalizedString("Making sure the compacted database looks valid before replacing the original.", comment: "Compaction progress stage detail")
case .replacingDatabase:
return NSLocalizedString("Swapping in the optimized database files.", comment: "Compaction progress stage detail")
case .cleaningUp:
return NSLocalizedString("Removing temporary files and recording completion.", comment: "Compaction progress stage detail")
case .completed:
return NSLocalizedString("Database optimization is complete.", comment: "Compaction progress stage detail")
}
}
/// The zero-based index of the step in the overall compaction flow.
var index: Int {
return Self.allCases.firstIndex(of: self) ?? 0
}
/// The total number of steps in the overall compaction flow.
static var totalCount: Int {
return Self.allCases.count
}
}
/// The current compaction stage.
let step: Step
/// The fraction completed for the overall compaction flow.
var fractionCompleted: Double {
guard Step.totalCount > 1 else { return 1.0 }
return Double(step.index) / Double(Step.totalCount - 1)
}
}
/// Defines how often the database should be automatically compacted.
enum AutoCompactSchedule: String, CaseIterable, Equatable {
case daily
@@ -162,12 +240,19 @@ extension Ndb {
/// 5. Remove the temp directory.
/// 6. Clear the flag so compaction does not run again on the following launch.
///
/// - Parameter db_path: Override the database directory path. Pass `nil` (default) to use
/// `Ndb.db_path`. Mainly useful for testing.
/// - Parameters:
/// - db_path: Override the database directory path. Pass `nil` (default) to use
/// `Ndb.db_path`. Mainly useful for testing.
/// - progress: An optional callback invoked as the compaction advances through major stages.
/// - Throws: `CompactionError` when compaction was requested but could not be completed.
static func compact_if_needed(db_path: String? = nil) throws {
static func compact_if_needed(
db_path: String? = nil,
progress: ((NdbCompactionProgress) -> Void)? = nil
) throws {
guard UserDefaults.standard.bool(forKey: compact_on_next_launch_key) else { return }
progress?(.init(step: .preparing))
guard let path = db_path ?? Self.db_path else {
Log.error("compact_if_needed: could not determine db path", for: .storage)
throw CompactionError.missingDatabasePath
@@ -176,6 +261,7 @@ extension Ndb {
guard db_file_exists(path: path) else {
// No database file present yet; nothing to compact just clear the flag.
UserDefaults.standard.set(false, forKey: compact_on_next_launch_key)
progress?(.init(step: .completed))
return
}
@@ -186,6 +272,8 @@ extension Ndb {
// Clean up any leftover temp directory from a previously failed attempt.
try? FileManager.default.removeItem(atPath: tempPath)
progress?(.init(step: .creatingTempDirectory))
do {
try FileManager.default.createDirectory(atPath: tempPath, withIntermediateDirectories: true)
} catch {
@@ -193,6 +281,8 @@ extension Ndb {
throw CompactionError.createTempDirectoryFailed(underlyingError: error)
}
progress?(.init(step: .openingDatabase))
// 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)
@@ -202,6 +292,8 @@ extension Ndb {
// Ensure the temporary Ndb is closed regardless of how this function exits.
defer { tempNdb.close() }
progress?(.init(step: .creatingSnapshot))
do {
try tempNdb.compact(to: tempPath)
} catch {
@@ -212,6 +304,8 @@ extension Ndb {
tempNdb.close()
progress?(.init(step: .validatingSnapshot))
// Atomically replace the original data.mdb with the compacted copy.
let originalDataMdb = URL(fileURLWithPath: "\(path)/\(main_db_file_name)")
let compactedDataMdb = URL(fileURLWithPath: "\(tempPath)/\(main_db_file_name)")
@@ -227,6 +321,8 @@ extension Ndb {
throw CompactionError.compactedFileMissingOrEmpty(path: compactedDataMdb.path)
}
progress?(.init(step: .replacingDatabase))
// Delete the stale lock.mdb BEFORE replacing data.mdb.
// The temp Ndb wrote reader-table / txn state into lock.mdb that references
// pages in the old data.mdb. After data.mdb is replaced with the smaller
@@ -256,6 +352,8 @@ extension Ndb {
throw CompactionError.postReplaceSizeMismatch(expected: compactedSize, actual: finalSize)
}
progress?(.init(step: .cleaningUp))
Log.info("NostrDB compacted successfully", for: .storage)
// Clean up the temp directory (any remaining files such as lock.mdb).
@@ -266,5 +364,7 @@ extension Ndb {
// Clear the flag so we don't compact again on the next launch.
UserDefaults.standard.set(false, forKey: compact_on_next_launch_key)
progress?(.init(step: .completed))
}
}