-
-
Notifications
You must be signed in to change notification settings - Fork 783
Description
What did you do?
I'm using DatabaseSnapshot as a stable data source for my UI and hitting some performance bottlenecks when repeatedly rebuilding the DatabaseSnapshot.
From what I've read, the documentation recommends building a new DatabaseSnapshot and discarding the old one when you need to update it, but this can be quite expensive.
In my app, database writes occur mostly in the background. After a write, I need to update my UI's DatabaseSnapshot, then notify any stale views to re-render with the latest snapshot. This notification occurs as part of my UIDatabaseDelegate protocol in the following example:
protocol UIDatabaseDelegate {
func uiDatabaseWillUpdate(_ uiDatabase: UIDatabase)
func uiDatabaseDidUpdate(_ uiDatabase: UIDatabase)
}
class UIDatabase {
let pool: DatabasePool
var latestSnapshot: DatabaseSnapshot
var delegates: [Weak<UIDatabaseDelegate>] = []
init(pool: DatabasePool) {
self.pool = pool
self.latestSnapshot = try! pool.makeSnapshot()
}
}
extension UIDatabase: TransactionObserver {
func databaseDidCommit(_ db: Database) {
// This is expensive!
let newSnapshot = try! pool.makeSnapshot()
DispatchQueue.main.async {
delegates.forEach { $0.value?.uiDatabaseWillUpdate(self) }
self.latestSnapshot = newSnapshot
// delegates re-render stale views
delegates.forEach { $0.value?.uiDatabaseDidUpdate(self) }
}
}
// ... snipped for brevity
}
In practice the UIDatabaseDelegate is usually almost equivalent to the set of ViewControllers on the navigation stack (typically 2-4 items).
Since snapshot isolation is transaction isolation, rather than building a new snapshot, it seems like we could do something cheaper, along the lines of:
extension UIDatabase: TransactionObserver {
public func databaseDidCommit(_ db: Database) {
DispatchQueue.main.async {
delegates.forEach { $0.value?.uiDatabaseWillUpdate(self) }
// the following is an incomplete implementation, do not use
databaseSnapshot.read { db in
// [1] end the old transaction from the old db state
try! db.commit()
// [2] open a new transaction from the current db state
try! db.beginSnapshotIsolation() // <- this is an internal method, so this won't actually compile
}
// delegates re-render stale views
delegates.forEach { $0.value?.uiDatabaseDidUpdate(self) }
}
}
// ... snipped for brevity
}
Intuitively, and in my profiling, starting a new transaction is much faster than creating a new snapshot (which I believe opens a new database connection). However, it introduces some immediate questions:
-
concurrency - can we be sure the new transaction at [2] reflects the state of the db that was just committed? I think so, because didCommit happens within the context of
SchedulingWatchdog.preconditionValidQueue(database)
so no interstitial could have occurred. -
beginSnapshotIsolation is currently an internal method. Could it be public? Or could we encapsulate something like the above in a public
try databaseSnapshot.updateToLatest()
method? Is that something you'd be interested in supporting? -
would this have an impact on the db's ability to efficiently checkpoint?
I'm also curious if you think this is a reasonable approach generally, or if this is not how you envisioned DatabaseSnapshot being used.
I'm aware of the more targeted Observer flavors GRDB offers, e.g. each view has a view model based on the Records it uses. The same query(ies) that build the view models can be fed to an observer, etc. And although I might like to move more in that direction in the future, it doesn't mesh well with the current app architecture.
Environment
GRDB flavor(s): GRDB+SQLCipher
GRDB version: 4.3.0
Installation method: CocoaPods
Xcode version: 10.3
Swift version: 5.0.1
Platform(s) running GRDB: iOS
macOS version running Xcode: 10.14.6