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
+150 -8
View File
@@ -17,9 +17,11 @@ final class NdbCompactionTests: XCTestCase {
try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true)
// Ensure the flag is cleared before each test.
UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key)
UserDefaults.standard.removeObject(forKey: Ndb.compact_on_next_launch_source_key)
// Reset scheduling-related keys so tests start from a clean state.
UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key)
UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key)
UserDefaults.standard.removeObject(forKey: Ndb.large_db_compaction_notification_pending_key)
}
override func tearDown() async throws {
@@ -28,9 +30,11 @@ final class NdbCompactionTests: XCTestCase {
}
// Leave the flag cleared after each test.
UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key)
UserDefaults.standard.removeObject(forKey: Ndb.compact_on_next_launch_source_key)
// Clean up scheduling keys.
UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key)
UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key)
UserDefaults.standard.removeObject(forKey: Ndb.large_db_compaction_notification_pending_key)
try await super.tearDown()
}
@@ -48,6 +52,21 @@ final class NdbCompactionTests: XCTestCase {
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"set_compact_on_next_launch() should set the UserDefaults flag to true"
)
XCTAssertEqual(
Ndb.get_compact_on_next_launch_source(),
.manual,
"set_compact_on_next_launch() should default to a manual request source"
)
}
func testSetCompactOnNextLaunch_persistsExplicitSource() {
Ndb.set_compact_on_next_launch(source: .automatic)
XCTAssertEqual(
Ndb.get_compact_on_next_launch_source(),
.automatic,
"set_compact_on_next_launch(source:) should persist the provided request source"
)
}
// MARK: - compact_if_needed: flag not set
@@ -181,10 +200,11 @@ final class NdbCompactionTests: XCTestCase {
// MARK: - AutoCompactSchedule: interval values
func testAutoCompactSchedule_intervalValues() {
XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours")
XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7, "weekly interval should be 7 days")
XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30, "monthly interval should be 30 days")
XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval")
XCTAssertEqual(AutoCompactSchedule.everyMinute.interval, 60, "everyMinute interval should be 60 seconds")
XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours")
XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7,"weekly interval should be 7 days")
XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30,"monthly interval should be 30 days")
XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval")
}
// MARK: - get/set auto-compact schedule
@@ -201,14 +221,45 @@ final class NdbCompactionTests: XCTestCase {
"Round-trip failed for schedule: \(schedule)")
}
}
func testScheduleAutoCompact_setsFlag_whenEveryMinuteIntervalHasElapsed() {
// Given: schedule is every minute, last compaction was 2 minutes ago
Ndb.set_auto_compact_schedule(.everyMinute)
let twoMinutesAgo = Date().addingTimeInterval(-(60 * 2))
UserDefaults.standard.set(twoMinutesAgo, forKey: Ndb.last_compact_date_key)
let dbPath = testDirectory.appendingPathComponent("every_minute_db").path
try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)
guard let ndb = Ndb(path: dbPath) else {
XCTFail("Could not open Ndb at \(dbPath)")
return
}
ndb.close()
// When
let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath)
// Then
XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled when the every-minute interval has elapsed")
XCTAssertTrue(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should be set when the every-minute interval has elapsed"
)
XCTAssertEqual(
Ndb.get_compact_on_next_launch_source(),
.automatic,
"Every-minute auto-compaction should record an automatic request source"
)
}
// MARK: - schedule_auto_compact_if_needed
func testScheduleAutoCompact_doesNothingWhenNever() {
Ndb.set_auto_compact_schedule(.never)
Ndb.schedule_auto_compact_if_needed()
let decision = Ndb.schedule_auto_compact_if_needed()
XCTAssertEqual(decision, .noAction, "schedule=never should not schedule compaction")
XCTAssertFalse(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"schedule=never should never set the compact flag"
@@ -220,15 +271,29 @@ final class NdbCompactionTests: XCTestCase {
Ndb.set_auto_compact_schedule(.weekly)
let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8))
UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key)
let dbPath = testDirectory.appendingPathComponent("scheduled_db").path
try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)
guard let ndb = Ndb(path: dbPath) else {
XCTFail("Could not open Ndb at \(dbPath)")
return
}
ndb.close()
// When
Ndb.schedule_auto_compact_if_needed()
let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath)
// Then: flag should be set
XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled when the interval has elapsed and the DB is not too large")
XCTAssertTrue(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should be set when scheduled interval has elapsed"
)
XCTAssertEqual(
Ndb.get_compact_on_next_launch_source(),
.automatic,
"Automatically scheduled compaction should record an automatic request source"
)
}
func testScheduleAutoCompact_doesNotSetFlag_whenIntervalHasNotElapsed() {
@@ -238,9 +303,10 @@ final class NdbCompactionTests: XCTestCase {
UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key)
// When
Ndb.schedule_auto_compact_if_needed()
let decision = Ndb.schedule_auto_compact_if_needed()
// Then: flag should NOT be set
XCTAssertEqual(decision, .noAction, "Auto-compaction should do nothing when the interval has not elapsed")
XCTAssertFalse(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should not be set when the scheduled interval has not yet elapsed"
@@ -251,16 +317,61 @@ final class NdbCompactionTests: XCTestCase {
// Given: schedule is daily, no last compact date stored (first launch)
Ndb.set_auto_compact_schedule(.daily)
// last_compact_date_key is absent (cleaned in setUp)
let dbPath = testDirectory.appendingPathComponent("first_launch_db").path
try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)
guard let ndb = Ndb(path: dbPath) else {
XCTFail("Could not open Ndb at \(dbPath)")
return
}
ndb.close()
// When
Ndb.schedule_auto_compact_if_needed()
let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath)
// Then: flag should be set (distantPast triggers compaction on first launch)
XCTAssertEqual(decision, .scheduled, "Auto-compaction should be scheduled on first launch when a database exists")
XCTAssertTrue(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"compact flag should be set on first launch (no previous compaction date)"
)
}
func testScheduleAutoCompact_skipsLargeDatabase_andSetsReminder() {
// Given: schedule is due and the database is considered large
Ndb.set_auto_compact_schedule(.weekly)
let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8))
UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key)
let dbPath = testDirectory.appendingPathComponent("large_db").path
try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)
let dataPath = "\(dbPath)/\(Ndb.main_db_file_name)"
FileManager.default.createFile(atPath: dataPath, contents: Data())
guard let handle = FileHandle(forWritingAtPath: dataPath) else {
XCTFail("Could not open \(dataPath) for writing")
return
}
defer { try? handle.close() }
try? handle.truncate(atOffset: Ndb.large_database_compaction_threshold_bytes)
// When
let decision = Ndb.schedule_auto_compact_if_needed(db_path: dbPath)
// Then
XCTAssertEqual(
decision,
.skippedBecauseDatabaseTooLarge(databaseSizeBytes: Ndb.large_database_compaction_threshold_bytes),
"Auto-compaction should be skipped when the database exceeds the large-database threshold"
)
XCTAssertFalse(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"Large databases should not be auto-scheduled for compaction"
)
XCTAssertTrue(
Ndb.is_large_db_compaction_notification_pending(),
"Skipping auto-compaction for a large database should queue an in-app reminder"
)
}
// MARK: - compact_if_needed: records last compact date
@@ -290,4 +401,35 @@ final class NdbCompactionTests: XCTestCase {
XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started")
XCTAssertLessThanOrEqual(lastDate, afterCompact, "last compact date should be <= time after compaction ended")
}
func testCompactIfNeeded_skipsAutomaticRequest_whenDatabaseIsLarge() {
let dbPath = testDirectory.appendingPathComponent("auto_skip_large_db").path
try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)
let dataPath = "\(dbPath)/\(Ndb.main_db_file_name)"
FileManager.default.createFile(atPath: dataPath, contents: Data())
guard let handle = FileHandle(forWritingAtPath: dataPath) else {
XCTFail("Could not open \(dataPath) for writing")
return
}
defer { try? handle.close() }
try? handle.truncate(atOffset: Ndb.large_database_compaction_threshold_bytes)
Ndb.set_compact_on_next_launch(source: .automatic)
XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath))
XCTAssertFalse(
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
"Automatic compaction requests should be cleared when skipped for a large database"
)
XCTAssertNil(
Ndb.get_compact_on_next_launch_source(),
"Skipping a large automatic compaction should clear the stored request source"
)
XCTAssertTrue(
Ndb.is_large_db_compaction_notification_pending(),
"Skipping a large automatic compaction should queue an in-app reminder"
)
}
}