// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import GRDB @objc public class GRDBDatabaseStorageAdapter: NSObject { // 256 bit key + 128 bit salt public static let kSQLCipherKeySpecLength: UInt = 48 @objc public enum DirectoryMode: Int { case primary case hotswap var folderName: String { switch self { case .primary: return "grdb" case .hotswap: return "grdb-hotswap" } } } @objc public static func databaseDirUrl(baseDir: URL, directoryMode: DirectoryMode = .primary) -> URL { return baseDir.appendingPathComponent(directoryMode.folderName, isDirectory: true) } public static func databaseFileUrl(baseDir: URL, directoryMode: DirectoryMode = .primary) -> URL { let databaseDir = databaseDirUrl(baseDir: baseDir, directoryMode: directoryMode) OWSFileSystem.ensureDirectoryExists(databaseDir.path) return databaseDir.appendingPathComponent("signal.sqlite", isDirectory: false) } public static func databaseWalUrl(baseDir: URL, directoryMode: DirectoryMode = .primary) -> URL { let databaseDir = databaseDirUrl(baseDir: baseDir, directoryMode: directoryMode) OWSFileSystem.ensureDirectoryExists(databaseDir.path) return databaseDir.appendingPathComponent("signal.sqlite-wal", isDirectory: false) } private let databaseUrl: URL private let storage: GRDBStorage public var pool: DatabasePool { return storage.pool } private let checkpointLock = UnfairLock() // The number of writes we can perform until our next checkpoint attempt. // // checkpointBudget should only be accessed while checkpointLock is acquired. private var checkpointBudget: Int = 0 // lastSuccessfulCheckpointDate should only be accessed while checkpointLock is acquired. private var lastSuccessfulCheckpointDate: Date? init(baseDir: URL, directoryMode: DirectoryMode = .primary) { databaseUrl = GRDBDatabaseStorageAdapter.databaseFileUrl(baseDir: baseDir, directoryMode: directoryMode) do { // Crash if keychain is inaccessible. try GRDBDatabaseStorageAdapter.ensureDatabaseKeySpecExists(baseDir: baseDir) } catch { owsFail("\(error.grdbErrorForLogging)") } do { // Crash if storage can't be initialized. storage = try GRDBStorage(dbURL: databaseUrl, keyspec: GRDBDatabaseStorageAdapter.keyspec) } catch { owsFail("\(error.grdbErrorForLogging)") } super.init() AppReadiness.runNowOrWhenAppWillBecomeReady { [weak self] in // This adapter may have been discarded after running // schema migrations. guard let self = self else { return } BenchEventStart(title: "GRDB Setup", eventId: "GRDB Setup") defer { BenchEventComplete(eventId: "GRDB Setup") } do { try self.setup() } catch { owsFail("unable to setup database: \(error)") } } } public func add(function: DatabaseFunction) { pool.add(function: function) } static let tables: [SDSTableMetadata] = [ // Models TSThread.table, TSInteraction.table, StickerPack.table, InstalledSticker.table, KnownStickerPack.table, TSAttachment.table, SSKJobRecord.table, OWSMessageContentJob.table, OWSRecipientIdentity.table, ExperienceUpgrade.table, OWSDisappearingMessagesConfiguration.table, SignalRecipient.table, SignalAccount.table, OWSUserProfile.table, OWSDevice.table, TestModel.table, OWSReaction.table, IncomingGroupsV2MessageJob.table, TSMention.table, TSPaymentModel.table, TSPaymentRequestModel.table, TSGroupMember.table // NOTE: We don't include OWSMessageDecryptJob, // since we should never use it with GRDB. ] static let swiftTables: [TableRecord.Type] = [ ThreadAssociatedData.self, PendingReadReceiptRecord.self, PendingViewedReceiptRecord.self, MediaGalleryRecord.self, MessageSendLog.Payload.self, MessageSendLog.Recipient.self, MessageSendLog.Message.self ] // MARK: - DatabaseChangeObserver @objc public private(set) var databaseChangeObserver: DatabaseChangeObserver? @objc public func setupDatabaseChangeObserver() throws { owsAssertDebug(self.databaseChangeObserver == nil) // DatabaseChangeObserver is a general purpose observer, whose delegates // are notified when things change, but are not given any specific details // about the changes. let databaseChangeObserver = DatabaseChangeObserver() self.databaseChangeObserver = databaseChangeObserver try pool.write { db in db.add(transactionObserver: databaseChangeObserver, extent: Database.TransactionObservationExtent.observerLifetime) } } // NOTE: This should only be used in exceptional circumstances, // e.g. after reloading the database due to a device transfer. func publishUpdatesImmediately() { databaseChangeObserver?.publishUpdatesImmediately() } func testing_tearDownDatabaseChangeObserver() { // DatabaseChangeObserver is a general purpose observer, whose delegates // are notified when things change, but are not given any specific details // about the changes. self.databaseChangeObserver = nil } func setup() throws { MediaGalleryManager.setup(storage: self) try setupDatabaseChangeObserver() } // MARK: - private static let keyServiceName: String = "GRDBKeyChainService" private static let keyName: String = "GRDBDatabaseCipherKeySpec" public static var keyspec: GRDBKeySpecSource { return GRDBKeySpecSource(keyServiceName: keyServiceName, keyName: keyName) } @objc public static var isKeyAccessible: Bool { do { return try keyspec.fetchString().count > 0 } catch { owsFailDebug("Key not accessible: \(error)") return false } } /// Fetches the GRDB key data from the keychain. /// - Note: Will fatally assert if not running in a debug or test build. /// - Returns: The key data, if available. @objc public static var debugOnly_keyData: Data? { owsAssert(OWSIsTestableBuild()) return try? keyspec.fetchData() } @objc public static func ensureDatabaseKeySpecExists(baseDir: URL) throws { do { _ = try keyspec.fetchString() // Key exists and is valid. return } catch { Logger.warn("Key not accessible: \(error)") } // Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, // the keychain will be inaccessible after device restart until // device is unlocked for the first time. If the app receives // a push notification, we won't be able to access the keychain to // process that notification, so we should just terminate by throwing // an uncaught exception. var errorDescription = "CipherKeySpec inaccessible. New install, migration or no unlock since device restart?" if CurrentAppContext().isMainApp { let applicationState = CurrentAppContext().reportedApplicationState errorDescription += ", ApplicationState: \(NSStringForUIApplicationState(applicationState))" } Logger.error(errorDescription) Logger.flush() if CurrentAppContext().isMainApp { if CurrentAppContext().isInBackground() { // Rather than crash here, we should have already detected the situation earlier // and exited gracefully (in the app delegate) using isDatabasePasswordAccessible. // This is a last ditch effort to avoid blowing away the user's database. throw OWSAssertionError(errorDescription) } } else { throw OWSAssertionError("CipherKeySpec inaccessible; not main app.") } // At this point, either: // // * This is a new install so there's no existing password to retrieve. // * The keychain has become corrupt. let databaseUrl = GRDBDatabaseStorageAdapter.databaseFileUrl(baseDir: baseDir) let doesDBExist = FileManager.default.fileExists(atPath: databaseUrl.path) if doesDBExist { owsFail("Could not load database metadata") } keyspec.generateAndStore() } @objc public static func resetAllStorage(baseDir: URL) { Logger.info("") // This might be redundant but in the spirit of thoroughness... GRDBDatabaseStorageAdapter.removeAllFiles(baseDir: baseDir) deleteDBKeys() if CurrentAppContext().isMainApp { TSAttachmentStream.deleteAttachmentsFromDisk() } // TODO: Delete Profiles on Disk? } private static func deleteDBKeys() { do { try keyspec.clear() } catch { owsFailDebug("Could not clear keychain: \(error)") } } static func prepareDatabase(db: Database, keyspec: GRDBKeySpecSource, name: String? = nil) throws { let prefix: String if let name = name, !name.isEmpty { prefix = name + "." } else { prefix = "" } let keyspec = try keyspec.fetchString() try db.execute(sql: "PRAGMA \(prefix)key = \"\(keyspec)\"") try db.execute(sql: "PRAGMA \(prefix)cipher_plaintext_header_size = 32") } } // MARK: - extension GRDBDatabaseStorageAdapter: SDSDatabaseStorageAdapter { #if TESTABLE_BUILD // TODO: We could eventually eliminate all nested transactions. private static let detectNestedTransactions = false // In debug builds, we can detect transactions opened within transaction. // These checks can also be used to detect unexpected "sneaky" transactions. @ThreadBacked(key: "canOpenTransaction", defaultValue: true) public static var canOpenTransaction: Bool #endif // TODO writeThrows flavors public func readThrows(block: (GRDBReadTransaction) throws -> Void) throws { #if TESTABLE_BUILD owsAssertDebug(Self.canOpenTransaction) // Check for nested tractions. if Self.detectNestedTransactions { // Check for nested tractions. Self.canOpenTransaction = false } defer { if Self.detectNestedTransactions { Self.canOpenTransaction = true } } #endif return try pool.read { database in try autoreleasepool { try block(GRDBReadTransaction(database: database)) } } } @discardableResult public func read(block: (GRDBReadTransaction) throws -> T) throws -> T { #if TESTABLE_BUILD owsAssertDebug(Self.canOpenTransaction) // Check for nested tractions. if Self.detectNestedTransactions { // Check for nested tractions. Self.canOpenTransaction = false } defer { if Self.detectNestedTransactions { Self.canOpenTransaction = true } } #endif return try pool.read { database in try autoreleasepool { try block(GRDBReadTransaction(database: database)) } } } @discardableResult public func write(block: (GRDBWriteTransaction) throws -> T) throws -> T { var value: T! var thrown: Error? try write { (transaction) in do { value = try block(transaction) } catch { thrown = error } } if let error = thrown { throw error.grdbErrorForLogging } return value } @objc public func read(block: (GRDBReadTransaction) -> Void) throws { #if TESTABLE_BUILD owsAssertDebug(Self.canOpenTransaction) if Self.detectNestedTransactions { // Check for nested tractions. Self.canOpenTransaction = false } defer { if Self.detectNestedTransactions { Self.canOpenTransaction = true } } #endif try pool.read { database in autoreleasepool { block(GRDBReadTransaction(database: database)) } } } @objc public func write(block: (GRDBWriteTransaction) -> Void) throws { #if TESTABLE_BUILD owsAssertDebug(Self.canOpenTransaction) // Check for nested tractions. if Self.detectNestedTransactions { // Check for nested tractions. Self.canOpenTransaction = false } defer { if Self.detectNestedTransactions { Self.canOpenTransaction = true } } #endif var syncCompletions: [GRDBWriteTransaction.CompletionBlock] = [] var asyncCompletions: [GRDBWriteTransaction.AsyncCompletion] = [] try pool.write { database in autoreleasepool { let transaction = GRDBWriteTransaction(database: database) block(transaction) transaction.finalizeTransaction() syncCompletions = transaction.syncCompletions asyncCompletions = transaction.asyncCompletions } } checkpointLock.withLock { checkpointIfNecessary() } // Perform all completions _after_ the write transaction completes. for block in syncCompletions { block() } for asyncCompletion in asyncCompletions { asyncCompletion.queue.async(execute: asyncCompletion.block) } } // This method should only be invoked with checkpointLock already acquired. private func checkpointIfNecessary() { // What Is Checkpointing? // // Checkpointing is the process of integrating the WAL into the main database file. // Without it, the WAL will grow indefinitely. A large WAL affects read performance. // Therefore we want to keep the WAL small. // // * The SQLite WAL consists of "frames", representing changes to the database. // * Frames are appended to the tail of the WAL. // * The WAL tracks how many of its frames have been integrated into the database. // * Checkpointing entails some subset of the following tasks: // * Integrating some or all of the frames of the WAL into the database. // * "Restarting" the WAL so the next frame is written to the head of the WAL // file, not the tail. WAL file size doesn't change, but since subsequent writes // overwrite from the start of the WAL, WAL file size growth can be bounded. // * "Truncating" the WAL so that that the WAL file is deleted or returned to // an empty state. // // The more unintegrated frames there are in the WAL, the longer a checkpoint takes // to complete. Long-running checkpoints can cause problems in the app, e.g. // blocking the main thread (note: we currently do _NOT_ checkpoint on the main // thread). Therefore we want to bound overall WAL file size _and_ the number of // unintegrated frames. // // To bound WAL file size, it's important to periodically "restart" or (preferably) // truncate the WAL file. We currently always truncate. // // To bound the number of unintegrated frames, we can use passive checkpoints. // We don't explicitly initiate passive checkpoints, but leave this to SQLite // auto-checkpointing. // // // Checkpoint Types // // Checkpointing has several flavors: passive, full, restart, truncate. // // * Passive checkpoints abort immediately if there are any database // readers or writers. This makes them "cheap" in the sense that // they won't block for long. // However they only integrate WAL contents, they don't "restart" or // "truncate" so they don't inherently limit WAL growth. // My understanding is that they can have partial success, e.g. // integrating some but not all of the frames of the WAL. This is // beneficial. // * Full/Restart/Truncate checkpoints will block using the busy-handler. // We use truncate checkpoints since they truncate the WAL file. // See GRDBStorage.buildConfiguration for our busy-handler (aka busyMode // callback). It aborts after ~50ms. // These checkpoints are more expensive and will block while they do // their work but will limit WAL growth. // // SQLite has auto-checkpointing enabled by default, meaning that it // is continually trying to perform passive checkpoints in the background. // This is beneficial. // // // Exclusion // // Note that we are navigating multiple exclusion mechanisms. // // * SQLite (as we have configured it) excludes database writes using // write locks (POSIX advisory locking on the database files). // This locking protects the database from cross-process writes. // * GRDB writers use a serial DispatchQueue to exclude writes from // each other within a given DatabasePool / DatabaseQueue. // AFAIK this does not protect any GRDB internal state; it allows // GRDB to detect re-entrancy, etc. // // SQLite cannot checkpoint if there are any readers or writers. // Therefore we cannot checkpoint within a SQLite write transaction. // We checkpoint after write transactions using // DatabasePool.writeWithoutTransaction(). This method uses the // GRDB exclusion mechanism but not the SQL one. // // // Our approach: // // * Always (not including auto-checkpointing) use truncate checkpoints // to limit WAL size. // * Only checkpoint immediately after writes. // * It's expensive and unnecessary to do a checkpoint on every write, // so we only checkpoint once every N writes. We always checkpoint after // the first write. Large (in terms of file size) writes should be rare, // so WAL file size should be bounded and quite small. // * Use a "budget" to tracking the urgency of trying to perform a checkpoint after // the next write. When the budget reaches zero, we should try after the next // write. Successes bump up the budget considerably, failures bump it up a little. // * Retry more often after failures, via the budget. // // // What could go wrong: // // * Our busy-handler (aka busyMode callback) is untested. Previously it was // irrelevant because we always performed checkpoints on a separate // DatabaseQueue. // It needs to work correctly to ensure that checkpoints timeout if there's // heavy contention (reads or writes). // * Checkpointing could be expensive in some cases, causing blocking. // This shouldn't be an issue: we're more aggressive than ever about // keeping the WAL small. // * Cross-process activity could interfere with checkpointing. // This shouldn't be an issue: We shouldn't have more than one of // the apps (main app, SAE, NSE) active at the same time for long. // * Checkpoints might frequently fail if we're constantly doing reads. // This shouldn't be an issue: A checkpoint should eventually // succeed when db activity settles. This checkpoint might take a while // but that's unavoidable. // The counter-argument is that we only try to checkpoint immediately after // a write. We often do reads immediately after writes to update the UI // to reflect the DB changes. Those reads _might_ frequently interfere // with checkpointing. // * We might not be checkpointing often enough, or we might be checkpointing // too often. Either way, it's about balancing overall perf with the perf // cost of the next successful checkpoint. We can tune this behavior // using the "checkpoint budget". // // Reference // // * https://www.sqlite.org/c3ref/wal_checkpoint_v2.html // * https://www.sqlite.org/wal.html // * https://www.sqlite.org/howtocorrupt.html // guard !Thread.isMainThread else { // To avoid blocking the main thread, we avoid doing "truncate" checkpoints // on the main thread. We perhaps could do passive checkpoints on the main // thread, which abort if there is any contention. // // We decrement the checkpoint budget anyway. checkpointBudget -= 1 return } var shouldCheckpoint = checkpointBudget <= 0 // Limit checkpoint frequency by time so that heavy write activity // won't bog down the main thread. let maxCheckpointFrequency: TimeInterval = 0.25 if shouldCheckpoint, let lastSuccessfulCheckpointDate = self.lastSuccessfulCheckpointDate, abs(lastSuccessfulCheckpointDate.timeIntervalSinceNow) < maxCheckpointFrequency { Logger.verbose("Skipping checkpoint due to frequency.") shouldCheckpoint = false } guard shouldCheckpoint else { // We decrement the checkpoint budget. checkpointBudget -= 1 return } // Set isCheckpointing flag. owsAssertDebug(!GRDBStorage.isCheckpointing) GRDBStorage.isCheckpointing = true owsAssertDebug(GRDBStorage.isCheckpointing) defer { // Clear isCheckpointing flag. owsAssertDebug(GRDBStorage.isCheckpointing) GRDBStorage.isCheckpointing = false owsAssertDebug(!GRDBStorage.isCheckpointing) } pool.writeWithoutTransaction { database in let kind: Database.CheckpointMode = .truncate var walSizePages: Int32 = 0 var pagesCheckpointed: Int32 = 0 var code: Int32 = 0 Bench(title: "Checkpoint", logIfLongerThan: TimeInterval(5) / TimeInterval(1000), logInProduction: true) { code = sqlite3_wal_checkpoint_v2(database.sqliteConnection, nil, kind.rawValue, &walSizePages, &pagesCheckpointed) } if code != SQLITE_OK { // Extracting this error message can race. let errorMessage = String(cString: sqlite3_errmsg(database.sqliteConnection)) if code == SQLITE_BUSY { // It is expected that the busy-handler (aka busyMode callback) // will abort checkpoints if there is contention. Logger.warn("Error code: \(code), errorMessage: \(errorMessage).") } else { owsFailDebug("Error code: \(code), errorMessage: \(errorMessage).") } // If the checkpoint failed, try again soon. checkpointBudget += 5 } else { let pageSize: Int32 = 4 * 1024 let walFileSizeBytes = walSizePages * pageSize let maxWalFileSizeBytes = 4 * 1024 * 1024 if walFileSizeBytes > maxWalFileSizeBytes { Logger.info("walFileSizeBytes: \(walFileSizeBytes).") Logger.info("walSizePages: \(walSizePages), pagesCheckpointed: \(pagesCheckpointed).") } else { Logger.verbose("walSizePages: \(walSizePages), pagesCheckpointed: \(pagesCheckpointed).") } // If the checkpoint succeeded, wait N writes before performing another checkpoint. checkpointBudget += 32 lastSuccessfulCheckpointDate = Date() } } } } // MARK: - func filterForDBQueryLog(_ input: String) -> String { var result = input while let matchRange = result.range(of: "x'[0-9a-f\n]*'", options: .regularExpression) { let charCount = result.distance(from: matchRange.lowerBound, to: matchRange.upperBound) let byteCount = Int64(charCount) / 2 let formattedByteCount = ByteCountFormatter.string(fromByteCount: byteCount, countStyle: .memory) result = result.replacingCharacters(in: matchRange, with: "x'<\(formattedByteCount)>'") } return result } private func dbQueryLog(_ value: String) { guard SDSDatabaseStorage.shouldLogDBQueries else { return } Logger.info(filterForDBQueryLog(value)) } // MARK: - private struct GRDBStorage { let pool: DatabasePool private let dbURL: URL private let poolConfiguration: Configuration fileprivate static let maxBusyTimeoutMs = 50 init(dbURL: URL, keyspec: GRDBKeySpecSource) throws { self.dbURL = dbURL self.poolConfiguration = Self.buildConfiguration(keyspec: keyspec) self.pool = try Self.buildPool(dbURL: dbURL, poolConfiguration: poolConfiguration) Logger.debug("dbURL: \(dbURL)") OWSFileSystem.protectFileOrFolder(atPath: dbURL.path) } // See: https://github.com/groue/GRDB.swift/blob/master/Documentation/SharingADatabase.md private static func buildPool(dbURL: URL, poolConfiguration: Configuration) throws -> DatabasePool { let coordinator = NSFileCoordinator(filePresenter: nil) var coordinatorError: NSError? var newPool: DatabasePool? var dbError: Error? coordinator.coordinate(writingItemAt: dbURL, options: .forMerging, error: &coordinatorError, byAccessor: { url in do { newPool = try DatabasePool(path: url.path, configuration: poolConfiguration) } catch { dbError = error } }) if let error = dbError ?? coordinatorError { throw error } guard let pool = newPool else { throw OWSAssertionError("Missing pool.") } return pool } // The isCheckpointing flag is backed by a thread local. // We don't want to affect the behavior of the busy-handler (aka busyMode callback) // in other threads while checkpointing. fileprivate static let isCheckpointingKey = "GRDBStorage.isCheckpointingKey" fileprivate static var isCheckpointing: Bool { get { Thread.current.threadDictionary[Self.isCheckpointingKey] as? Bool == true } set { Thread.current.threadDictionary[Self.isCheckpointingKey] = newValue } } private static func buildConfiguration(keyspec: GRDBKeySpecSource) -> Configuration { var configuration = Configuration() configuration.readonly = false configuration.foreignKeysEnabled = true // Default is already true configuration.trace = { logString in dbQueryLog(logString) } // Useful when your app opens multiple databases configuration.label = "GRDB Storage" configuration.maximumReaderCount = 10 // The default is 5 configuration.busyMode = .callback({ (retryCount: Int) -> Bool in // sleep N milliseconds let millis = 25 usleep(useconds_t(millis * 1000)) Logger.verbose("retryCount: \(retryCount)") let accumulatedWaitMs = millis * (retryCount + 1) if accumulatedWaitMs > 0, (accumulatedWaitMs % 250) == 0 { Logger.warn("Database busy for \(accumulatedWaitMs)ms") } // Only time out during checkpoints, not writes. if isCheckpointing { if accumulatedWaitMs > GRDBStorage.maxBusyTimeoutMs { Logger.warn("Aborting busy retry.") return false } return true } else { return true } }) configuration.prepareDatabase = { db in try GRDBDatabaseStorageAdapter.prepareDatabase(db: db, keyspec: keyspec) } configuration.defaultTransactionKind = .immediate configuration.allowsUnsafeTransactions = true return configuration } } // MARK: - public struct GRDBKeySpecSource { private var kSQLCipherKeySpecLength: UInt { GRDBDatabaseStorageAdapter.kSQLCipherKeySpecLength } let keyServiceName: String let keyName: String func fetchString() throws -> String { // Use a raw key spec, where the 96 hexadecimal digits are provided // (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt) // using explicit BLOB syntax, e.g.: // // x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101' let data = try fetchData() guard data.count == kSQLCipherKeySpecLength else { owsFail("unexpected keyspec length") } let passphrase = "x'\(data.hexadecimalString)'" return passphrase } public func fetchData() throws -> Data { return try CurrentAppContext().keychainStorage().data(forService: keyServiceName, key: keyName) } func clear() throws { Logger.info("") try CurrentAppContext().keychainStorage().remove(service: keyServiceName, key: keyName) } func generateAndStore() { Logger.info("") do { let keyData = Randomness.generateRandomBytes(Int32(kSQLCipherKeySpecLength)) try store(data: keyData) } catch { owsFail("Could not generate key for GRDB: \(error)") } } public func store(data: Data) throws { guard data.count == kSQLCipherKeySpecLength else { owsFail("unexpected keyspec length") } try CurrentAppContext().keychainStorage().set(data: data, service: keyServiceName, key: keyName) } } // MARK: - extension GRDBDatabaseStorageAdapter { public var databaseFilePath: String { return databaseUrl.path } public var databaseWALFilePath: String { return databaseUrl.path + "-wal" } public var databaseSHMFilePath: String { return databaseUrl.path + "-shm" } static func removeAllFiles(baseDir: URL) { let databaseUrl = GRDBDatabaseStorageAdapter.databaseFileUrl(baseDir: baseDir) OWSFileSystem.deleteFileIfExists(databaseUrl.path) OWSFileSystem.deleteFileIfExists(databaseUrl.path + "-wal") OWSFileSystem.deleteFileIfExists(databaseUrl.path + "-shm") } } // MARK: - Reporting extension GRDBDatabaseStorageAdapter { var databaseFileSize: UInt64 { guard let fileSize = OWSFileSystem.fileSize(ofPath: databaseFilePath) else { owsFailDebug("Could not determine file size.") return 0 } return fileSize.uint64Value } var databaseWALFileSize: UInt64 { guard let fileSize = OWSFileSystem.fileSize(ofPath: databaseWALFilePath) else { owsFailDebug("Could not determine file size.") return 0 } return fileSize.uint64Value } var databaseSHMFileSize: UInt64 { guard let fileSize = OWSFileSystem.fileSize(ofPath: databaseSHMFilePath) else { owsFailDebug("Could not determine file size.") return 0 } return fileSize.uint64Value } } // MARK: - Checkpoints public struct GrdbTruncationResult { let walSizePages: Int32 let pagesCheckpointed: Int32 } extension GRDBDatabaseStorageAdapter { @objc public func syncTruncatingCheckpoint() throws { Logger.info("Running truncating checkpoint.") SDSDatabaseStorage.shared.logFileSizes() let result = try GRDBDatabaseStorageAdapter.checkpoint(pool: pool, mode: .truncate) Logger.info("walSizePages: \(result.walSizePages), pagesCheckpointed: \(result.pagesCheckpointed)") SDSDatabaseStorage.shared.logFileSizes() } public static func checkpoint(pool: DatabasePool, mode: Database.CheckpointMode) throws -> GrdbTruncationResult { var walSizePages: Int32 = 0 var pagesCheckpointed: Int32 = 0 try Bench(title: "Slow checkpoint: \(mode)", logIfLongerThan: 0.01, logInProduction: true) { #if TESTABLE_BUILD let startTime = CACurrentMediaTime() #endif try pool.writeWithoutTransaction { db in #if TESTABLE_BUILD let startElapsedSeconds: TimeInterval = CACurrentMediaTime() - startTime let slowStartSeconds: TimeInterval = TimeInterval(GRDBStorage.maxBusyTimeoutMs) / 1000 if startElapsedSeconds > slowStartSeconds * 2 { // maxBusyTimeoutMs isn't a hard limit, but slow starts should be very rare. let formattedTime = String(format: "%0.2fms", startElapsedSeconds * 1000) owsFailDebug("Slow checkpoint start: \(formattedTime)") } #endif let code = sqlite3_wal_checkpoint_v2(db.sqliteConnection, nil, mode.rawValue, &walSizePages, &pagesCheckpointed) switch code { case SQLITE_OK: if mode != .passive { Logger.info("Checkpoint succeeded: \(mode).") } break case SQLITE_BUSY: // Busy is not an error. Logger.info("Checkpoint \(mode) failed due to busy.") break default: throw OWSAssertionError("checkpoint sql error with code: \(code)") } } } return GrdbTruncationResult(walSizePages: walSizePages, pagesCheckpointed: pagesCheckpointed) } } // MARK: - public extension Error { var grdbErrorForLogging: Error { // If not a GRDB error, return unmodified. guard let grdbError = self as? GRDB.DatabaseError else { return self } // DatabaseError.description includes the arguments. Logger.verbose("grdbError: \(grdbError))") // DatabaseError.description does not include the extendedResultCode. Logger.verbose("resultCode: \(grdbError.resultCode), extendedResultCode: \(grdbError.extendedResultCode), message: \(String(describing: grdbError.message)), sql: \(String(describing: grdbError.sql))") let error = GRDB.DatabaseError(resultCode: grdbError.extendedResultCode, message: grdbError.message, sql: nil, arguments: nil) return error } }