Improve large-DB compaction UX and add developer auto-compact testing

mode

- Skip automatic compaction when `data.mdb` is 10GB or larger, and
  surface an in-app notification instead of surprising users with a long
  startup wait
- Keep manual compaction available for large databases by tracking
  whether a next-launch compaction request was scheduled automatically
  or explicitly by the user
- Add a large-database explanation to the compaction loading screen to
  clarify that long runtimes are expected for oversized databases and
  are usually a one-time catch-up cost
- Add a developer-only `Every minute` auto-compact schedule to make
  rollout and reminder behavior easier to test locally
- Add and update unit tests covering large-database auto-compaction
  skips, request-source persistence, reminder scheduling, and the
  developer testing interval

Motivation:
TestFlight users with long-lived databases were hitting the brand-new
compaction flow for the first time, which could turn startup into a
multi-minute wait. These changes make automatic compaction less
disruptive for very large databases, preserve an explicit manual path
for users who want to optimize immediately, and add a fast
developer-only schedule to make the new behavior easier to validate
during testing.

This is an enhancement for an unreleased feature, so therefore no
changelog is needed.

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

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-04-29 14:40:18 -07:00
parent 53dde55616
commit 2af65320ef
7 changed files with 415 additions and 50 deletions
+39 -11
View File
@@ -27,16 +27,18 @@ struct CompactionView: View {
/// The current state of the startup compaction flow.
enum CompactionState {
case idle
case compacting(progress: Double, stepTitle: String, stepDetail: String)
case compacting(progress: Double, stepTitle: String, stepDetail: String, showsLargeDatabaseWarning: Bool)
case failed(error: String)
case complete
/// The initial visible state shown when compaction begins.
static var initialCompactingState: CompactionState {
/// - Parameter showsLargeDatabaseWarning: Whether the loading UI should explain that a large database may take longer to compact.
static func initialCompactingState(showsLargeDatabaseWarning: Bool) -> 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")
stepDetail: NSLocalizedString("Checking the database and getting everything ready.", comment: "Initial detail shown during database compaction"),
showsLargeDatabaseWarning: showsLargeDatabaseWarning
)
}
}
@@ -55,7 +57,8 @@ struct CompactionView: View {
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")
stepDetail: NSLocalizedString("Checking whether database optimization is needed.", comment: "Detail shown before database compaction starts"),
showsLargeDatabaseWarning: false
),
continueAfterError: continueAfterError
)
@@ -90,7 +93,8 @@ struct CompactionView: View {
return
}
compactionState = CompactionState.initialCompactingState
let showsLargeDatabaseWarning = Ndb.db_path.map({ Ndb.is_large_database(path: $0) }) ?? false
compactionState = CompactionState.initialCompactingState(showsLargeDatabaseWarning: showsLargeDatabaseWarning)
Task.detached(priority: .userInitiated) {
do {
@@ -100,7 +104,8 @@ struct CompactionView: View {
compactionState = .compacting(
progress: progress.fractionCompleted,
stepTitle: progress.step.title,
stepDetail: progress.step.detail
stepDetail: progress.step.detail,
showsLargeDatabaseWarning: showsLargeDatabaseWarning
)
}
}
@@ -133,7 +138,7 @@ struct CompactionLoadingView: View {
/// A percentage label for the current compaction progress.
private var progressPercentageText: String? {
guard case .compacting(let progress, _, _) = state else { return nil }
guard case .compacting(let progress, _, _, _) = state else { return nil }
let percentage = Int((progress * 100).rounded())
return String(
@@ -141,6 +146,12 @@ struct CompactionLoadingView: View {
percentage
)
}
/// Whether the current loading state should show the large-database warning.
private var showsLargeDatabaseWarning: Bool {
guard case .compacting(_, _, _, let showsLargeDatabaseWarning) = state else { return false }
return showsLargeDatabaseWarning
}
var body: some View {
ZStack {
@@ -183,14 +194,14 @@ struct CompactionLoadingView: View {
case .idle, .complete:
EmptyView()
case .compacting(let progress, let stepTitle, let stepDetail):
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) {
VStack(alignment: .leading, spacing: 14) {
Text(stepTitle)
.font(.headline)
.foregroundColor(.primary)
@@ -199,6 +210,8 @@ struct CompactionLoadingView: View {
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
.padding(20)
.frame(maxWidth: 420, alignment: .leading)
@@ -212,7 +225,9 @@ struct CompactionLoadingView: View {
)
Label(
NSLocalizedString("This can take a few minutes. Please keep the app open.", comment: "Subtitle shown during long-running database compaction"),
showsLargeDatabaseWarning
? NSLocalizedString("Your database is very large, so this optimization may take a few minutes. This is usually a one-time catch-up cost, and future optimizations should be faster once the database has been compacted. Please keep the app open.", comment: "Subtitle shown during long-running compaction when the database is large")
: NSLocalizedString("This can take several seconds. Please keep the app open.", comment: "Subtitle shown during long-running database compaction"),
systemImage: "clock"
)
.font(.footnote)
@@ -273,7 +288,20 @@ struct CompactionLoadingView: View {
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."
stepDetail: "Copying data into a smaller optimized database file. This is usually the longest step.",
showsLargeDatabaseWarning: false
),
continueAfterError: {}
)
}
#Preview("Large database warning") {
CompactionLoadingView(
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.",
showsLargeDatabaseWarning: true
),
continueAfterError: {}
)
@@ -387,5 +387,6 @@ struct DamusAppNotification {
enum Content: Hashable, Equatable {
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
case purple_expired(expiry_date: UInt64)
case large_db_compaction_recommended(database_size_bytes: UInt64)
}
}
@@ -67,6 +67,8 @@ struct DamusAppNotificationView: View {
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: days_remaining, expired: false)
case .purple_expired(expiry_date: _):
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: 0, expired: true)
case .large_db_compaction_recommended(let database_size_bytes):
LargeDatabaseCompactionNotificationView(damus_state: self.damus_state, database_size_bytes: database_size_bytes)
}
}
.padding(.horizontal)
@@ -146,6 +148,35 @@ struct DamusAppNotificationView: View {
return String(format: message_format, String(days_remaining))
}
}
struct LargeDatabaseCompactionNotificationView: View {
let damus_state: DamusState
let database_size_bytes: UInt64
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(message())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
NavigationLink(destination: StorageSettingsView(damus_state: damus_state, settings: damus_state.settings), label: {
HStack {
Text("Open Storage Settings", comment: "Button to open StorageSettingsView, where the user can manually schedule database compaction")
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
Image("arrow-right")
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
}
})
}
}
func message() -> String {
let sizeText = ByteCountFormatter.string(fromByteCount: Int64(database_size_bytes), countStyle: .file)
let messageFormat = NSLocalizedString("Your database is currently %@, so automatic optimization was skipped to avoid a long startup delay. This is usually a one-time catch-up step. You can schedule optimization for your next launch.", comment: "A notification message explaining that automatic compaction was skipped because the database is large, and that the user can manually schedule it.")
return String(format: messageFormat, sizeText)
}
}
}
// `AppIcon` code from: https://stackoverflow.com/a/65153628 and licensed with CC BY-SA 4.0 with the following modifications:
@@ -179,3 +210,7 @@ fileprivate struct AppIcon: View {
#Preview {
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_expired(expiry_date: 1709156602), timestamp: Date.now))
}
#Preview {
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .large_db_compaction_recommended(database_size_bytes: 12 * 1024 * 1024 * 1024), timestamp: Date.now))
}
@@ -16,6 +16,7 @@ extension DamusPurple {
func check_and_send_app_notifications_if_needed(handler: NotificationHandlerFunction) async {
await self.check_and_send_purple_expiration_notifications_if_needed(handler: handler)
await self.check_and_send_large_db_compaction_notification_if_needed(handler: handler)
}
/// Checks if we need to send a DamusPurple impending expiration notification to the user, and sends them if needed.
@@ -53,6 +54,23 @@ extension DamusPurple {
)
}
}
/// Sends an in-app reminder when automatic compaction was skipped because the database is too large.
///
/// This uses a persisted pending flag so the reminder can be surfaced in the notifications tab
/// without blocking startup. The reminder is cleared once it has been emitted.
private func check_and_send_large_db_compaction_notification_if_needed(handler: NotificationHandlerFunction) async {
guard Ndb.is_large_db_compaction_notification_pending() else { return }
guard let dbPath = Ndb.db_path else { return }
guard let databaseSize = Ndb.database_file_size(path: dbPath) else { return }
await handler(.init(
content: .large_db_compaction_recommended(database_size_bytes: databaseSize),
timestamp: Date.now
))
Ndb.set_large_db_compaction_notification_pending(false)
}
}
fileprivate func round_days_to_date(_ target_date: Date, from from_date: Date) -> Int {
@@ -56,6 +56,21 @@ struct StorageSettingsView: View {
@State var showing_compact_alert: Bool = false
@State fileprivate var auto_compact_schedule: AutoCompactSchedule = Ndb.get_auto_compact_schedule()
/// Whether the current database is large enough that automatic compaction will be skipped.
private var isLargeDatabaseSkippingAutoCompact: Bool {
guard let dbPath = Ndb.db_path else { return false }
return Ndb.is_large_database(path: dbPath)
}
/// The auto-compact schedule options currently available to the user.
private var availableAutoCompactSchedules: [AutoCompactSchedule] {
if settings.developer_mode {
return AutoCompactSchedule.allCases
}
return AutoCompactSchedule.allCases.filter { $0 != .everyMinute }
}
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard let stats = stats else { return [] }
@@ -357,8 +372,9 @@ struct StorageSettingsView: View {
}
/// Section that lets the user configure automatic periodic compaction.
/// Auto-compact schedule picker and status section.
///
/// Shows a `Picker` with four schedule options and a caption that describes the
/// Shows a `Picker` with the available schedule options and a caption that describes the
/// current state: either the time remaining until the next compaction or a note
/// that compaction will run on the next app launch.
var AutoCompactSection: some View {
@@ -370,7 +386,7 @@ struct StorageSettingsView: View {
NSLocalizedString("Automatically compact database", comment: "Setting label for choosing how often to auto-compact the database"),
selection: $auto_compact_schedule
) {
ForEach(AutoCompactSchedule.allCases, id: \.self) { option in
ForEach(availableAutoCompactSchedules, id: \.self) { option in
Text(option.text_description()).tag(option)
}
}
@@ -385,23 +401,32 @@ struct StorageSettingsView: View {
/// Shows either the time remaining until the next scheduled compaction or a message
/// indicating that compaction will happen on the next app launch.
private var AutoCompactCaption: some View {
Group {
if auto_compact_schedule == .never {
Text("Automatic database compaction is disabled.", comment: "Caption shown when auto-compact is set to never")
} else if compact_scheduling_state == .scheduled {
Text("Will compact on the next app launch.", comment: "Caption shown when a compaction is already queued for the next launch")
} else if let timeRemaining = nextCompactTimeRemaining {
Text(
String(
format: NSLocalizedString(
"Next automatic compaction %@.",
comment: "Caption showing how long until the next automatic compaction. %@ is replaced with a human-readable duration like 'in 3 days'."
),
timeRemaining
VStack(alignment: .leading, spacing: 6) {
Group {
if auto_compact_schedule == .never {
Text("Automatic database compaction is disabled.", comment: "Caption shown when auto-compact is set to never")
} else if compact_scheduling_state == .scheduled {
Text("Will compact on the next app launch.", comment: "Caption shown when a compaction is already queued for the next launch")
}
else if isLargeDatabaseSkippingAutoCompact {
Text("Automatic compaction is currently skipped because your database is very large. Use “Compact Database” above to request a manual compaction on the next app launch.", comment: "Caption shown when automatic compaction is skipped because the database is too large and the user must request compaction manually")
} else if let timeRemaining = nextCompactTimeRemaining {
Text(
String(
format: NSLocalizedString(
"Next automatic compaction %@.",
comment: "Caption showing how long until the next automatic compaction. %@ is replaced with a human-readable duration like 'in 3 days'."
),
timeRemaining
)
)
)
} else {
Text("Will compact on the next app launch.", comment: "Caption shown when the scheduled compaction interval has already elapsed")
} else {
Text("Will compact on the next app launch.", comment: "Caption shown when the scheduled compaction interval has already elapsed")
}
}
if settings.developer_mode {
Text("“Every minute” is a developer-only testing option.", comment: "Caption explaining that the every-minute auto-compact schedule is only intended for developer testing")
}
}
.font(.caption)
@@ -451,7 +476,7 @@ struct StorageSettingsView: View {
title: Text("Compact Database", comment: "Confirmation dialog title for database compaction"),
message: Text("This will reclaim unused space in the database. The app will need to restart to complete the operation. Proceed?", comment: "Message explaining what database compaction does and that a restart is required."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
Ndb.set_compact_on_next_launch()
Ndb.set_compact_on_next_launch(source: .manual)
compact_scheduling_state = .scheduled
},
secondaryButton: .cancel()