|
| 1 | +Sharing a Datatase in an App Group Container |
| 2 | +============================================ |
| 3 | + |
| 4 | +On iOS, you can share database files between multiple processes by storing them in an [App Group Container](https://developer.apple.com/documentation/foundation/nsfilemanager/1412643-containerurlforsecurityapplicati). |
| 5 | + |
| 6 | +A shared database is accessed from several SQLite connections, from several processes. This creates challenges at various levels: |
| 7 | + |
| 8 | +1. **Database setup** may be attempted by multiple processes, concurrently. |
| 9 | +2. **SQLite** may throw `SQLITE_BUSY` errors, code 5, "database is locked". |
| 10 | +3. **iOS** may kill your application with a `0xDEAD10CC` exception. |
| 11 | +4. **GRDB** database observation misses changes performed by external processes. |
| 12 | + |
| 13 | +We'll address all of those challenges below. |
| 14 | + |
| 15 | +- [Use a Database Pool] |
| 16 | +- [How to limit the `SQLITE_BUSY` error] |
| 17 | +- [How to limit the `0xDEAD10CC` exception] |
| 18 | +- [How to perform cross-process database observation] |
| 19 | + |
| 20 | + |
| 21 | +## Use a Database Pool |
| 22 | + |
| 23 | +In order to access a shared database, use a [Database Pool]. It opens the database in the [WAL mode](https://www.sqlite.org/wal.html), which helps sharing a database. |
| 24 | + |
| 25 | +Since several processes may open the database at the same time, protect the creation of the database pool with an [NSFileCoordinator]. |
| 26 | + |
| 27 | +- In a process that can create and write in the database, use this sample code: |
| 28 | + |
| 29 | + ```swift |
| 30 | + /// Returns an initialized database pool at the shared location databaseURL |
| 31 | + func openSharedDatabase(at databaseURL: URL) throws -> DatabasePool { |
| 32 | + let coordinator = NSFileCoordinator(filePresenter: nil) |
| 33 | + var coordinatorError: NSError? |
| 34 | + var dbPool: DatabasePool? |
| 35 | + var dbError: Error? |
| 36 | + coordinator.coordinate(writingItemAt: databaseURL, options: .forMerging, error: &coordinatorError, byAccessor: { url in |
| 37 | + do { |
| 38 | + dbPool = try openDatabase(at: url) |
| 39 | + } catch { |
| 40 | + dbError = error |
| 41 | + } |
| 42 | + }) |
| 43 | + if let error = dbError ?? coordinatorError { |
| 44 | + throw error |
| 45 | + } |
| 46 | + return dbPool! |
| 47 | + } |
| 48 | + |
| 49 | + private func openDatabase(at databaseURL: URL) throws -> DatabasePool { |
| 50 | + let dbPool = try DatabasePool(path: databaseURL.path) |
| 51 | + // Perform here other database setups, such as defining |
| 52 | + // the database schema with a DatabaseMigrator. |
| 53 | + return dbPool |
| 54 | + } |
| 55 | + ``` |
| 56 | + |
| 57 | +- In a process that only reads in the database, use this sample code: |
| 58 | + |
| 59 | + ```swift |
| 60 | + /// Returns an initialized database pool at the shared location databaseURL, |
| 61 | + /// or nil if the database was not created yet. |
| 62 | + func openSharedReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { |
| 63 | + let coordinator = NSFileCoordinator(filePresenter: nil) |
| 64 | + var coordinatorError: NSError? |
| 65 | + var dbPool: DatabasePool? |
| 66 | + var dbError: Error? |
| 67 | + coordinator.coordinate(readingItemAt: databaseURL, options: .withoutChanges, error: &coordinatorError, byAccessor: { url in |
| 68 | + do { |
| 69 | + dbPool = try openReadOnlyDatabase(at: url) |
| 70 | + } catch { |
| 71 | + dbError = error |
| 72 | + } |
| 73 | + }) |
| 74 | + if let error = dbError ?? coordinatorError { |
| 75 | + throw error |
| 76 | + } |
| 77 | + return dbPool |
| 78 | + } |
| 79 | + |
| 80 | + private func openReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { |
| 81 | + do { |
| 82 | + var configuration = Configuration() |
| 83 | + configuration.readonly = true |
| 84 | + return try DatabasePool(path: databaseURL.path, configuration: configuration) |
| 85 | + } catch { |
| 86 | + if FileManager.default.fileExists(atPath: databaseURL.path) { |
| 87 | + throw error |
| 88 | + } else { |
| 89 | + return nil |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + ``` |
| 94 | + |
| 95 | + |
| 96 | +## How to limit the `SQLITE_BUSY` error |
| 97 | + |
| 98 | +> 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. |
| 99 | + |
| 100 | +See https://www.sqlite.org/rescode.html#busy for more information about this error. |
| 101 | + |
| 102 | +If several processes want to write in the database, configure the database pool of each process that wants to write: |
| 103 | + |
| 104 | +```swift |
| 105 | +var configuration = Configuration() |
| 106 | +configuration.busyMode = .timeout(/* a TimeInterval */) |
| 107 | +configuration.defaultTransactionKind = .immediate |
| 108 | +let dbPool = try DatabasePool(path: ..., configuration: configuration) |
| 109 | +``` |
| 110 | + |
| 111 | +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. |
| 112 | + |
| 113 | +```swift |
| 114 | +do { |
| 115 | + try dbPool.write { db in ... } |
| 116 | +} catch let error as DatabaseError where error.resultCode == .SQLITE_BUSY { |
| 117 | + // Another process won't let you write. Deal with it. |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +> :bulb: **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. |
| 122 | +
|
| 123 | + |
| 124 | +## How to limit the `0xDEAD10CC` exception |
| 125 | + |
| 126 | +> 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. |
| 127 | +
|
| 128 | +See https://developer.apple.com/library/archive/technotes/tn2151/_index.html for more information about this exception. |
| 129 | + |
| 130 | +1. If you use SQLCipher, use SQLCipher 4+, and call the `cipher_plaintext_header_size` pragma from your database preparation function: |
| 131 | + |
| 132 | + ```swift |
| 133 | + var configuration = Configuration() |
| 134 | + configuration.prepareDatabase = { (db: Database) in |
| 135 | + try db.usePassphrase("secret") |
| 136 | + try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") |
| 137 | + } |
| 138 | + let dbPool = try DatabasePool(path: ..., configuration: configuration) |
| 139 | + ``` |
| 140 | + |
| 141 | + This will avoid https://github.com/sqlcipher/sqlcipher/issues/255. |
| 142 | + |
| 143 | +2. [**:fire: EXPERIMENTAL**](README.md#what-are-experimental-features) In each process that wants to write in the database: |
| 144 | + |
| 145 | + Set the `observesSuspensionNotifications` configuration flag: |
| 146 | + |
| 147 | + ```swift |
| 148 | + var configuration = Configuration() |
| 149 | + configuration.suspendsOnBackgroundTimeExpiration = true |
| 150 | + let dbPool = try DatabasePool(path: ..., configuration: configuration) |
| 151 | + ``` |
| 152 | + |
| 153 | + Post `Database.suspendNotification` when the application is about to be [suspended](https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle). You can for example post this notification from `UIApplicationDelegate.applicationDidEnterBackground(_:)`, or in the expiration handler of a [background task](https://forums.developer.apple.com/thread/85066). |
| 154 | + |
| 155 | + ```swift |
| 156 | + @UIApplicationMain |
| 157 | + class AppDelegate: UIResponder, UIApplicationDelegate { |
| 158 | + func applicationDidEnterBackground(_ application: UIApplication) { |
| 159 | + // Suspend databases |
| 160 | + NotificationCenter.default.post(name: Database.suspendNotification, object: self) |
| 161 | + } |
| 162 | + } |
| 163 | + ``` |
| 164 | + |
| 165 | + Post `Database.resumeNotification` from `UIApplicationDelegate.applicationWillEnterForeground(_:)` (or `SceneDelegate.sceneWillEnterForeground(_:)` for scene-based applications): |
| 166 | + |
| 167 | + ```swift |
| 168 | + @UIApplicationMain |
| 169 | + class AppDelegate: UIResponder, UIApplicationDelegate { |
| 170 | + func applicationWillEnterForeground(_ application: UIApplication) { |
| 171 | + // Resume databases |
| 172 | + NotificationCenter.default.post(name: Database.resumeNotification, object: self) |
| 173 | + } |
| 174 | + } |
| 175 | + ``` |
| 176 | + |
| 177 | + 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: |
| 178 | + |
| 179 | + ```swift |
| 180 | + @UIApplicationMain |
| 181 | + class AppDelegate: UIResponder, UIApplicationDelegate { |
| 182 | + func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { |
| 183 | + // Resume databases |
| 184 | + NotificationCenter.default.post(name: Database.resumeNotification, object: self) |
| 185 | + // Proceed with background fetch |
| 186 | + ... |
| 187 | + } |
| 188 | + } |
| 189 | + ``` |
| 190 | + |
| 191 | + Suspended databases greatly reduce the odds of `0xDEAD10CC` exception are greatly reduced. If you see one in your crash logs, please open an issue! |
| 192 | + |
| 193 | + In exchange, you will get `SQLITE_INTERRUPT` (9) or `SQLITE_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. |
| 194 | + |
| 195 | + You can catch those errors: |
| 196 | + |
| 197 | + ```swift |
| 198 | + do { |
| 199 | + try dbPool.write { db in ... } |
| 200 | + } catch let error as DatabaseError where error.isInterruptionError { |
| 201 | + // Oops, the database is suspended. |
| 202 | + // Maybe try again after database is resumed? |
| 203 | + } |
| 204 | + ``` |
| 205 | + |
| 206 | + |
| 207 | +## How to perform cross-process database observation |
| 208 | + |
| 209 | +GRDB [Database Observation] features, as well as [GRDBCombine] and [RxGRDB], are not able to notify database changes performed by other processes. |
| 210 | + |
| 211 | +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]. |
| 212 | + |
| 213 | +You can trigger those notifications automatically with [DatabaseRegionObservation]: |
| 214 | + |
| 215 | +```swift |
| 216 | +// Notify all changes made to the "player" and "team" database tables |
| 217 | +let observation = DatabaseRegionObservation(tracking: Player.all(), Team.all()) |
| 218 | +let observer = try observation.start(in: dbPool) { (db: Database) in |
| 219 | + // Notify other processes |
| 220 | +} |
| 221 | + |
| 222 | +// Notify all changes made to the database |
| 223 | +let observation = DatabaseRegionObservation(tracking: DatabaseRegion.fullDatabase) |
| 224 | +let observer = try observation.start(in: dbPool) { (db: Database) in |
| 225 | + // Notify other processes |
| 226 | +} |
| 227 | +``` |
| 228 | + |
| 229 | +[Use a Database Pool]: #use-a-database-pool |
| 230 | +[How to limit the `SQLITE_BUSY` error]: #how-to-limit-the-sqlite_busy-error |
| 231 | +[How to limit the `0xDEAD10CC` exception]: #how-to-limit-the-0xdead10cc-exception |
| 232 | +[How to perform cross-process database observation]: #how-to-perform-cross-process-database-observation |
| 233 | +[Database Pool]: ../README.md#database-pools |
| 234 | +[Database Observation]: ../README.md#database-changes-observation |
| 235 | +[GRDBCombine]: http://github.com/groue/GRDBCombine |
| 236 | +[RxGRDB]: https://github.com/RxSwiftCommunity/RxGRDB |
| 237 | +[NSFileCoordinator]: https://developer.apple.com/documentation/foundation/nsfilecoordinator |
| 238 | +[CFNotificationCenterGetDarwinNotifyCenter]: https://developer.apple.com/documentation/corefoundation/1542572-cfnotificationcentergetdarwinnot |
| 239 | +[DatabaseRegionObservation]: ../README.md#databaseregionobservation |
0 commit comments