On iOS, you can share database files between multiple processes by storing them in an App Group Container.
A shared database is accessed from several SQLite connections, from several processes. This creates challenges at various levels:
- Database setup may be attempted by multiple processes, concurrently.
- SQLite may throw
SQLITE_BUSY
errors, code 5, "database is locked". - iOS may kill your application with a
0xDEAD10CC
exception. - GRDB database observation misses changes performed by external processes.
We'll address all of those challenges below.
- Use a Database Pool
- How to limit the
SQLITE_BUSY
error - How to limit the
0xDEAD10CC
exception - How to perform cross-process database observation
In order to access a shared database, use a Database Pool. It opens the database in the WAL mode, which helps sharing a database.
Since several processes may open the database at the same time, protect the creation of the database pool with an NSFileCoordinator.
-
In a process that can create and write in the database, use this sample code:
/// Returns an initialized database pool at the shared location databaseURL func openSharedDatabase(at databaseURL: URL) throws -> DatabasePool { let coordinator = NSFileCoordinator(filePresenter: nil) var coordinatorError: NSError? var dbPool: DatabasePool? var dbError: Error? coordinator.coordinate(writingItemAt: databaseURL, options: .forMerging, error: &coordinatorError, byAccessor: { url in do { dbPool = try openDatabase(at: url) } catch { dbError = error } }) if let error = dbError ?? coordinatorError { throw error } return dbPool! } private func openDatabase(at databaseURL: URL) throws -> DatabasePool { let dbPool = try DatabasePool(path: databaseURL.path) // Perform here other database setups, such as defining // the database schema with a DatabaseMigrator. try migrator.migrate(dbPool) return dbPool }
-
In a process that only reads in the database, use this sample code:
/// Returns an initialized database pool at the shared location databaseURL, /// or nil if the database is not created yet, or does not have the required /// schema version. func openSharedReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { let coordinator = NSFileCoordinator(filePresenter: nil) var coordinatorError: NSError? var dbPool: DatabasePool? var dbError: Error? coordinator.coordinate(readingItemAt: databaseURL, options: .withoutChanges, error: &coordinatorError, byAccessor: { url in do { dbPool = try openReadOnlyDatabase(at: url) } catch { dbError = error } }) if let error = dbError ?? coordinatorError { throw error } return dbPool } private func openReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { do { var configuration = Configuration() configuration.readonly = true let dbPool = try DatabasePool(path: databaseURL.path, configuration: configuration) // Check here if the database schema is correct, for example // with a DatabaseMigrator. if try dbPool.read(migrator.hasCompletedMigrations) { return dbPool } else { return nil } } catch { if FileManager.default.fileExists(atPath: databaseURL.path) { throw error } else { return nil } } }
The SQLITE_BUSY result code indicates that the database file could not be written (or in some cases read) because of concurrent activity by some other database connection, usually a database connection in a separate process.
See https://www.sqlite.org/rescode.html#busy for more information about this error.
If several processes want to write in the database, configure the database pool of each process that wants to write:
var configuration = Configuration()
configuration.busyMode = .timeout(/* a TimeInterval */)
configuration.defaultTransactionKind = .immediate
let dbPool = try DatabasePool(path: ..., configuration: configuration)
With such a setup, you may still get SQLITE_BUSY
(5, "database is locked") errors from all write operations. They will occur if the database remains locked by another process for longer than the specified timeout.
do {
try dbPool.write { db in ... }
} catch let error as DatabaseError where error.resultCode == .SQLITE_BUSY {
// Another process won't let you write. Deal with it.
}
💡 Tip: In order to be nice to other processes, measure the duration of your longest writes, and attempt at optimizing the ones that last for too long.
The exception code 0xDEAD10CC indicates that an application has been terminated by the OS because it held on to a file lock or sqlite database lock during suspension.
See https://developer.apple.com/library/archive/technotes/tn2151/_index.html for more information about this exception.
-
If you use SQLCipher, use SQLCipher 4+, and call the
cipher_plaintext_header_size
pragma from your database preparation function:var configuration = Configuration() configuration.prepareDatabase = { (db: Database) in try db.usePassphrase("secret") try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") } let dbPool = try DatabasePool(path: ..., configuration: configuration)
This will avoid sqlcipher/sqlcipher#255.
-
🔥 EXPERIMENTAL In each process that wants to write in the database:
Set the
observesSuspensionNotifications
configuration flag:var configuration = Configuration() configuration.suspendsOnBackgroundTimeExpiration = true let dbPool = try DatabasePool(path: ..., configuration: configuration)
Post
Database.suspendNotification
when the application is about to be suspended. You can for example post this notification fromUIApplicationDelegate.applicationDidEnterBackground(_:)
, or in the expiration handler of a background task.@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func applicationDidEnterBackground(_ application: UIApplication) { // Suspend databases NotificationCenter.default.post(name: Database.suspendNotification, object: self) } }
Post
Database.resumeNotification
fromUIApplicationDelegate.applicationWillEnterForeground(_:)
(orSceneDelegate.sceneWillEnterForeground(_:)
for scene-based applications):@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func applicationWillEnterForeground(_ application: UIApplication) { // Resume databases NotificationCenter.default.post(name: Database.resumeNotification, object: self) } }
If the application uses the background modes supported by iOS, post
Database.resumeNotification
method from each and every background mode callback that may use the database. For example, if your application supports background fetches:@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { // Resume databases NotificationCenter.default.post(name: Database.resumeNotification, object: self) // Proceed with background fetch ... } }
Suspended databases greatly reduce the odds of
0xDEAD10CC
exception are greatly reduced. If you see one in your crash logs, please open an issue!In exchange, you will get
SQLITE_INTERRUPT
(9) orSQLITE_ABORT
(4) errors, with messages "Database is suspended", "Transaction was aborted", or "interrupted", for any attempt at writing in the database when it is suspended.You can catch those errors:
do { try dbPool.write { db in ... } } catch let error as DatabaseError where error.isInterruptionError { // Oops, the database is suspended. // Maybe try again after database is resumed? }
GRDB Database Observation features, as well as GRDBCombine and RxGRDB, are not able to notify database changes performed by other processes.
Whenever you need to notify other processes that the database has been changed, you will have to use a cross-process notification mechanism such as NSFileCoordinator or CFNotificationCenterGetDarwinNotifyCenter.
You can trigger those notifications automatically with DatabaseRegionObservation:
// Notify all changes made to the "player" and "team" database tables
let observation = DatabaseRegionObservation(tracking: Player.all(), Team.all())
let observer = try observation.start(in: dbPool) { (db: Database) in
// Notify other processes
}
// Notify all changes made to the database
let observation = DatabaseRegionObservation(tracking: DatabaseRegion.fullDatabase)
let observer = try observation.start(in: dbPool) { (db: Database) in
// Notify other processes
}