Skip to content

Commit

Permalink
Merge pull request #1535 from groue/dev/doc-ValueObservation-scheduling
Browse files Browse the repository at this point in the history
Improve the documentation about the scheduling of ValueObservation fetches
  • Loading branch information
groue authored Apr 21, 2024
2 parents f1ff7f2 + c8bb0a4 commit a0a9cfc
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 73 deletions.
14 changes: 8 additions & 6 deletions GRDB/Core/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
47 changes: 39 additions & 8 deletions GRDB/Documentation.docc/Extension/ValueObservation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <doc:ValueObservation#ValueObservation-Scheduling>.

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 <doc:ValueObservation#ValueObservation-Scheduling>.

`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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down
170 changes: 111 additions & 59 deletions GRDB/ValueObservation/ValueObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <doc:ValueObservation#ValueObservation-Scheduling>), 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<Value>(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Value>(
Expand Down
4 changes: 4 additions & 0 deletions GRDB/ValueObservation/ValueObservationScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit a0a9cfc

Please sign in to comment.