Ndb.swift (16610B)
1 // 2 // Ndb.swift 3 // damus 4 // 5 // Created by William Casarin on 2023-08-25. 6 // 7 8 import Foundation 9 import OSLog 10 11 fileprivate let APPLICATION_GROUP_IDENTIFIER = "group.com.damus" 12 13 enum NdbSearchOrder { 14 case oldest_first 15 case newest_first 16 } 17 18 19 enum DatabaseError: Error { 20 case failed_open 21 22 var errorDescription: String? { 23 switch self { 24 case .failed_open: 25 return "Failed to open database" 26 } 27 } 28 } 29 30 class Ndb { 31 var ndb: ndb_t 32 let path: String? 33 let owns_db: Bool 34 var generation: Int 35 private var closed: Bool 36 37 var is_closed: Bool { 38 self.closed || self.ndb.ndb == nil 39 } 40 41 static func safemode() -> Ndb? { 42 guard let path = db_path ?? old_db_path else { return nil } 43 44 // delete the database and start fresh 45 if Self.db_files_exist(path: path) { 46 let file_manager = FileManager.default 47 for db_file in db_files { 48 try? file_manager.removeItem(atPath: "\(path)/\(db_file)") 49 } 50 } 51 52 guard let ndb = Ndb(path: path) else { 53 return nil 54 } 55 56 return ndb 57 } 58 59 // NostrDB used to be stored on the app container's document directory 60 static private var old_db_path: String? { 61 guard let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString else { 62 return nil 63 } 64 return remove_file_prefix(path) 65 } 66 67 static var db_path: String? { 68 // Use the `group.com.damus` container, so that it can be accessible from other targets 69 // e.g. The notification service extension needs to access Ndb data, which is done through this shared file container. 70 guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APPLICATION_GROUP_IDENTIFIER) else { 71 return nil 72 } 73 return remove_file_prefix(containerURL.absoluteString) 74 } 75 76 static private var db_files: [String] = ["data.mdb", "lock.mdb"] 77 78 static var empty: Ndb { 79 print("txn: NOSTRDB EMPTY") 80 return Ndb(ndb: ndb_t(ndb: nil)) 81 } 82 83 static func open(path: String? = nil, owns_db_file: Bool = true) -> ndb_t? { 84 var ndb_p: OpaquePointer? = nil 85 86 let ingest_threads: Int32 = 4 87 var mapsize: Int = 1024 * 1024 * 1024 * 32 88 89 if path == nil && owns_db_file { 90 // `nil` path indicates the default path will be used. 91 // The default path changed over time, so migrate the database to the new location if needed 92 do { 93 try Self.migrate_db_location_if_needed() 94 } 95 catch { 96 // If it fails to migrate, the app can still run without serious consequences. Log instead. 97 Log.error("Error migrating NostrDB to new file container", for: .storage) 98 } 99 } 100 101 guard let db_path = Self.db_path, 102 owns_db_file || Self.db_files_exist(path: db_path) else { 103 return nil // If the caller claims to not own the DB file, and the DB files do not exist, then we should not initialize Ndb 104 } 105 106 guard let path = path.map(remove_file_prefix) ?? Ndb.db_path else { 107 return nil 108 } 109 110 let ok = path.withCString { testdir in 111 var ok = false 112 while !ok && mapsize > 1024 * 1024 * 700 { 113 var cfg = ndb_config(flags: 0, ingester_threads: ingest_threads, mapsize: mapsize, filter_context: nil, ingest_filter: nil) 114 ok = ndb_init(&ndb_p, testdir, &cfg) != 0 115 if !ok { 116 mapsize /= 2 117 } 118 } 119 return ok 120 } 121 122 if !ok { 123 return nil 124 } 125 126 return ndb_t(ndb: ndb_p) 127 } 128 129 init?(path: String? = nil, owns_db_file: Bool = true) { 130 guard let db = Self.open(path: path, owns_db_file: owns_db_file) else { 131 return nil 132 } 133 134 self.generation = 0 135 self.path = path 136 self.owns_db = owns_db_file 137 self.ndb = db 138 self.closed = false 139 } 140 141 private static func migrate_db_location_if_needed() throws { 142 guard let old_db_path, let db_path else { 143 throw Errors.cannot_find_db_path 144 } 145 146 let file_manager = FileManager.default 147 148 let old_db_files_exist = Self.db_files_exist(path: old_db_path) 149 let new_db_files_exist = Self.db_files_exist(path: db_path) 150 151 // Migration rules: 152 // 1. If DB files exist in the old path but not the new one, move files to the new path 153 // 2. If files do not exist anywhere, do nothing (let new DB be initialized) 154 // 3. If files exist in the new path, but not the old one, nothing needs to be done 155 // 4. If files exist on both, do nothing. 156 // Scenario 4 likely means that user has downgraded and re-upgraded. 157 // Although it might make sense to get the most recent DB, it might lead to data loss. 158 // If we leave both intact, it makes it easier to fix later, as no data loss would occur. 159 if old_db_files_exist && !new_db_files_exist { 160 Log.info("Migrating NostrDB to new file location…", for: .storage) 161 do { 162 try db_files.forEach { db_file in 163 let old_path = "\(old_db_path)/\(db_file)" 164 let new_path = "\(db_path)/\(db_file)" 165 try file_manager.moveItem(atPath: old_path, toPath: new_path) 166 } 167 Log.info("NostrDB files successfully migrated to the new location", for: .storage) 168 } catch { 169 throw Errors.db_file_migration_error 170 } 171 } 172 } 173 174 private static func db_files_exist(path: String) -> Bool { 175 return db_files.allSatisfy { FileManager.default.fileExists(atPath: "\(path)/\($0)") } 176 } 177 178 init(ndb: ndb_t) { 179 self.ndb = ndb 180 self.generation = 0 181 self.path = nil 182 self.owns_db = true 183 self.closed = false 184 } 185 186 func close() { 187 guard !self.is_closed else { return } 188 self.closed = true 189 print("txn: CLOSING NOSTRDB") 190 ndb_destroy(self.ndb.ndb) 191 self.generation += 1 192 print("txn: NOSTRDB CLOSED") 193 } 194 195 func reopen() -> Bool { 196 guard self.is_closed, 197 let db = Self.open(path: self.path, owns_db_file: self.owns_db) else { 198 return false 199 } 200 201 print("txn: NOSTRDB REOPENED (gen \(generation))") 202 203 self.closed = false 204 self.ndb = db 205 return true 206 } 207 208 func lookup_note_by_key_with_txn<Y>(_ key: NoteKey, txn: NdbTxn<Y>) -> NdbNote? { 209 var size: Int = 0 210 guard let note_p = ndb_get_note_by_key(&txn.txn, key, &size) else { 211 return nil 212 } 213 return NdbNote(note: note_p, size: size, owned: false, key: key) 214 } 215 216 func text_search(query: String, limit: Int = 32, order: NdbSearchOrder = .newest_first) -> [NoteKey] { 217 guard let txn = NdbTxn(ndb: self) else { return [] } 218 var results = ndb_text_search_results() 219 let res = query.withCString { q in 220 let order = order == .newest_first ? NDB_ORDER_DESCENDING : NDB_ORDER_ASCENDING 221 var config = ndb_text_search_config(order: order, limit: Int32(limit)) 222 return ndb_text_search(&txn.txn, q, &results, &config) 223 } 224 225 if res == 0 { 226 return [] 227 } 228 229 var note_ids = [NoteKey]() 230 for i in 0..<results.num_results { 231 // seriously wtf 232 switch i { 233 case 0: note_ids.append(results.results.0.key.note_id) 234 case 1: note_ids.append(results.results.1.key.note_id) 235 case 2: note_ids.append(results.results.2.key.note_id) 236 case 3: note_ids.append(results.results.3.key.note_id) 237 case 4: note_ids.append(results.results.4.key.note_id) 238 case 5: note_ids.append(results.results.5.key.note_id) 239 case 6: note_ids.append(results.results.6.key.note_id) 240 case 7: note_ids.append(results.results.7.key.note_id) 241 case 8: note_ids.append(results.results.8.key.note_id) 242 case 9: note_ids.append(results.results.9.key.note_id) 243 case 10: note_ids.append(results.results.10.key.note_id) 244 case 11: note_ids.append(results.results.11.key.note_id) 245 case 12: note_ids.append(results.results.12.key.note_id) 246 case 13: note_ids.append(results.results.13.key.note_id) 247 case 14: note_ids.append(results.results.14.key.note_id) 248 case 15: note_ids.append(results.results.15.key.note_id) 249 case 16: note_ids.append(results.results.16.key.note_id) 250 case 17: note_ids.append(results.results.17.key.note_id) 251 case 18: note_ids.append(results.results.18.key.note_id) 252 case 19: note_ids.append(results.results.19.key.note_id) 253 case 20: note_ids.append(results.results.20.key.note_id) 254 default: 255 break 256 } 257 } 258 259 return note_ids 260 } 261 262 func lookup_note_by_key(_ key: NoteKey) -> NdbTxn<NdbNote?>? { 263 return NdbTxn(ndb: self) { txn in 264 lookup_note_by_key_with_txn(key, txn: txn) 265 } 266 } 267 268 private func lookup_profile_by_key_inner<Y>(_ key: ProfileKey, txn: NdbTxn<Y>) -> ProfileRecord? { 269 var size: Int = 0 270 guard let profile_p = ndb_get_profile_by_key(&txn.txn, key, &size) else { 271 return nil 272 } 273 274 return profile_flatbuf_to_record(ptr: profile_p, size: size, key: key) 275 } 276 277 private func profile_flatbuf_to_record(ptr: UnsafeMutableRawPointer, size: Int, key: UInt64) -> ProfileRecord? { 278 do { 279 var buf = ByteBuffer(assumingMemoryBound: ptr, capacity: size) 280 let rec: NdbProfileRecord = try getDebugCheckedRoot(byteBuffer: &buf) 281 return ProfileRecord(data: rec, key: key) 282 } catch { 283 // Handle error appropriately 284 print("UNUSUAL: \(error)") 285 return nil 286 } 287 } 288 289 private func lookup_note_with_txn_inner<Y>(id: NoteId, txn: NdbTxn<Y>) -> NdbNote? { 290 return id.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NdbNote? in 291 var key: UInt64 = 0 292 var size: Int = 0 293 guard let baseAddress = ptr.baseAddress, 294 let note_p = ndb_get_note_by_id(&txn.txn, baseAddress, &size, &key) else { 295 return nil 296 } 297 return NdbNote(note: note_p, size: size, owned: false, key: key) 298 } 299 } 300 301 private func lookup_profile_with_txn_inner<Y>(pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileRecord? { 302 return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> ProfileRecord? in 303 var size: Int = 0 304 var key: UInt64 = 0 305 306 guard let baseAddress = ptr.baseAddress, 307 let profile_p = ndb_get_profile_by_pubkey(&txn.txn, baseAddress, &size, &key) 308 else { 309 return nil 310 } 311 312 return profile_flatbuf_to_record(ptr: profile_p, size: size, key: key) 313 } 314 } 315 316 func lookup_profile_by_key_with_txn<Y>(key: ProfileKey, txn: NdbTxn<Y>) -> ProfileRecord? { 317 lookup_profile_by_key_inner(key, txn: txn) 318 } 319 320 func lookup_profile_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? { 321 return NdbTxn(ndb: self) { txn in 322 lookup_profile_by_key_inner(key, txn: txn) 323 } 324 } 325 326 func lookup_note_with_txn<Y>(id: NoteId, txn: NdbTxn<Y>) -> NdbNote? { 327 lookup_note_with_txn_inner(id: id, txn: txn) 328 } 329 330 func lookup_profile_key(_ pubkey: Pubkey) -> ProfileKey? { 331 guard let txn = NdbTxn(ndb: self, with: { txn in 332 lookup_profile_key_with_txn(pubkey, txn: txn) 333 }) else { 334 return nil 335 } 336 337 return txn.value 338 } 339 340 func lookup_profile_key_with_txn<Y>(_ pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileKey? { 341 return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NoteKey? in 342 guard let p = ptr.baseAddress else { return nil } 343 let r = ndb_get_profilekey_by_pubkey(&txn.txn, p) 344 if r == 0 { 345 return nil 346 } 347 return r 348 } 349 } 350 351 func lookup_note_key_with_txn<Y>(_ id: NoteId, txn: NdbTxn<Y>) -> NoteKey? { 352 guard !closed else { return nil } 353 return id.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NoteKey? in 354 guard let p = ptr.baseAddress else { 355 return nil 356 } 357 let r = ndb_get_notekey_by_id(&txn.txn, p) 358 if r == 0 { 359 return nil 360 } 361 return r 362 } 363 } 364 365 func lookup_note_key(_ id: NoteId) -> NoteKey? { 366 guard let txn = NdbTxn(ndb: self, with: { txn in 367 lookup_note_key_with_txn(id, txn: txn) 368 }) else { 369 return nil 370 } 371 372 return txn.value 373 } 374 375 func lookup_note(_ id: NoteId, txn_name: String? = nil) -> NdbTxn<NdbNote?>? { 376 NdbTxn(ndb: self, name: txn_name) { txn in 377 lookup_note_with_txn_inner(id: id, txn: txn) 378 } 379 } 380 381 func lookup_profile(_ pubkey: Pubkey, txn_name: String? = nil) -> NdbTxn<ProfileRecord?>? { 382 NdbTxn(ndb: self, name: txn_name) { txn in 383 lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn) 384 } 385 } 386 387 func lookup_profile_with_txn<Y>(_ pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileRecord? { 388 lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn) 389 } 390 391 func process_client_event(_ str: String) -> Bool { 392 guard !self.is_closed else { return false } 393 return str.withCString { cstr in 394 return ndb_process_client_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 395 } 396 } 397 398 func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) { 399 guard !closed else { return } 400 let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in 401 guard let p = ptr.baseAddress else { return } 402 ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at) 403 } 404 } 405 406 func read_profile_last_fetched<Y>(txn: NdbTxn<Y>, pubkey: Pubkey) -> UInt64? { 407 guard !closed else { return nil } 408 return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> UInt64? in 409 guard let p = ptr.baseAddress else { return nil } 410 let res = ndb_read_last_profile_fetch(&txn.txn, p) 411 if res == 0 { 412 return nil 413 } 414 415 return res 416 } 417 } 418 419 func process_event(_ str: String) -> Bool { 420 guard !is_closed else { return false } 421 return str.withCString { cstr in 422 return ndb_process_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 423 } 424 } 425 426 func process_events(_ str: String) -> Bool { 427 guard !is_closed else { return false } 428 return str.withCString { cstr in 429 return ndb_process_events(ndb.ndb, cstr, str.utf8.count) != 0 430 } 431 } 432 433 func search_profile<Y>(_ search: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] { 434 var pks = Array<Pubkey>() 435 436 return search.withCString { q in 437 var s = ndb_search() 438 guard ndb_search_profile(&txn.txn, &s, q) != 0 else { 439 return pks 440 } 441 442 defer { ndb_search_profile_end(&s) } 443 pks.append(Pubkey(Data(bytes: &s.key.pointee.id.0, count: 32))) 444 445 var n = limit 446 while n > 0 { 447 guard ndb_search_profile_next(&s) != 0 else { 448 return pks 449 } 450 pks.append(Pubkey(Data(bytes: &s.key.pointee.id.0, count: 32))) 451 452 n -= 1 453 } 454 455 return pks 456 } 457 } 458 459 enum Errors: Error { 460 case cannot_find_db_path 461 case db_file_migration_error 462 } 463 464 deinit { 465 print("txn: Ndb de-init") 466 self.close() 467 } 468 } 469 470 #if DEBUG 471 func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) throws -> T { 472 return getRoot(byteBuffer: &byteBuffer) 473 } 474 #else 475 func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) throws -> T { 476 return getRoot(byteBuffer: &byteBuffer) 477 } 478 #endif 479 480 func remove_file_prefix(_ str: String) -> String { 481 return str.replacingOccurrences(of: "file://", with: "") 482 } 483