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>
436 lines
18 KiB
Swift
436 lines
18 KiB
Swift
//
|
|
// NdbCompactionTests.swift
|
|
// damus
|
|
//
|
|
|
|
import XCTest
|
|
@testable import damus
|
|
|
|
final class NdbCompactionTests: XCTestCase {
|
|
|
|
var testDirectory: URL!
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
testDirectory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("NdbCompactionTests-\(UUID().uuidString)")
|
|
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 {
|
|
if let testDirectory {
|
|
try? FileManager.default.removeItem(at: testDirectory)
|
|
}
|
|
// 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()
|
|
}
|
|
|
|
// MARK: - set_compact_on_next_launch
|
|
|
|
func testSetCompactOnNextLaunch_setsUserDefaultsFlag() {
|
|
// Given: the flag is not set
|
|
XCTAssertFalse(UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key))
|
|
|
|
// When
|
|
Ndb.set_compact_on_next_launch()
|
|
|
|
// Then
|
|
XCTAssertTrue(
|
|
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
|
|
|
|
func testCompactIfNeeded_doesNothingWhenFlagNotSet() {
|
|
// Given: the flag is false (set in setUp)
|
|
let dbPath = testDirectory.path
|
|
|
|
// When / Then
|
|
XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath))
|
|
|
|
// Then: no temp directory was created, no files were touched
|
|
let tempPath = "\(dbPath)/ndb_compact_temp"
|
|
XCTAssertFalse(
|
|
FileManager.default.fileExists(atPath: tempPath),
|
|
"compact_if_needed should not create a temp directory when the flag is not set"
|
|
)
|
|
XCTAssertFalse(
|
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
|
"Flag should remain false when compact_if_needed does nothing"
|
|
)
|
|
}
|
|
|
|
// MARK: - compact_if_needed: no database present
|
|
|
|
func testCompactIfNeeded_clearsFlagWhenNoDatabaseExists() {
|
|
// Given: flag is set, but no data.mdb exists in the directory
|
|
Ndb.set_compact_on_next_launch()
|
|
let emptyPath = testDirectory.appendingPathComponent("empty_db").path
|
|
try? FileManager.default.createDirectory(atPath: emptyPath, withIntermediateDirectories: true)
|
|
|
|
// When / Then
|
|
XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: emptyPath))
|
|
|
|
// Then: flag is cleared, no temp directory left over
|
|
XCTAssertFalse(
|
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
|
"compact_if_needed should clear the flag even when no database file exists"
|
|
)
|
|
let tempPath = "\(emptyPath)/ndb_compact_temp"
|
|
XCTAssertFalse(
|
|
FileManager.default.fileExists(atPath: tempPath),
|
|
"No temp directory should remain after compact_if_needed with no DB"
|
|
)
|
|
}
|
|
|
|
// MARK: - compact_if_needed: full round-trip
|
|
|
|
func testCompactIfNeeded_compactsAndClearsFlagWithRealDatabase() {
|
|
// Given: a real Ndb database and the compact flag is set
|
|
let dbPath = testDirectory.appendingPathComponent("real_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()
|
|
|
|
let originalSize = (try? FileManager.default.attributesOfItem(atPath: "\(dbPath)/data.mdb")[.size] as? Int) ?? 0
|
|
XCTAssertGreaterThan(originalSize, 0, "A freshly created database should have a non-zero size")
|
|
|
|
Ndb.set_compact_on_next_launch()
|
|
|
|
// When / Then
|
|
XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath))
|
|
|
|
// Then: the flag is cleared
|
|
XCTAssertFalse(
|
|
UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key),
|
|
"compact_if_needed should clear the flag after a successful compaction"
|
|
)
|
|
|
|
// The compacted database must be a valid, openable Ndb
|
|
guard let compactedNdb = Ndb(path: dbPath) else {
|
|
XCTFail("Could not open Ndb after compaction — database may be corrupt")
|
|
return
|
|
}
|
|
compactedNdb.close()
|
|
|
|
// No leftover temp directory
|
|
let tempPath = "\(dbPath)/ndb_compact_temp"
|
|
XCTAssertFalse(
|
|
FileManager.default.fileExists(atPath: tempPath),
|
|
"Temp directory should be cleaned up after successful compaction"
|
|
)
|
|
}
|
|
|
|
// MARK: - compact_if_needed: lock.mdb removal
|
|
|
|
func testCompactIfNeeded_removesLockFileAfterCompaction() {
|
|
// Given: a real Ndb database that has been opened and closed (so lock.mdb exists)
|
|
let dbPath = testDirectory.appendingPathComponent("lock_test_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()
|
|
|
|
let lockPath = "\(dbPath)/lock.mdb"
|
|
XCTAssertTrue(
|
|
FileManager.default.fileExists(atPath: lockPath),
|
|
"lock.mdb should exist after opening and closing Ndb"
|
|
)
|
|
|
|
// When: compact_if_needed runs
|
|
Ndb.set_compact_on_next_launch()
|
|
XCTAssertNoThrow(try Ndb.compact_if_needed(db_path: dbPath))
|
|
|
|
// Then: lock.mdb should NOT exist (it was deleted during compaction)
|
|
XCTAssertFalse(
|
|
FileManager.default.fileExists(atPath: lockPath),
|
|
"lock.mdb should be removed after compaction to prevent stale reader-table crashes"
|
|
)
|
|
|
|
// And: LMDB recreates a fresh lock.mdb when re-opened
|
|
guard let reopenedNdb = Ndb(path: dbPath) else {
|
|
XCTFail("Could not re-open Ndb after compaction — database may be corrupt")
|
|
return
|
|
}
|
|
reopenedNdb.close()
|
|
|
|
XCTAssertTrue(
|
|
FileManager.default.fileExists(atPath: lockPath),
|
|
"lock.mdb should be recreated by LMDB after opening the compacted database"
|
|
)
|
|
}
|
|
|
|
// MARK: - AutoCompactSchedule: interval values
|
|
|
|
func testAutoCompactSchedule_intervalValues() {
|
|
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
|
|
|
|
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)")
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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"
|
|
)
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
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() {
|
|
// 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
|
|
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"
|
|
)
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
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
|
|
|
|
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()
|
|
XCTAssertNoThrow(try 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")
|
|
}
|
|
|
|
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"
|
|
)
|
|
}
|
|
}
|