Merge pull request #3719 from damus-io/copilot/add-auto-compact-lmdb-setting

Auto compact LMDB on a schedule
This commit is contained in:
Daniel D’Aquino
2026-04-06 17:13:04 -07:00
committed by GitHub
4 changed files with 276 additions and 2 deletions
+4 -2
View File
@@ -141,11 +141,13 @@ struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
init(keypair: Keypair, appDelegate: AppDelegate?) { init(keypair: Keypair, appDelegate: AppDelegate?) {
// Compact the database if requested from the previous session. // Schedule automatic compaction based on the user's configured interval, then
// This runs before opening the main Ndb instance so that it works on an idle database. // run compaction if it was previously requested (either manually or by the scheduler).
// Both calls run before opening the main Ndb instance so they work on an idle database.
// This also gets run here instead of `connect` because we should anticipate this to add a few seconds of delay in worst case scenarios. // This also gets run here instead of `connect` because we should anticipate this to add a few seconds of delay in worst case scenarios.
// If we were to add this in the `connect` function, parallel functions that depend on `damus_state!` could cause crashes in the app. // If we were to add this in the `connect` function, parallel functions that depend on `damus_state!` could cause crashes in the app.
// By placing this here, we only delay the splash screen a bit // By placing this here, we only delay the splash screen a bit
Ndb.schedule_auto_compact_if_needed()
Ndb.compact_if_needed() Ndb.compact_if_needed()
self.keypair = keypair self.keypair = keypair
@@ -54,6 +54,7 @@ struct StorageSettingsView: View {
@State var showing_cache_clear_alert: Bool = false @State var showing_cache_clear_alert: Bool = false
@State fileprivate var compact_scheduling_state: CompactSchedulingState = .not_scheduled @State fileprivate var compact_scheduling_state: CompactSchedulingState = .not_scheduled
@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()
/// 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>)] {
@@ -174,6 +175,9 @@ struct StorageSettingsView: View {
self.ClearCacheButton self.ClearCacheButton
self.CompactDatabaseButton self.CompactDatabaseButton
} }
// Auto-compact Section
self.AutoCompactSection
} }
// Loading state // Loading state
@@ -352,6 +356,77 @@ struct StorageSettingsView: View {
} }
} }
/// Section that lets the user configure automatic periodic compaction.
///
/// Shows a `Picker` with four 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 {
Section(
header: Text("Auto-Compact", comment: "Section header for automatic database compaction schedule"),
footer: AutoCompactCaption
) {
Picker(
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
Text(option.text_description()).tag(option)
}
}
.onChange(of: auto_compact_schedule) { newSchedule in
Ndb.set_auto_compact_schedule(newSchedule)
}
}
}
/// Caption displayed below the auto-compact picker.
///
/// 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
)
)
} else {
Text("Will compact on the next app launch.", comment: "Caption shown when the scheduled compaction interval has already elapsed")
}
}
.font(.caption)
.foregroundColor(.secondary)
}
/// Human-readable string describing the time remaining until the next auto-compaction,
/// or `nil` if the interval has already elapsed (i.e., compaction is due now).
private var nextCompactTimeRemaining: String? {
guard let interval = auto_compact_schedule.interval else { return nil }
let lastDate = Ndb.get_last_compact_date() ?? .distantPast
let nextDate = lastDate.addingTimeInterval(interval)
let remaining = nextDate.timeIntervalSince(Date())
guard remaining > 0 else { return nil }
return Self.relativeFormatter.localizedString(fromTimeInterval: remaining)
}
/// Shared formatter for human-readable relative durations (e.g. "in 3 days").
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()
/// Compact database button view with confirmation dialog. /// Compact database button view with confirmation dialog.
/// ///
/// Schedules a one-time database compaction to run on the next app launch. The user /// Schedules a one-time database compaction to run on the next app launch. The user
+119
View File
@@ -17,6 +17,9 @@ 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)
// 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)
} }
override func tearDown() async throws { override func tearDown() async throws {
@@ -25,6 +28,9 @@ 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)
// Clean up scheduling keys.
UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key)
UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key)
try await super.tearDown() try await super.tearDown()
} }
@@ -171,4 +177,117 @@ final class NdbCompactionTests: XCTestCase {
"lock.mdb should be recreated by LMDB after opening the compacted database" "lock.mdb should be recreated by LMDB after opening the compacted database"
) )
} }
// 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")
}
// MARK: - get/set auto-compact schedule
func testGetAutoCompactSchedule_returnsWeeklyByDefault() {
// No key stored default should be .weekly
XCTAssertEqual(Ndb.get_auto_compact_schedule(), .weekly)
}
func testSetAndGetAutoCompactSchedule_roundTrips() {
for schedule in AutoCompactSchedule.allCases {
Ndb.set_auto_compact_schedule(schedule)
XCTAssertEqual(Ndb.get_auto_compact_schedule(), schedule,
"Round-trip failed for schedule: \(schedule)")
}
}
// MARK: - schedule_auto_compact_if_needed
func testScheduleAutoCompact_doesNothingWhenNever() {
Ndb.set_auto_compact_schedule(.never)
Ndb.schedule_auto_compact_if_needed()
XCTAssertFalse(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"schedule=never should never set the compact flag"
)
}
func testScheduleAutoCompact_setsFlag_whenIntervalHasElapsed() {
// Given: schedule is weekly, last compaction was 8 days ago
Ndb.set_auto_compact_schedule(.weekly)
let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8))
UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key)
// When
Ndb.schedule_auto_compact_if_needed()
// Then: flag should be set
XCTAssertTrue(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should be set when scheduled interval has elapsed"
)
}
func testScheduleAutoCompact_doesNotSetFlag_whenIntervalHasNotElapsed() {
// Given: schedule is weekly, last compaction was 1 day ago
Ndb.set_auto_compact_schedule(.weekly)
let oneDayAgo = Date().addingTimeInterval(-(60 * 60 * 24))
UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key)
// When
Ndb.schedule_auto_compact_if_needed()
// Then: flag should NOT be set
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"
)
}
func testScheduleAutoCompact_setsFlag_whenNoLastCompactDate() {
// 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)
// When
Ndb.schedule_auto_compact_if_needed()
// Then: flag should be set (distantPast triggers compaction on first launch)
XCTAssertTrue(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should be set on first launch (no previous compaction date)"
)
}
// MARK: - compact_if_needed: records last compact date
func testCompactIfNeeded_recordsLastCompactDate_afterSuccessfulCompaction() {
let dbPath = testDirectory.appendingPathComponent("date_record_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()
XCTAssertNil(Ndb.get_last_compact_date(), "No last compact date should exist before first compaction")
Ndb.set_compact_on_next_launch()
let beforeCompact = Date()
Ndb.compact_if_needed(db_path: dbPath)
let afterCompact = Date()
guard let lastDate = Ndb.get_last_compact_date() else {
XCTFail("Last compact date should be set after successful compaction")
return
}
XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started")
XCTAssertLessThanOrEqual(lastDate, afterCompact, "last compact date should be <= time after compaction ended")
}
} }
+78
View File
@@ -5,6 +5,38 @@
import Foundation import Foundation
/// Defines how often the database should be automatically compacted.
enum AutoCompactSchedule: String, CaseIterable, Equatable {
case daily
case weekly
case monthly
case never
/// Human-readable label shown in the settings UI.
func text_description() -> String {
switch self {
case .daily:
return NSLocalizedString("Once a day", comment: "Auto-compact schedule option: compact once a day")
case .weekly:
return NSLocalizedString("Once a week", comment: "Auto-compact schedule option: compact once a week")
case .monthly:
return NSLocalizedString("Once a month", comment: "Auto-compact schedule option: compact once a month")
case .never:
return NSLocalizedString("Never", comment: "Auto-compact schedule option: never auto-compact")
}
}
/// 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
}
}
}
extension Ndb { extension Ndb {
/// Makes a compacted copy of the database in a separate directory. /// Makes a compacted copy of the database in a separate directory.
/// ///
@@ -37,6 +69,12 @@ extension Ndb {
/// 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 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"
/// 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
@@ -45,6 +83,43 @@ extension Ndb {
UserDefaults.standard.set(true, forKey: compact_on_next_launch_key) UserDefaults.standard.set(true, forKey: compact_on_next_launch_key)
} }
/// Reads the persisted auto-compact schedule from `UserDefaults`.
///
/// Defaults to `.weekly` if no value has been saved yet.
static func get_auto_compact_schedule() -> AutoCompactSchedule {
guard let raw = UserDefaults.standard.string(forKey: auto_compact_schedule_key),
let schedule = AutoCompactSchedule(rawValue: raw) else {
return .weekly
}
return schedule
}
/// Persists the auto-compact schedule to `UserDefaults`.
static func set_auto_compact_schedule(_ schedule: AutoCompactSchedule) {
UserDefaults.standard.set(schedule.rawValue, forKey: auto_compact_schedule_key)
}
/// Returns the date of the last successful compaction, or `nil` if none has occurred.
static func get_last_compact_date() -> Date? {
return UserDefaults.standard.object(forKey: last_compact_date_key) as? Date
}
/// Sets the compact-on-next-launch flag if the scheduled interval has elapsed since the
/// last successful compaction.
///
/// Call this once on app startup **before** `compact_if_needed()`.
static func schedule_auto_compact_if_needed() {
let schedule = get_auto_compact_schedule()
guard let interval = schedule.interval else { return }
let now = Date()
let lastDate = get_last_compact_date() ?? .distantPast
guard now.timeIntervalSince(lastDate) >= interval else { return }
Log.info("Auto-compact: interval elapsed — scheduling compaction on next launch", for: .storage)
set_compact_on_next_launch()
}
/// 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.
/// ///
/// This is intended to be called once during app startup **before** `Ndb` is opened for /// This is intended to be called once during app startup **before** `Ndb` is opened for
@@ -154,6 +229,9 @@ extension Ndb {
// Clean up the temp directory (any remaining files such as lock.mdb). // Clean up the temp directory (any remaining files such as lock.mdb).
try? FileManager.default.removeItem(atPath: tempPath) try? FileManager.default.removeItem(atPath: tempPath)
// Record the date of this successful compaction for the auto-compact scheduler.
UserDefaults.standard.set(Date(), forKey: last_compact_date_key)
// 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) UserDefaults.standard.set(false, forKey: compact_on_next_launch_key)
} }