diff --git a/damus/Features/Compaction/CompactionView.swift b/damus/Features/Compaction/CompactionView.swift index a3497488..6fff3be8 100644 --- a/damus/Features/Compaction/CompactionView.swift +++ b/damus/Features/Compaction/CompactionView.swift @@ -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: {} ) diff --git a/damus/Features/Notifications/Models/NotificationsModel.swift b/damus/Features/Notifications/Models/NotificationsModel.swift index 125f359c..a6a3f09e 100644 --- a/damus/Features/Notifications/Models/NotificationsModel.swift +++ b/damus/Features/Notifications/Models/NotificationsModel.swift @@ -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) } } diff --git a/damus/Features/Notifications/Views/DamusAppNotificationView.swift b/damus/Features/Notifications/Views/DamusAppNotificationView.swift index 8afdd26f..2a2fc0b2 100644 --- a/damus/Features/Notifications/Views/DamusAppNotificationView.swift +++ b/damus/Features/Notifications/Views/DamusAppNotificationView.swift @@ -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)) +} diff --git a/damus/Features/Purple/Models/Extensions/DamusPurpleNotificationManagement.swift b/damus/Features/Purple/Models/Extensions/DamusPurpleNotificationManagement.swift index 6c69b4b9..261d8c61 100644 --- a/damus/Features/Purple/Models/Extensions/DamusPurpleNotificationManagement.swift +++ b/damus/Features/Purple/Models/Extensions/DamusPurpleNotificationManagement.swift @@ -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 { diff --git a/damus/Features/Settings/Views/StorageSettingsView.swift b/damus/Features/Settings/Views/StorageSettingsView.swift index 13578989..d18303fa 100644 --- a/damus/Features/Settings/Views/StorageSettingsView.swift +++ b/damus/Features/Settings/Views/StorageSettingsView.swift @@ -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)] { 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() diff --git a/damusTests/NdbCompactionTests.swift b/damusTests/NdbCompactionTests.swift index 1f3ebff3..2d5833d8 100644 --- a/damusTests/NdbCompactionTests.swift +++ b/damusTests/NdbCompactionTests.swift @@ -17,9 +17,11 @@ final class NdbCompactionTests: XCTestCase { try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true) // Ensure the flag is cleared before each test. UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key) + UserDefaults.standard.removeObject(forKey: Ndb.compact_on_next_launch_source_key) // Reset scheduling-related keys so tests start from a clean state. UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key) UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key) + UserDefaults.standard.removeObject(forKey: Ndb.large_db_compaction_notification_pending_key) } override func tearDown() async throws { @@ -28,9 +30,11 @@ final class NdbCompactionTests: XCTestCase { } // Leave the flag cleared after each test. UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key) + UserDefaults.standard.removeObject(forKey: Ndb.compact_on_next_launch_source_key) // Clean up scheduling keys. UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key) UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key) + UserDefaults.standard.removeObject(forKey: Ndb.large_db_compaction_notification_pending_key) try await super.tearDown() } @@ -48,6 +52,21 @@ final class NdbCompactionTests: XCTestCase { UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), "set_compact_on_next_launch() should set the UserDefaults flag to true" ) + XCTAssertEqual( + Ndb.get_compact_on_next_launch_source(), + .manual, + "set_compact_on_next_launch() should default to a manual request source" + ) + } + + func testSetCompactOnNextLaunch_persistsExplicitSource() { + Ndb.set_compact_on_next_launch(source: .automatic) + + XCTAssertEqual( + Ndb.get_compact_on_next_launch_source(), + .automatic, + "set_compact_on_next_launch(source:) should persist the provided request source" + ) } // MARK: - compact_if_needed: flag not set @@ -181,10 +200,11 @@ final class NdbCompactionTests: XCTestCase { // MARK: - AutoCompactSchedule: interval values func testAutoCompactSchedule_intervalValues() { - XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours") - XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7, "weekly interval should be 7 days") - XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30, "monthly interval should be 30 days") - XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval") + XCTAssertEqual(AutoCompactSchedule.everyMinute.interval, 60, "everyMinute interval should be 60 seconds") + XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours") + XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7,"weekly interval should be 7 days") + XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30,"monthly interval should be 30 days") + XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval") } // MARK: - get/set auto-compact schedule @@ -201,14 +221,45 @@ final class NdbCompactionTests: XCTestCase { "Round-trip failed for schedule: \(schedule)") } } + + func testScheduleAutoCompact_setsFlag_whenEveryMinuteIntervalHasElapsed() { + // Given: schedule is every minute, last compaction was 2 minutes ago + Ndb.set_auto_compact_schedule(.everyMinute) + let twoMinutesAgo = Date().addingTimeInterval(-(60 * 2)) + UserDefaults.standard.set(twoMinutesAgo, forKey: Ndb.last_compact_date_key) + + let dbPath = testDirectory.appendingPathComponent("every_minute_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + guard let ndb = Ndb(path: dbPath) else { + XCTFail("Could not open Ndb at \(dbPath)") + return + } + ndb.close() + + // When + let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath) + + // Then + XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled when the every-minute interval has elapsed") + XCTAssertTrue( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "compact flag should be set when the every-minute interval has elapsed" + ) + XCTAssertEqual( + Ndb.get_compact_on_next_launch_source(), + .automatic, + "Every-minute auto-compaction should record an automatic request source" + ) + } // MARK: - schedule_auto_compact_if_needed func testScheduleAutoCompact_doesNothingWhenNever() { Ndb.set_auto_compact_schedule(.never) - Ndb.schedule_auto_compact_if_needed() + let decision = Ndb.schedule_auto_compact_if_needed() + XCTAssertEqual(decision, .noAction, "schedule=never should not schedule compaction") XCTAssertFalse( UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), "schedule=never should never set the compact flag" @@ -220,15 +271,29 @@ final class NdbCompactionTests: XCTestCase { Ndb.set_auto_compact_schedule(.weekly) let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8)) UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key) + + let dbPath = testDirectory.appendingPathComponent("scheduled_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + guard let ndb = Ndb(path: dbPath) else { + XCTFail("Could not open Ndb at \(dbPath)") + return + } + ndb.close() // When - Ndb.schedule_auto_compact_if_needed() + let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath) // Then: flag should be set + XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled when the interval has elapsed and the DB is not too large") XCTAssertTrue( UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), "compact flag should be set when scheduled interval has elapsed" ) + XCTAssertEqual( + Ndb.get_compact_on_next_launch_source(), + .automatic, + "Automatically scheduled compaction should record an automatic request source" + ) } func testScheduleAutoCompact_doesNotSetFlag_whenIntervalHasNotElapsed() { @@ -238,9 +303,10 @@ final class NdbCompactionTests: XCTestCase { UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key) // When - Ndb.schedule_auto_compact_if_needed() + let decision = Ndb.schedule_auto_compact_if_needed() // Then: flag should NOT be set + XCTAssertEqual(decision, .noAction, "Auto-compaction should do nothing when the interval has not elapsed") XCTAssertFalse( UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), "compact flag should not be set when the scheduled interval has not yet elapsed" @@ -251,16 +317,61 @@ final class NdbCompactionTests: XCTestCase { // Given: schedule is daily, no last compact date stored (first launch) Ndb.set_auto_compact_schedule(.daily) // last_compact_date_key is absent (cleaned in setUp) + + let dbPath = testDirectory.appendingPathComponent("first_launch_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + guard let ndb = Ndb(path: dbPath) else { + XCTFail("Could not open Ndb at \(dbPath)") + return + } + ndb.close() // When - Ndb.schedule_auto_compact_if_needed() + let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath) // Then: flag should be set (distantPast triggers compaction on first launch) + XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled on first launch when a database exists") XCTAssertTrue( UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), "compact flag should be set on first launch (no previous compaction date)" ) } + + func testScheduleAutoCompact_skipsLargeDatabase_andSetsReminder() { + // Given: schedule is due and the database is considered large + Ndb.set_auto_compact_schedule(.weekly) + let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8)) + UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key) + + let dbPath = testDirectory.appendingPathComponent("large_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + let dataPath = "\(dbPath)/\(Ndb.main_db_file_name)" + FileManager.default.createFile(atPath: dataPath, contents: Data()) + guard let handle = FileHandle(forWritingAtPath: dataPath) else { + XCTFail("Could not open \(dataPath) for writing") + return + } + defer { try? handle.close() } + try? handle.truncate(atOffset: Ndb.large_database_compaction_threshold_bytes) + + // When + let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath) + + // Then + XCTAssertEqual( + decision, + .skippedBecauseDatabaseTooLarge(databaseSizeBytes: Ndb.large_database_compaction_threshold_bytes), + "Auto-compaction should be skipped when the database exceeds the large-database threshold" + ) + XCTAssertFalse( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "Large databases should not be auto-scheduled for compaction" + ) + XCTAssertTrue( + Ndb.is_large_db_compaction_notification_pending(), + "Skipping auto-compaction for a large database should queue an in-app reminder" + ) + } // MARK: - compact_if_needed: records last compact date @@ -290,4 +401,35 @@ final class NdbCompactionTests: XCTestCase { XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started") XCTAssertLessThanOrEqual(lastDate, afterCompact, "last compact date should be <= time after compaction ended") } + + func testCompactIfNeeded_skipsAutomaticRequest_whenDatabaseIsLarge() { + let dbPath = testDirectory.appendingPathComponent("auto_skip_large_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + + let dataPath = "\(dbPath)/\(Ndb.main_db_file_name)" + FileManager.default.createFile(atPath: dataPath, contents: Data()) + guard let handle = FileHandle(forWritingAtPath: dataPath) else { + XCTFail("Could not open \(dataPath) for writing") + return + } + defer { try? handle.close() } + try? handle.truncate(atOffset: Ndb.large_database_compaction_threshold_bytes) + + Ndb.set_compact_on_next_launch(source: .automatic) + + XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath)) + + XCTAssertFalse( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "Automatic compaction requests should be cleared when skipped for a large database" + ) + XCTAssertNil( + Ndb.get_compact_on_next_launch_source(), + "Skipping a large automatic compaction should clear the stored request source" + ) + XCTAssertTrue( + Ndb.is_large_db_compaction_notification_pending(), + "Skipping a large automatic compaction should queue an in-app reminder" + ) + } } diff --git a/nostrdb/Ndb+Compaction.swift b/nostrdb/Ndb+Compaction.swift index ee1702a5..7b380f2e 100644 --- a/nostrdb/Ndb+Compaction.swift +++ b/nostrdb/Ndb+Compaction.swift @@ -85,6 +85,7 @@ struct NdbCompactionProgress: Equatable { /// Defines how often the database should be automatically compacted. enum AutoCompactSchedule: String, CaseIterable, Equatable { + case everyMinute case daily case weekly case monthly @@ -93,6 +94,8 @@ enum AutoCompactSchedule: String, CaseIterable, Equatable { /// Human-readable label shown in the settings UI. func text_description() -> String { switch self { + case .everyMinute: + return NSLocalizedString("Every minute", comment: "Auto-compact schedule option: compact every minute for developer testing") case .daily: return NSLocalizedString("Once a day", comment: "Auto-compact schedule option: compact once a day") case .weekly: @@ -107,14 +110,28 @@ enum AutoCompactSchedule: String, CaseIterable, Equatable { /// The time interval (in seconds) between automatic compactions, or `nil` for `.never`. var interval: TimeInterval? { switch self { - case .daily: return 60 * 60 * 24 - case .weekly: return 60 * 60 * 24 * 7 - case .monthly: return 60 * 60 * 24 * 30 - case .never: return nil + case .everyMinute: return 60 + case .daily: return 60 * 60 * 24 + case .weekly: return 60 * 60 * 24 * 7 + case .monthly: return 60 * 60 * 24 * 30 + case .never: return nil } } } +/// Describes why a compaction was scheduled for the next launch. +enum NdbCompactionRequestSource: String, Equatable { + case automatic + case manual +} + +/// Describes the result of evaluating whether an automatic compaction should proceed. +enum AutoCompactionDecision: Equatable { + case noAction + case scheduled + case skippedBecauseDatabaseTooLarge(databaseSizeBytes: UInt64) +} + extension Ndb { /// Errors that can occur while compacting the database. enum CompactionError: LocalizedError { @@ -175,21 +192,89 @@ extension Ndb { /// Name of the temporary subdirectory created during an in-place compaction. private static let compactTempDirName = "ndb_compact_temp" + /// Databases at or above this size skip automatic compaction and require explicit user opt-in. + static let large_database_compaction_threshold_bytes: UInt64 = 10 * 1024 * 1024 * 1024 + /// The `UserDefaults` key used to signal that the database should be compacted on the next app launch. static let compact_on_next_launch_key = "ndb_compact_on_next_launch" + /// The `UserDefaults` key used to persist why compaction was scheduled. + static let compact_on_next_launch_source_key = "ndb_compact_on_next_launch_source" + /// The `UserDefaults` key used to persist the auto-compact schedule (stored as raw string). static let auto_compact_schedule_key = "ndb_auto_compact_schedule" /// The `UserDefaults` key used to record when the last successful compaction occurred. static let last_compact_date_key = "ndb_last_compact_date" + /// The `UserDefaults` key used to remember that a large-database compaction reminder should be shown. + static let large_db_compaction_notification_pending_key = "ndb_large_db_compaction_notification_pending" + /// Requests that the database be compacted the next time the app launches. /// /// Call this to schedule a one-time compaction. The flag is cleared automatically after /// a successful compaction in `compact_if_needed()`. - static func set_compact_on_next_launch() { + /// - Parameter source: Whether the request came from automatic scheduling or explicit user action. + static func set_compact_on_next_launch(source: NdbCompactionRequestSource = .manual) { UserDefaults.standard.set(true, forKey: compact_on_next_launch_key) + UserDefaults.standard.set(source.rawValue, forKey: compact_on_next_launch_source_key) + } + + /// Returns the source of the currently scheduled compaction request, if any. + static func get_compact_on_next_launch_source() -> NdbCompactionRequestSource? { + guard UserDefaults.standard.bool(forKey: compact_on_next_launch_key) else { return nil } + guard let rawValue = UserDefaults.standard.string(forKey: compact_on_next_launch_source_key) else { + return .manual + } + return NdbCompactionRequestSource(rawValue: rawValue) ?? .manual + } + + /// Clears any pending compaction request and its associated metadata. + static func clear_compact_on_next_launch() { + UserDefaults.standard.set(false, forKey: compact_on_next_launch_key) + UserDefaults.standard.removeObject(forKey: compact_on_next_launch_source_key) + } + + /// Returns the size of the main LMDB file in bytes, or `nil` if it cannot be determined. + /// - Parameter path: The database directory path. + static func database_file_size(path: String) -> UInt64? { + let dataPath = "\(path)/\(main_db_file_name)" + guard let attributes = try? FileManager.default.attributesOfItem(atPath: dataPath), + let sizeValue = attributes[.size] else { + return nil + } + + if let number = sizeValue as? NSNumber { + return number.uint64Value + } + + if let uint64 = sizeValue as? UInt64 { + return uint64 + } + + if let int = sizeValue as? Int { + return UInt64(int) + } + + return nil + } + + /// Returns whether the database at `path` is considered large enough to skip automatic compaction. + /// - Parameter path: The database directory path. + static func is_large_database(path: String) -> Bool { + guard let size = database_file_size(path: path) else { return false } + return size >= large_database_compaction_threshold_bytes + } + + /// Returns whether a large-database compaction reminder is pending. + static func is_large_db_compaction_notification_pending() -> Bool { + return UserDefaults.standard.bool(forKey: large_db_compaction_notification_pending_key) + } + + /// Marks whether a large-database compaction reminder should be shown. + /// - Parameter pending: `true` to show the reminder, `false` to clear it. + static func set_large_db_compaction_notification_pending(_ pending: Bool) { + UserDefaults.standard.set(pending, forKey: large_db_compaction_notification_pending_key) } /// Reads the persisted auto-compact schedule from `UserDefaults`. @@ -214,19 +299,38 @@ extension Ndb { } /// Sets the compact-on-next-launch flag if the scheduled interval has elapsed since the - /// last successful compaction. + /// last successful compaction and the database is not too large for automatic compaction. /// /// Call this once on app startup **before** `compact_if_needed()`. - static func schedule_auto_compact_if_needed() { + /// - Parameter db_path: Override the database directory path. Pass `nil` (default) to use `Ndb.db_path`. + /// - Returns: The decision taken for this launch. + static func schedule_auto_compact_if_needed(db_path: String? = nil) -> AutoCompactionDecision { let schedule = get_auto_compact_schedule() - guard let interval = schedule.interval else { return } + guard let interval = schedule.interval else { return .noAction } let now = Date() let lastDate = get_last_compact_date() ?? .distantPast - guard now.timeIntervalSince(lastDate) >= interval else { return } + guard now.timeIntervalSince(lastDate) >= interval else { return .noAction } + + guard let path = db_path ?? Self.db_path else { + Log.error("schedule_auto_compact_if_needed: could not determine db path", for: .storage) + return .noAction + } + + guard db_file_exists(path: path) else { + return .noAction + } + + if is_large_database(path: path) { + let databaseSize = database_file_size(path: path) ?? 0 + Log.info("Auto-compact skipped because database is too large: %d bytes", for: .storage, databaseSize) + set_large_db_compaction_notification_pending(true) + return .skippedBecauseDatabaseTooLarge(databaseSizeBytes: databaseSize) + } Log.info("Auto-compact: interval elapsed — scheduling compaction on next launch", for: .storage) - set_compact_on_next_launch() + set_compact_on_next_launch(source: .automatic) + return .scheduled } /// Compacts the NostrDB database files if the compact-on-next-launch flag is set. @@ -260,7 +364,16 @@ 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) + clear_compact_on_next_launch() + progress?(.init(step: .completed)) + return + } + + if get_compact_on_next_launch_source() == .automatic, is_large_database(path: path) { + let databaseSize = database_file_size(path: path) ?? 0 + Log.info("compact_if_needed: skipping automatic compaction because database is too large: %d bytes", for: .storage, databaseSize) + set_large_db_compaction_notification_pending(true) + clear_compact_on_next_launch() progress?(.init(step: .completed)) return } @@ -362,8 +475,11 @@ extension Ndb { // Record the date of this successful compaction for the auto-compact scheduler. UserDefaults.standard.set(Date(), forKey: last_compact_date_key) + // Clear any pending reminder because the user has now completed compaction. + set_large_db_compaction_notification_pending(false) + // Clear the flag so we don't compact again on the next launch. - UserDefaults.standard.set(false, forKey: compact_on_next_launch_key) + clear_compact_on_next_launch() progress?(.init(step: .completed)) }