Merge pull request #3733 from danieldaquino/gh-3726-c
Improve large-DB compaction UX and add developer auto-compact testing mode
This commit is contained in:
@@ -27,16 +27,18 @@ struct CompactionView: View {
|
|||||||
/// The current state of the startup compaction flow.
|
/// The current state of the startup compaction flow.
|
||||||
enum CompactionState {
|
enum CompactionState {
|
||||||
case idle
|
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 failed(error: String)
|
||||||
case complete
|
case complete
|
||||||
|
|
||||||
/// The initial visible state shown when compaction begins.
|
/// 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(
|
return .compacting(
|
||||||
progress: 0.05,
|
progress: 0.05,
|
||||||
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Initial title shown during database compaction"),
|
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(
|
state: .compacting(
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Title shown before database compaction starts"),
|
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
|
continueAfterError: continueAfterError
|
||||||
)
|
)
|
||||||
@@ -90,7 +93,8 @@ struct CompactionView: View {
|
|||||||
return
|
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) {
|
Task.detached(priority: .userInitiated) {
|
||||||
do {
|
do {
|
||||||
@@ -100,7 +104,8 @@ struct CompactionView: View {
|
|||||||
compactionState = .compacting(
|
compactionState = .compacting(
|
||||||
progress: progress.fractionCompleted,
|
progress: progress.fractionCompleted,
|
||||||
stepTitle: progress.step.title,
|
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.
|
/// A percentage label for the current compaction progress.
|
||||||
private var progressPercentageText: String? {
|
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())
|
let percentage = Int((progress * 100).rounded())
|
||||||
return String(
|
return String(
|
||||||
@@ -142,6 +147,12 @@ struct CompactionLoadingView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@@ -183,7 +194,7 @@ struct CompactionLoadingView: View {
|
|||||||
case .idle, .complete:
|
case .idle, .complete:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
|
|
||||||
case .compacting(let progress, let stepTitle, let stepDetail):
|
case .compacting(let progress, let stepTitle, let stepDetail, _):
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
ProgressView(value: progress, total: 1.0)
|
ProgressView(value: progress, total: 1.0)
|
||||||
.progressViewStyle(.linear)
|
.progressViewStyle(.linear)
|
||||||
@@ -199,6 +210,8 @@ struct CompactionLoadingView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(maxWidth: 420, alignment: .leading)
|
.frame(maxWidth: 420, alignment: .leading)
|
||||||
@@ -212,7 +225,9 @@ struct CompactionLoadingView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Label(
|
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"
|
systemImage: "clock"
|
||||||
)
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
@@ -273,7 +288,20 @@ struct CompactionLoadingView: View {
|
|||||||
state: CompactionView.CompactionState.compacting(
|
state: CompactionView.CompactionState.compacting(
|
||||||
progress: 0.55,
|
progress: 0.55,
|
||||||
stepTitle: "Creating compacted snapshot",
|
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: {}
|
continueAfterError: {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -387,5 +387,6 @@ struct DamusAppNotification {
|
|||||||
enum Content: Hashable, Equatable {
|
enum Content: Hashable, Equatable {
|
||||||
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
|
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
|
||||||
case purple_expired(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)
|
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: days_remaining, expired: false)
|
||||||
case .purple_expired(expiry_date: _):
|
case .purple_expired(expiry_date: _):
|
||||||
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: 0, expired: true)
|
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)
|
.padding(.horizontal)
|
||||||
@@ -146,6 +148,35 @@ struct DamusAppNotificationView: View {
|
|||||||
return String(format: message_format, String(days_remaining))
|
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:
|
// `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 {
|
#Preview {
|
||||||
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_expired(expiry_date: 1709156602), timestamp: Date.now))
|
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 {
|
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_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.
|
/// 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 {
|
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 var showing_compact_alert: Bool = false
|
||||||
@State fileprivate var auto_compact_schedule: AutoCompactSchedule = Ndb.get_auto_compact_schedule()
|
@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+)
|
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
|
||||||
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
||||||
guard let stats = stats else { return [] }
|
guard let stats = stats else { return [] }
|
||||||
@@ -357,8 +372,9 @@ struct StorageSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Section that lets the user configure automatic periodic compaction.
|
/// 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
|
/// current state: either the time remaining until the next compaction or a note
|
||||||
/// that compaction will run on the next app launch.
|
/// that compaction will run on the next app launch.
|
||||||
var AutoCompactSection: some View {
|
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"),
|
NSLocalizedString("Automatically compact database", comment: "Setting label for choosing how often to auto-compact the database"),
|
||||||
selection: $auto_compact_schedule
|
selection: $auto_compact_schedule
|
||||||
) {
|
) {
|
||||||
ForEach(AutoCompactSchedule.allCases, id: \.self) { option in
|
ForEach(availableAutoCompactSchedules, id: \.self) { option in
|
||||||
Text(option.text_description()).tag(option)
|
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
|
/// Shows either the time remaining until the next scheduled compaction or a message
|
||||||
/// indicating that compaction will happen on the next app launch.
|
/// indicating that compaction will happen on the next app launch.
|
||||||
private var AutoCompactCaption: some View {
|
private var AutoCompactCaption: some View {
|
||||||
Group {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if auto_compact_schedule == .never {
|
Group {
|
||||||
Text("Automatic database compaction is disabled.", comment: "Caption shown when auto-compact is set to never")
|
if auto_compact_schedule == .never {
|
||||||
} else if compact_scheduling_state == .scheduled {
|
Text("Automatic database compaction is disabled.", comment: "Caption shown when auto-compact is set to never")
|
||||||
Text("Will compact on the next app launch.", comment: "Caption shown when a compaction is already queued for the next launch")
|
} else if compact_scheduling_state == .scheduled {
|
||||||
} else if let timeRemaining = nextCompactTimeRemaining {
|
Text("Will compact on the next app launch.", comment: "Caption shown when a compaction is already queued for the next launch")
|
||||||
Text(
|
}
|
||||||
String(
|
else if isLargeDatabaseSkippingAutoCompact {
|
||||||
format: NSLocalizedString(
|
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")
|
||||||
"Next automatic compaction %@.",
|
} else if let timeRemaining = nextCompactTimeRemaining {
|
||||||
comment: "Caption showing how long until the next automatic compaction. %@ is replaced with a human-readable duration like 'in 3 days'."
|
Text(
|
||||||
),
|
String(
|
||||||
timeRemaining
|
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 {
|
||||||
} else {
|
Text("Will compact on the next app launch.", comment: "Caption shown when the scheduled compaction interval has already elapsed")
|
||||||
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)
|
.font(.caption)
|
||||||
@@ -451,7 +476,7 @@ struct StorageSettingsView: View {
|
|||||||
title: Text("Compact Database", comment: "Confirmation dialog title for database compaction"),
|
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."),
|
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.")) {
|
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
|
compact_scheduling_state = .scheduled
|
||||||
},
|
},
|
||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true)
|
||||||
// Ensure the flag is cleared before each test.
|
// Ensure the flag is cleared before each test.
|
||||||
UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key)
|
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.
|
// 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.auto_compact_schedule_key)
|
||||||
UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_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 {
|
override func tearDown() async throws {
|
||||||
@@ -28,9 +30,11 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
// Leave the flag cleared after each test.
|
// Leave the flag cleared after each test.
|
||||||
UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key)
|
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.
|
// Clean up scheduling keys.
|
||||||
UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key)
|
UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key)
|
||||||
UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_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()
|
try await super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +52,21 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
||||||
"set_compact_on_next_launch() should set the UserDefaults flag to true"
|
"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
|
// MARK: - compact_if_needed: flag not set
|
||||||
@@ -181,10 +200,11 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
// MARK: - AutoCompactSchedule: interval values
|
// MARK: - AutoCompactSchedule: interval values
|
||||||
|
|
||||||
func testAutoCompactSchedule_intervalValues() {
|
func testAutoCompactSchedule_intervalValues() {
|
||||||
XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours")
|
XCTAssertEqual(AutoCompactSchedule.everyMinute.interval, 60, "everyMinute interval should be 60 seconds")
|
||||||
XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7, "weekly interval should be 7 days")
|
XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours")
|
||||||
XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30, "monthly interval should be 30 days")
|
XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7,"weekly interval should be 7 days")
|
||||||
XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval")
|
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
|
// MARK: - get/set auto-compact schedule
|
||||||
@@ -202,13 +222,44 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - schedule_auto_compact_if_needed
|
||||||
|
|
||||||
func testScheduleAutoCompact_doesNothingWhenNever() {
|
func testScheduleAutoCompact_doesNothingWhenNever() {
|
||||||
Ndb.set_auto_compact_schedule(.never)
|
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(
|
XCTAssertFalse(
|
||||||
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
||||||
"schedule=never should never set the compact flag"
|
"schedule=never should never set the compact flag"
|
||||||
@@ -221,14 +272,28 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8))
|
let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8))
|
||||||
UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key)
|
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
|
// When
|
||||||
Ndb.schedule_auto_compact_if_needed()
|
let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath)
|
||||||
|
|
||||||
// Then: flag should be set
|
// 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(
|
XCTAssertTrue(
|
||||||
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
||||||
"compact flag should be set when scheduled interval has elapsed"
|
"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() {
|
func testScheduleAutoCompact_doesNotSetFlag_whenIntervalHasNotElapsed() {
|
||||||
@@ -238,9 +303,10 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key)
|
UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
Ndb.schedule_auto_compact_if_needed()
|
let decision = Ndb.schedule_auto_compact_if_needed()
|
||||||
|
|
||||||
// Then: flag should NOT be set
|
// Then: flag should NOT be set
|
||||||
|
XCTAssertEqual(decision, .noAction, "Auto-compaction should do nothing when the interval has not elapsed")
|
||||||
XCTAssertFalse(
|
XCTAssertFalse(
|
||||||
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
||||||
"compact flag should not be set when the scheduled interval has not yet elapsed"
|
"compact flag should not be set when the scheduled interval has not yet elapsed"
|
||||||
@@ -252,16 +318,61 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
Ndb.set_auto_compact_schedule(.daily)
|
Ndb.set_auto_compact_schedule(.daily)
|
||||||
// last_compact_date_key is absent (cleaned in setUp)
|
// 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
|
// 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)
|
// 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(
|
XCTAssertTrue(
|
||||||
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
||||||
"compact flag should be set on first launch (no previous compaction date)"
|
"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
|
// MARK: - compact_if_needed: records last compact date
|
||||||
|
|
||||||
func testCompactIfNeeded_recordsLastCompactDate_afterSuccessfulCompaction() {
|
func testCompactIfNeeded_recordsLastCompactDate_afterSuccessfulCompaction() {
|
||||||
@@ -290,4 +401,35 @@ final class NdbCompactionTests: XCTestCase {
|
|||||||
XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started")
|
XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started")
|
||||||
XCTAssertLessThanOrEqual(lastDate, afterCompact, "last compact date should be <= time after compaction ended")
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-12
@@ -85,6 +85,7 @@ struct NdbCompactionProgress: Equatable {
|
|||||||
|
|
||||||
/// Defines how often the database should be automatically compacted.
|
/// Defines how often the database should be automatically compacted.
|
||||||
enum AutoCompactSchedule: String, CaseIterable, Equatable {
|
enum AutoCompactSchedule: String, CaseIterable, Equatable {
|
||||||
|
case everyMinute
|
||||||
case daily
|
case daily
|
||||||
case weekly
|
case weekly
|
||||||
case monthly
|
case monthly
|
||||||
@@ -93,6 +94,8 @@ enum AutoCompactSchedule: String, CaseIterable, Equatable {
|
|||||||
/// Human-readable label shown in the settings UI.
|
/// Human-readable label shown in the settings UI.
|
||||||
func text_description() -> String {
|
func text_description() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .everyMinute:
|
||||||
|
return NSLocalizedString("Every minute", comment: "Auto-compact schedule option: compact every minute for developer testing")
|
||||||
case .daily:
|
case .daily:
|
||||||
return NSLocalizedString("Once a day", comment: "Auto-compact schedule option: compact once a day")
|
return NSLocalizedString("Once a day", comment: "Auto-compact schedule option: compact once a day")
|
||||||
case .weekly:
|
case .weekly:
|
||||||
@@ -107,14 +110,28 @@ enum AutoCompactSchedule: String, CaseIterable, Equatable {
|
|||||||
/// The time interval (in seconds) between automatic compactions, or `nil` for `.never`.
|
/// The time interval (in seconds) between automatic compactions, or `nil` for `.never`.
|
||||||
var interval: TimeInterval? {
|
var interval: TimeInterval? {
|
||||||
switch self {
|
switch self {
|
||||||
case .daily: return 60 * 60 * 24
|
case .everyMinute: return 60
|
||||||
case .weekly: return 60 * 60 * 24 * 7
|
case .daily: return 60 * 60 * 24
|
||||||
case .monthly: return 60 * 60 * 24 * 30
|
case .weekly: return 60 * 60 * 24 * 7
|
||||||
case .never: return nil
|
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 {
|
extension Ndb {
|
||||||
/// Errors that can occur while compacting the database.
|
/// Errors that can occur while compacting the database.
|
||||||
enum CompactionError: LocalizedError {
|
enum CompactionError: LocalizedError {
|
||||||
@@ -175,21 +192,89 @@ extension Ndb {
|
|||||||
/// Name of the temporary subdirectory created during an in-place compaction.
|
/// Name of the temporary subdirectory created during an in-place compaction.
|
||||||
private static let compactTempDirName = "ndb_compact_temp"
|
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.
|
/// 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"
|
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).
|
/// The `UserDefaults` key used to persist the auto-compact schedule (stored as raw string).
|
||||||
static let auto_compact_schedule_key = "ndb_auto_compact_schedule"
|
static let auto_compact_schedule_key = "ndb_auto_compact_schedule"
|
||||||
|
|
||||||
/// The `UserDefaults` key used to record when the last successful compaction occurred.
|
/// The `UserDefaults` key used to record when the last successful compaction occurred.
|
||||||
static let last_compact_date_key = "ndb_last_compact_date"
|
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.
|
/// 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
|
/// Call this to schedule a one-time compaction. The flag is cleared automatically after
|
||||||
/// a successful compaction in `compact_if_needed()`.
|
/// 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(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`.
|
/// 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
|
/// 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()`.
|
/// 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()
|
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 now = Date()
|
||||||
let lastDate = get_last_compact_date() ?? .distantPast
|
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)
|
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.
|
/// 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 {
|
guard db_file_exists(path: path) else {
|
||||||
// No database file present yet; nothing to compact — just clear the flag.
|
// 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))
|
progress?(.init(step: .completed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -362,8 +475,11 @@ extension Ndb {
|
|||||||
// Record the date of this successful compaction for the auto-compact scheduler.
|
// Record the date of this successful compaction for the auto-compact scheduler.
|
||||||
UserDefaults.standard.set(Date(), forKey: last_compact_date_key)
|
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.
|
// 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))
|
progress?(.init(step: .completed))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user