Skip to content

Faster alternative to pool.makeSnapshot which updates existing DatabaseSnapshot #619

@michaelkirk-signal

Description

@michaelkirk-signal

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:

  1. 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.

  2. 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?

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions