Add auto-compact LMDB schedule setting with UI and tests
Closes: https://github.com/damus-io/damus/issues/3718 Changelog-Added: Automatic lossless storage optimization Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user