feat: implement LMDB compact solution for storage optimization
- Add `compact(to:)` method on `Ndb` using `mdb_env_copy2` / `ndb_snapshot` with `MDB_CP_COMPACT` flag to produce a smaller compacted database copy - Add `compact_if_needed(db_path:)` static startup method that reads a UserDefaults flag, opens a temp Ndb, compacts to a sibling temp dir, atomically replaces `data.mdb`, cleans up, and clears the flag - Add `set_compact_on_next_launch()` to schedule compaction from the UI - Call `Ndb.compact_if_needed()` in `ContentView.connect()` before the main Ndb instance is opened - Add `CompactDatabaseButton` to `StorageSettingsView` with a confirmation dialog that informs the user a restart is needed - Add `NdbCompactionTests` unit tests covering flag API, no-op path, missing-DB path, and full round-trip compaction Closes: https://github.com/damus-io/damus/issues/3680 Changelog-Added: Added "Compact Database" button in Settings → Storage that reclaims unused space by compacting the NostrDB on the next app launch Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// Ndb+Compaction.swift
|
||||
// damus
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Ndb {
|
||||
/// Makes a compacted copy of the database in a separate directory.
|
||||
///
|
||||
/// This uses `mdb_env_copy2` with `MDB_CP_COMPACT` (flag = 0x01), which omits free pages
|
||||
/// and sequentially renumbers all pages in the output, reducing database file size.
|
||||
/// - Parameter path: The directory path where the compacted database will be written.
|
||||
/// The directory must already exist and be empty.
|
||||
func compact(to path: String) throws {
|
||||
enum CompactError: Error {
|
||||
case mdbOperationError(errno: Int32)
|
||||
}
|
||||
|
||||
try withNdb({
|
||||
try path.withCString({ pathCString in
|
||||
let rc = ndb_snapshot(self.ndb.ndb, pathCString, Self.MDB_CP_COMPACT)
|
||||
guard rc == 0 else {
|
||||
throw CompactError.mdbOperationError(errno: rc)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// LMDB compact-copy flag. Passed to `ndb_snapshot` / `mdb_env_copy2` to produce a
|
||||
/// compacted (free-page-omitting) database copy. Mirrors `MDB_CP_COMPACT = 0x01` from lmdb.h.
|
||||
private static let MDB_CP_COMPACT: UInt32 = 1
|
||||
|
||||
/// Name of the temporary subdirectory created during an in-place compaction.
|
||||
private static let compactTempDirName = "ndb_compact_temp"
|
||||
|
||||
/// 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"
|
||||
|
||||
/// 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() {
|
||||
UserDefaults.standard.set(true, forKey: compact_on_next_launch_key)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// normal use. The algorithm is:
|
||||
/// 1. Open a temporary `Ndb` instance at the same path to access the LMDB environment.
|
||||
/// 2. Write a compacted copy of the database to a sibling temp directory.
|
||||
/// 3. Close the temporary `Ndb` instance.
|
||||
/// 4. Atomically replace the original `data.mdb` with the compacted copy.
|
||||
/// 5. Remove the temp directory.
|
||||
/// 6. Clear the flag so compaction does not run again on the following launch.
|
||||
///
|
||||
/// - Parameter db_path: Override the database directory path. Pass `nil` (default) to use
|
||||
/// `Ndb.db_path`. Mainly useful for testing.
|
||||
static func compact_if_needed(db_path: String? = nil) {
|
||||
guard UserDefaults.standard.bool(forKey: compact_on_next_launch_key) else { return }
|
||||
|
||||
guard let path = db_path ?? Self.db_path else {
|
||||
Log.error("compact_if_needed: could not determine db path", for: .storage)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
Log.info("Compacting NostrDB on startup…", for: .storage)
|
||||
|
||||
let tempPath = "\(path)/\(compactTempDirName)"
|
||||
|
||||
// Clean up any leftover temp directory from a previously failed attempt.
|
||||
try? FileManager.default.removeItem(atPath: tempPath)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: tempPath, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
Log.error("compact_if_needed: failed to create temp dir: %@", for: .storage, String(describing: error))
|
||||
return
|
||||
}
|
||||
|
||||
// Open a temporary Ndb instance just to drive the compaction.
|
||||
guard let tempNdb = Ndb(path: path) else {
|
||||
Log.error("compact_if_needed: failed to open ndb for compaction", for: .storage)
|
||||
try? FileManager.default.removeItem(atPath: tempPath)
|
||||
return
|
||||
}
|
||||
// Ensure the temporary Ndb is closed regardless of how this function exits.
|
||||
defer { tempNdb.close() }
|
||||
|
||||
do {
|
||||
try tempNdb.compact(to: tempPath)
|
||||
} catch {
|
||||
Log.error("compact_if_needed: compaction failed: %@", for: .storage, String(describing: error))
|
||||
try? FileManager.default.removeItem(atPath: tempPath)
|
||||
return
|
||||
}
|
||||
|
||||
tempNdb.close()
|
||||
|
||||
// Atomically replace the original data.mdb with the compacted copy.
|
||||
let originalDataMdb = URL(fileURLWithPath: "\(path)/\(main_db_file_name)")
|
||||
let compactedDataMdb = URL(fileURLWithPath: "\(tempPath)/\(main_db_file_name)")
|
||||
|
||||
do {
|
||||
_ = try FileManager.default.replaceItemAt(
|
||||
originalDataMdb,
|
||||
withItemAt: compactedDataMdb,
|
||||
backupItemName: nil,
|
||||
options: [.usingNewMetadataOnly]
|
||||
)
|
||||
Log.info("NostrDB compacted successfully", for: .storage)
|
||||
} catch {
|
||||
Log.error("compact_if_needed: failed to replace db file: %@", for: .storage, String(describing: error))
|
||||
try? FileManager.default.removeItem(atPath: tempPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up the temp directory (any remaining files such as lock.mdb).
|
||||
try? FileManager.default.removeItem(atPath: tempPath)
|
||||
|
||||
// Clear the flag so we don't compact again on the next launch.
|
||||
UserDefaults.standard.set(false, forKey: compact_on_next_launch_key)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -241,7 +241,7 @@ class Ndb {
|
||||
}
|
||||
}
|
||||
|
||||
private static func db_file_exists(path: String) -> Bool {
|
||||
static func db_file_exists(path: String) -> Bool {
|
||||
return FileManager.default.fileExists(atPath: "\(path)/\(Self.main_db_file_name)")
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ class Ndb {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// MARK: Thread safety mechanisms
|
||||
// Use these for all externally accessible methods that interact with the nostrdb database to prevent race conditions with app lifecycle events (i.e. NostrDB opening and closing)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user