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

mode

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

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

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

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

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-04-29 14:40:18 -07:00
parent 53dde55616
commit 2af65320ef
7 changed files with 415 additions and 50 deletions
+128 -12
View File
@@ -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))
}