diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29b..67634c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Recover from corrupted DB during EmbraceStorage init. diff --git a/Package.swift b/Package.swift index 85efc1f7..c78aa54c 100644 --- a/Package.swift +++ b/Package.swift @@ -163,6 +163,9 @@ let package = Package( .testTarget( name: "EmbraceStorageTests", dependencies: ["EmbraceStorage", "TestSupport"], + resources: [ + .copy("Mocks/") + ], plugins: targetPlugins ), diff --git a/Sources/EmbraceStorage/EmbraceStorage.swift b/Sources/EmbraceStorage/EmbraceStorage.swift index 3ab874d5..65242e7a 100644 --- a/Sources/EmbraceStorage/EmbraceStorage.swift +++ b/Sources/EmbraceStorage/EmbraceStorage.swift @@ -3,12 +3,12 @@ // import Foundation +import EmbraceCommon import GRDB /// Class in charge of storing all the data captured by the Embrace SDK. /// It provides an abstraction layer over a GRDB SQLite database. public class EmbraceStorage { - public private(set) var options: Options public private(set) var dbQueue: DatabaseQueue @@ -21,14 +21,14 @@ public class EmbraceStorage { if case let .inMemory(name) = options.storageMechanism { dbQueue = try DatabaseQueue(named: name) - } else if case let .onDisk(baseURL, fileName) = options.storageMechanism { // create base directory if necessary try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) // create sqlite file - let filepath = baseURL.appendingPathComponent(fileName).path - dbQueue = try DatabaseQueue(path: filepath) + let filepath = baseURL.appendingPathComponent(fileName) + + dbQueue = try EmbraceStorage.getDBQueueIfPossible(at: filepath) } else { fatalError("Unsupported storage mechansim added") } @@ -40,6 +40,48 @@ public class EmbraceStorage { try ResourceRecord.defineTable(db: db) } } + + /// Will attempt to create or open the DB File. If first attempt fails due to GRDB error, it'll assume the existing DB is corruped and try again after deleting the existing DB file. + private static func getDBQueueIfPossible(at fileURL: URL) throws -> DatabaseQueue { + do { + return try DatabaseQueue(path: fileURL.path) + } catch { + if let dbError = error as? DatabaseError { + ConsoleLog.error(""" +GRDB Failed to initialize EmbraceStorage. +Will attempt to remove existing file and create a new DB. +Message: \(dbError.message ?? "[empty message]"), +Result Code: \(dbError.resultCode), +SQLite Extended Code: \(dbError.extendedResultCode) +""") + } else { + ConsoleLog.error(""" +Unknown error while trying to initialize EmbraceStorage: \(error) +Will attempt to recover by deleting existing DB. +""") + } + } + + try EmbraceStorage.deleteDBFile(at: fileURL) + + return try DatabaseQueue(path: fileURL.path) + } + + /// Will attempt to delete the provided file. + private static func deleteDBFile(at fileURL: URL) throws { + do { + let fileURL = URL(fileURLWithPath: fileURL.path) + try FileManager.default.removeItem(at: fileURL) + } catch let error { + ConsoleLog.error(""" +EmbraceStorage failed to remove DB file. +Domain: \(error._domain) +Code: \(error._code) +Filepath: \(fileURL) +""") + } + } + } // MARK: - Sync operations diff --git a/Tests/EmbraceStorageTests/EmbraceStorageTests.swift b/Tests/EmbraceStorageTests/EmbraceStorageTests.swift index 315e50b6..823ca3e8 100644 --- a/Tests/EmbraceStorageTests/EmbraceStorageTests.swift +++ b/Tests/EmbraceStorageTests/EmbraceStorageTests.swift @@ -4,6 +4,7 @@ import XCTest import TestSupport +import GRDB @testable import EmbraceStorage class EmbraceStorageTests: XCTestCase { @@ -152,4 +153,58 @@ class EmbraceStorageTests: XCTestCase { wait(for: [expectation2], timeout: .defaultTimeout) } + + func test_corruptedDbAction() { + guard let corruptedDb = EmbraceStorageTests.prepareCorruptedDBForTest() else { + return XCTFail("\(#function): Failed to create corrupted DB for test") + } + + let dbBaseUrl = corruptedDb.deletingLastPathComponent() + let dbFile = corruptedDb.lastPathComponent + + /// Make sure the target DB is corrupted and GRDB returns the expected result when trying to load it. + let corruptedAttempt = Result(catching: { try DatabaseQueue(path: corruptedDb.absoluteString) }) + if case let .failure(error as DatabaseError) = corruptedAttempt { + XCTAssertEqual(error.resultCode, .SQLITE_CORRUPT) + } else { + XCTFail("\(#function): Failed to load a corrupted db for test.") + } + + /// Attempting to create an EmbraceStorage with the corrupted DB should result in a valid storage creation + let storeCreationAttempt = Result(catching: { + try EmbraceStorage(options: .init(baseUrl: dbBaseUrl, fileName: dbFile)) + }) + if case let .failure(error) = storeCreationAttempt { + XCTFail("\(#function): EmbraceStorage failed to recover from corrupted existing DB: \(error)") + } + + /// Then the corrupted DB should've been corrected and now GRDB should be able to load it. + let fixedAttempt = Result(catching: { try DatabaseQueue(path: corruptedDb.absoluteString) }) + if case let .failure(error) = fixedAttempt { + XCTFail("\(#function): DB Is still corrupted after it should've been fixed: \(error)") + } + } + + static func prepareCorruptedDBForTest() -> URL? { + guard + let resourceUrl = Bundle.module.path(forResource: "db_corrupted", ofType: "sqlite", inDirectory: "Mocks"), + let corruptedDbPath = URL(string: "file://\(resourceUrl)") + else { + return nil + } + + let copyCorruptedPath = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("db.sqlite") + + do { + if FileManager.default.fileExists(atPath: copyCorruptedPath.path) { + try FileManager.default.removeItem(at: copyCorruptedPath) + } + try FileManager.default.copyItem(at: corruptedDbPath, to: copyCorruptedPath) + return copyCorruptedPath + } catch let e { + print("\(#function): error creating corrupt db: \(e)") + return nil + } + + } } diff --git a/Tests/EmbraceStorageTests/Mocks/db_corrupted.sqlite b/Tests/EmbraceStorageTests/Mocks/db_corrupted.sqlite new file mode 100644 index 00000000..e205fddf Binary files /dev/null and b/Tests/EmbraceStorageTests/Mocks/db_corrupted.sqlite differ