From c8bb0a479923479b7f8b3f7ec0a3ee08fddf9cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sun, 21 Apr 2024 14:42:47 +0200 Subject: [PATCH] Improve the documentation about the scheduling of ValueObservation fetches --- GRDB/Core/Database.swift | 14 +- .../Extension/ValueObservation.md | 47 ++++- GRDB/ValueObservation/ValueObservation.swift | 170 ++++++++++++------ .../ValueObservationScheduler.swift | 4 + 4 files changed, 162 insertions(+), 73 deletions(-) diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 68941ec2ae..762ff7506b 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -820,6 +820,11 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib /// Reports the database region to ``ValueObservation``. /// + /// Calling this method does not fetch any database values. It just + /// helps optimizing `ValueObservation`. See + /// ``ValueObservation/trackingConstantRegion(_:)`` for more + /// information, and some examples of usage. + /// /// For example: /// /// ```swift @@ -831,12 +836,9 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib /// } /// ``` /// - /// See ``ValueObservation/trackingConstantRegion(_:)`` for some examples - /// of region reporting. - /// - /// This method has no effect on a ``ValueObservation`` created with an - /// explicit list of tracked regions. In the example below, only the - /// `player` table is tracked: + /// This method has no effect on a `ValueObservation` created with + /// ``ValueObservation/tracking(regions:fetch:)``. In the example below, + /// only the `player` table is tracked: /// /// ```swift /// // Observes the 'player' table only diff --git a/GRDB/Documentation.docc/Extension/ValueObservation.md b/GRDB/Documentation.docc/Extension/ValueObservation.md index 153b81cd52..2fbf4f9e1a 100644 --- a/GRDB/Documentation.docc/Extension/ValueObservation.md +++ b/GRDB/Documentation.docc/Extension/ValueObservation.md @@ -80,6 +80,8 @@ By default, `ValueObservation` notifies a fresh value whenever any component of By default, `ValueObservation` notifies the initial value, as well as eventual changes and errors, on the main dispatch queue, asynchronously. This can be configured: see . +By default, `ValueObservation` fetches a fresh value immediately after a change is committed in the database. In particular, modifying the database on the main thread triggers a fetch on the main thread as well. This behavior can be configured: see . + `ValueObservation` may coalesce subsequent changes into a single notification. `ValueObservation` may notify consecutive identical values. You can filter out the undesired duplicates with the ``removeDuplicates()`` method. @@ -117,16 +119,45 @@ It is very useful in graphic applications, because you can configure views right The `immediate` scheduling requires that the observation starts from the main dispatch queue (a fatal error is raised otherwise): ```swift -let cancellable = observation.start(in: dbQueue, scheduling: .immediate) { error in - // Called on the main dispatch queue -} onChange: { value in - // Called on the main dispatch queue - print("Fresh value", value) -} +// Immediate scheduling notifies +// the initial value right on subscription. +let cancellable = observation + .start(in: dbQueue, scheduling: .immediate) { error in + // Called on the main dispatch queue + } onChange: { value in + // Called on the main dispatch queue + print("Fresh value", value) + } // <- Here "Fresh value" has already been printed. ``` -The other built-in scheduler ``ValueObservationScheduler/async(onQueue:)`` asynchronously schedules values and errors on the dispatch queue of your choice. +The other built-in scheduler ``ValueObservationScheduler/async(onQueue:)`` asynchronously schedules values and errors on the dispatch queue of your choice. Make sure you provide a serial queue, because a concurrent one such as `DispachQueue.global(qos: .default)` would mess with the ordering of fresh value notifications: + +```swift +// Async scheduling notifies all values +// on the specified dispatch queue. +let myQueue: DispatchQueue +let cancellable = observation + .start(in: dbQueue, scheduling: .async(myQueue)) { error in + // Called asynchronously on myQueue + } onChange: { value in + // Called asynchronously on myQueue + print("Fresh value", value) + } +``` + +As described above, the `scheduling` argument controls the execution of the change and error callbacks. You also have some control on the execution of the database fetch: + +- With the `.immediate` scheduling, the initial fetch is always performed synchronously, on the main thread, when the observation starts, so that the initial value can be notified immediately. + +- With the default `.async` scheduling, the initial fetch is always performed asynchronouly. It never blocks the main thread. + +- By default, fresh values are fetched immediately after the database was changed. In particular, modifying the database on the main thread triggers a fetch on the main thread as well. + + To change this behavior, and guarantee that fresh values are never fetched from the main thread, you need a ``DatabasePool`` and an optimized observation created with the ``tracking(regions:fetch:)`` or ``trackingConstantRegion(_:)`` methods. Make sure you read the documentation of those methods, or you might write an observation that misses some database changes. + + It is possible to use a ``DatabasePool`` in the application, and an in-memory ``DatabaseQueue`` in tests and Xcode previews, with the common protocol ``DatabaseWriter``. + ## ValueObservation Sharing @@ -237,7 +268,7 @@ When needed, you can help GRDB optimize observations and reduce database content > > The `map` operator performs its job without blocking database accesses, and without blocking the main thread. -> Tip: When the observation tracks a constant database region, create an optimized observation with the ``trackingConstantRegion(_:)`` method. See the documentation of this method for more information about what constitutes a "constant region", and the nature of the optimization. +> Tip: When the observation tracks a constant database region, create an optimized observation with the ``tracking(regions:fetch:)`` or ``trackingConstantRegion(_:)`` methods. Make sure you read the documentation of those methods, or you might write an observation that misses some database changes. **Truncating WAL checkpoints impact ValueObservation.** Such checkpoints are performed with ``Database/checkpoint(_:on:)`` or [`PRAGMA wal_checkpoint`](https://www.sqlite.org/pragma.html#pragma_wal_checkpoint). When an observation is started on a ``DatabasePool``, from a database that has a missing or empty [wal file](https://www.sqlite.org/tempfiles.html#write_ahead_log_wal_files), the observation will always notify two values when it starts, even if the database content is not changed. This is a consequence of the impossibility to create the [wal snapshot](https://www.sqlite.org/c3ref/snapshot_get.html) needed for detecting that no changes were performed during the observation startup. If your application performs truncating checkpoints, you will avoid this behavior if you recreate a non-empty wal file before starting observations. To do so, perform any kind of no-op transaction (such a creating and dropping a dummy table). diff --git a/GRDB/ValueObservation/ValueObservation.swift b/GRDB/ValueObservation/ValueObservation.swift index 31a481ccf6..37c313b20a 100644 --- a/GRDB/ValueObservation/ValueObservation.swift +++ b/GRDB/ValueObservation/ValueObservation.swift @@ -595,12 +595,18 @@ extension ValueObservation { /// Creates an optimized `ValueObservation` that notifies the fetched value /// whenever it changes. /// - /// The optimization reduces database contention by not blocking database - /// writes when the fresh value is fetched. - /// - /// The optimization is only applied when the observation is started from a - /// ``DatabasePool``. You can start such an observation from a - /// ``DatabaseQueue``, but the optimization will not be applied. + /// Unlike observations created with ``tracking(_:)``, the returned + /// observation can reduce database contention, by not blocking + /// database writes when fresh values are fetched. It can also avoid + /// fetching fresh values from the main thread, after the database was + /// modified on the main thread. + /// + /// Those scheduling optimizations are only applied when the observation + /// is started from a ``DatabasePool``. You can start such an + /// observation from a ``DatabaseQueue``, but the optimizations will not + /// be applied. The notified values will be the same, though. This makes + /// it possible to use a pool in the main application, and an in-memory + /// queue in tests and Xcode previews. /// /// **Precondition**: The `fetch` function must perform requests that fetch /// from a single and constant database region. This region is made of @@ -623,7 +629,7 @@ extension ValueObservation { /// /// // Tracks the 'score' column in the 'player' table /// let observation = ValueObservation.trackingConstantRegion { db -> Int? in - /// try Player.select(max(Column("score"))).fetchOne(db) + /// try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player") /// } /// /// // Tracks both the 'player' and 'team' tables @@ -634,73 +640,93 @@ extension ValueObservation { /// } /// ``` /// - /// Observations that do not track a constant region must not use this - /// method. Use ``tracking(_:)`` instead, or else some changes will not - /// be notified. + /// **Observations that do not track a constant database region must not + /// use this method, because some changes may not be notified to + /// the application.** /// - /// For example, the observations below do not track a constant region, and - /// must not be optimized: + /// For example, the observations below do not track a constant region. + /// They are correctly defined with ``tracking(_:)``, since + /// `trackingConstantRegion(_:)` is unsuited: /// /// ```swift - /// // Does not always track the same row in the player table. - /// let observation = ValueObservation.tracking { db -> Player? in - /// let pref = try Preference.fetchOne(db) ?? .default - /// return try Player.fetchOne(db, id: pref.favoritePlayerId) + /// // Does not always track the same row in the 'player' table: + /// let observation = ValueObservation.tracking { db -> Player in + /// let config = try AppConfiguration.find(db) + /// let playerId: Int64 = config.favoritePlayerId + /// return try Player.find(db, id: playerId) /// } /// - /// // Does not always track the 'user' table. - /// let observation = ValueObservation.tracking { db -> [User] in - /// let pref = try Preference.fetchOne(db) ?? .default - /// let playerIds: [Int64] = pref.favoritePlayerIds // may be empty + /// // Does not always track the 'player' table, or not always the same + /// // rows in the 'player' table: + /// let observation = ValueObservation.tracking { db -> [Player] in + /// let config = try AppConfiguration.find(db) + /// let playerIds: [Int64] = config.favoritePlayerIds + /// // Not only playerIds can change, but when it is empty, + /// // the player table is not tracked at all. /// return try Player.fetchAll(db, ids: playerIds) /// } /// /// // Sometimes tracks the 'food' table, and sometimes the 'beverage' table. /// let observation = ValueObservation.tracking { db -> Int in - /// let pref = try Preference.fetchOne(db) ?? .default - /// switch pref.selection { - /// case .food: return try Food.fetchCount(db) - /// case .beverage: return try Beverage.fetchCount(db) + /// let config = try AppConfiguration.find(db) + /// switch config.selection { + /// case .food: + /// return try Food.fetchCount(db) + /// case .beverage: + /// return try Beverage.fetchCount(db) /// } /// } /// ``` /// - /// You can turn them into optimized observations of a constant region with - /// the ``Database/registerAccess(to:)`` method: - /// - /// ```swift - /// let observation = ValueObservation.trackingConstantRegion { db -> Player? in - /// // Track all players so that the observed region does not depend on - /// // the rowid of the favorite player. - /// try db.registerAccess(to: Player.all()) - /// - /// let pref = try Preference.fetchOne(db) ?? .default - /// return try Player.fetchOne(db, id: pref.favoritePlayerId) - /// } - /// - /// let observation = ValueObservation.trackingConstantRegion { db -> [User] in - /// // Track all players so that the observed region does not change - /// // even if there is no favorite player at all. - /// try db.registerAccess(to: Player.all()) - /// - /// let pref = try Preference.fetchOne(db) ?? .default - /// let playerIds: [Int64] = pref.favoritePlayerIds // may be empty - /// return try Player.fetchAll(db, ids: playerIds) - /// } - /// - /// let observation = ValueObservation.trackingConstantRegion { db -> Int in - /// // Track foods and beverages so that the observed region does not - /// // depend on preferences. - /// try db.registerAccess(to: Food.all()) - /// try db.registerAccess(to: Beverage.all()) - /// - /// let pref = try Preference.fetchOne(db) ?? .default - /// switch pref.selection { - /// case .food: return try Food.fetchCount(db) - /// case .beverage: return try Beverage.fetchCount(db) + /// Since only observations of a constant region can achieve important + /// scheduling optimizations (such as the guarantee that fresh values + /// are never fetched from the main thread – + /// see ), you can + /// always create one: + /// + /// - With ``tracking(regions:fetch:)``, you provide all tracked + /// region(s) when the observation is created: + /// + /// ```swift + /// // Optimized observation that explicitly tracks the + /// // 'appConfiguration', 'food', and 'beverage' tables: + /// let observation = ValueObservation.tracking( + /// regions: [ + /// AppConfiguration.all(), + /// Food.all(), + /// Beverage.all(), + /// ], + /// fetch: { db -> Int in + /// let config = try AppConfiguration.find(db) + /// switch config.selection { + /// case .food: + /// return try Food.fetchCount(db) + /// case .beverage: + /// return try Beverage.fetchCount(db) + /// } + /// }) + /// ``` + /// + /// - With ``Database/registerAccess(to:)``, you extend the list of + /// tracked region(s) from the fetching closure: + /// + /// ```swift + /// // Optimized observation that implicitly tracks the + /// // 'appConfiguration' table, and explicitly tracks 'food' + /// // and 'beverage': + /// let observation = ValueObservation.trackingConstantRegion { db -> Int in + /// try db.registerAccess(to: Food.all()) + /// try db.registerAccess(to: Beverage.all()) + /// + /// let config = try AppConfiguration.find(db) + /// switch config.selection { + /// case .food: + /// return try Food.fetchCount(db) + /// case .beverage: + /// return try Beverage.fetchCount(db) + /// } /// } - /// } - /// ``` + /// ``` /// /// - parameter fetch: The closure that fetches the observed value. public static func trackingConstantRegion( @@ -758,6 +784,19 @@ extension ValueObservation { /// fetch: { db in ... }) /// ``` /// + /// Unlike observations created with ``tracking(_:)``, the returned + /// observation can reduce database contention, by not blocking + /// database writes when fresh values are fetched. It can also avoid + /// fetching fresh values from the main thread, after the database was + /// modified on the main thread. + /// + /// Those scheduling optimizations are only applied when the observation + /// is started from a ``DatabasePool``. You can start such an + /// observation from a ``DatabaseQueue``, but the optimizations will not + /// be applied. The notified values will be the same, though. This makes + /// it possible to use a pool in the main application, and an in-memory + /// queue in tests and Xcode previews. + /// /// - parameter region: A region to observe. /// - parameter otherRegions: A list of supplementary regions /// to observe. @@ -817,6 +856,19 @@ extension ValueObservation { /// fetch: { db in ... }) /// ``` /// + /// Unlike observations created with ``tracking(_:)``, the returned + /// observation can reduce database contention, by not blocking + /// database writes when fresh values are fetched. It can also avoid + /// fetching fresh values from the main thread, after the database was + /// modified on the main thread. + /// + /// Those scheduling optimizations are only applied when the observation + /// is started from a ``DatabasePool``. You can start such an + /// observation from a ``DatabaseQueue``, but the optimizations will not + /// be applied. The notified values will be the same, though. This makes + /// it possible to use a pool in the main application, and an in-memory + /// queue in tests and Xcode previews. + /// /// - parameter regions: An array of observed regions. /// - parameter fetch: The closure that fetches the observed value. public static func tracking( diff --git a/GRDB/ValueObservation/ValueObservationScheduler.swift b/GRDB/ValueObservation/ValueObservationScheduler.swift index ad3b5f246f..d43cad7dc3 100644 --- a/GRDB/ValueObservation/ValueObservationScheduler.swift +++ b/GRDB/ValueObservation/ValueObservationScheduler.swift @@ -66,6 +66,10 @@ extension ValueObservationScheduler where Self == AsyncValueObservationScheduler /// print("fresh players: \(players)") /// }) /// ``` + /// + /// - warning: Make sure you provide a serial queue, because a + /// concurrent one such as `DispachQueue.global(qos: .default)` would + /// mess with the ordering of fresh value notifications. public static func async(onQueue queue: DispatchQueue) -> AsyncValueObservationScheduler { AsyncValueObservationScheduler(queue: queue) }