383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
// swiftlint:disable:next line_length
|
|
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
|
/// A database connection that allows concurrent accesses to an unchanging
|
|
/// database content, as it existed at the moment the snapshot was created.
|
|
///
|
|
/// ## Overview
|
|
///
|
|
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
|
///
|
|
/// A `DatabaseSnapshotPool` never sees any database modification during all its
|
|
/// lifetime. All database accesses performed from a snapshot always see the
|
|
/// same identical database content.
|
|
///
|
|
/// It creates a pool of up to ``Configuration/maximumReaderCount`` read-only
|
|
/// SQLite connections. All read accesses are executed in **reader dispatch
|
|
/// queues** (one per read-only SQLite connection). SQLite connections are
|
|
/// closed when the `DatabasePool` is deallocated.
|
|
///
|
|
/// An SQLite database in the [WAL mode](https://www.sqlite.org/wal.html) is
|
|
/// required for creating a `DatabaseSnapshotPool`.
|
|
///
|
|
/// ## Usage
|
|
///
|
|
/// You create a `DatabaseSnapshotPool` from a
|
|
/// [WAL mode](https://www.sqlite.org/wal.html) database, such as databases
|
|
/// created from a ``DatabasePool``:
|
|
///
|
|
/// ```swift
|
|
/// let dbPool = try DatabasePool(path: "/path/to/database.sqlite")
|
|
/// let snapshot = try dbPool.makeSnapshotPool()
|
|
/// ```
|
|
///
|
|
/// When you want to control the database state seen by a snapshot, create the
|
|
/// snapshot from a database connection, outside of a write transaction. You can
|
|
/// for example take snapshots from a ``ValueObservation``:
|
|
///
|
|
/// ```swift
|
|
/// // An observation of the 'player' table
|
|
/// // that notifies fresh database snapshots:
|
|
/// let observation = ValueObservation.tracking { db in
|
|
/// // Don't fetch players now, and return a snapshot instead.
|
|
/// // Register an access to the player table so that the
|
|
/// // observation tracks changes to this table.
|
|
/// try db.registerAccess(to: Player.all())
|
|
/// return try DatabaseSnapshotPool(db)
|
|
/// }
|
|
///
|
|
/// // Start observing the 'player' table
|
|
/// let cancellable = try observation.start(in: dbPool) { error in
|
|
/// // Handle error
|
|
/// } onChange: { (snapshot: DatabaseSnapshotPool) in
|
|
/// // Handle a fresh snapshot
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// `DatabaseSnapshotPool` inherits its database access methods from the
|
|
/// ``DatabaseReader`` protocols.
|
|
///
|
|
/// Related SQLite documentation:
|
|
///
|
|
/// - <https://www.sqlite.org/c3ref/snapshot_get.html>
|
|
/// - <https://www.sqlite.org/c3ref/snapshot_open.html>
|
|
///
|
|
/// ## Topics
|
|
///
|
|
/// ### Creating a DatabaseSnapshotPool
|
|
///
|
|
/// See also ``DatabasePool/makeSnapshotPool()``.
|
|
///
|
|
/// - ``init(_:configuration:)``
|
|
/// - ``init(path:configuration:)``
|
|
public final class DatabaseSnapshotPool {
|
|
public let configuration: Configuration
|
|
|
|
/// The path to the database file.
|
|
public let path: String
|
|
|
|
/// The pool of reader connections.
|
|
/// It is constant, until close() sets it to nil.
|
|
private var readerPool: Pool<SerializedDatabase>?
|
|
|
|
/// The WAL snapshot
|
|
private let walSnapshot: WALSnapshot
|
|
|
|
/// A connection that prevents checkpoints and keeps the WAL snapshot valid.
|
|
/// It is never used.
|
|
private let snapshotHolder: DatabaseQueue
|
|
|
|
/// Creates a snapshot of the database.
|
|
///
|
|
/// For example:
|
|
///
|
|
/// ```swift
|
|
/// let dbPool = try DatabasePool(path: "/path/to/database.sqlite")
|
|
/// let snapshot = try dbPool.writeWithoutTransaction { db -> DatabaseSnapshotPool in
|
|
/// try db.inTransaction {
|
|
/// try Player.deleteAll()
|
|
/// return .commit
|
|
/// }
|
|
///
|
|
/// // Create the snapshot after all players have been deleted.
|
|
/// return DatabaseSnapshotPool(db)
|
|
/// }
|
|
///
|
|
/// // Later... Maybe some players have been created.
|
|
/// // The snapshot is guaranteed to see an empty table of players, though:
|
|
/// let count = try snapshot.read { db in
|
|
/// try Player.fetchCount(db)
|
|
/// }
|
|
/// assert(count == 0)
|
|
/// ```
|
|
///
|
|
/// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite
|
|
/// database is not in the [WAL mode](https://www.sqlite.org/wal.html),
|
|
/// or if this method is called from a write transaction, or if the
|
|
/// wal file is missing or truncated (size zero).
|
|
///
|
|
/// Related SQLite documentation: <https://www.sqlite.org/c3ref/snapshot_get.html>
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - parameter configuration: A configuration. If nil, the configuration of
|
|
/// `db` is used.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public init(_ db: Database, configuration: Configuration? = nil) throws {
|
|
var configuration = Self.configure(configuration ?? db.configuration)
|
|
|
|
// Acquire and hold WAL snapshot
|
|
let walSnapshot = try db.isolated(readOnly: true) {
|
|
try WALSnapshot(db)
|
|
}
|
|
var holderConfig = Configuration()
|
|
holderConfig.allowsUnsafeTransactions = true
|
|
snapshotHolder = try DatabaseQueue(path: db.path, configuration: holderConfig)
|
|
try snapshotHolder.inDatabase { db in
|
|
try db.beginTransaction(.deferred)
|
|
try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1")
|
|
let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot)
|
|
guard code == SQLITE_OK else {
|
|
throw DatabaseError(resultCode: code)
|
|
}
|
|
}
|
|
|
|
configuration.prepareDatabase { db in
|
|
try db.beginTransaction(.deferred)
|
|
try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1")
|
|
let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot)
|
|
guard code == SQLITE_OK else {
|
|
throw DatabaseError(resultCode: code)
|
|
}
|
|
}
|
|
|
|
self.configuration = configuration
|
|
self.path = db.path
|
|
self.walSnapshot = walSnapshot
|
|
|
|
var readerCount = 0
|
|
readerPool = Pool(
|
|
maximumCount: configuration.maximumReaderCount,
|
|
qos: configuration.readQoS,
|
|
makeElement: {
|
|
readerCount += 1 // protected by Pool (TODO: document this protection behavior)
|
|
return try SerializedDatabase(
|
|
path: db.path,
|
|
configuration: configuration,
|
|
defaultLabel: "GRDB.DatabaseSnapshotPool",
|
|
purpose: "snapshot.\(readerCount)")
|
|
})
|
|
}
|
|
|
|
/// Creates a snapshot of the database.
|
|
///
|
|
/// For example:
|
|
///
|
|
/// ```swift
|
|
/// let snapshot = try DatabaseSnapshotPool(path: "/path/to/database.sqlite")
|
|
/// ```
|
|
///
|
|
/// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite
|
|
/// database is not in the [WAL mode](https://www.sqlite.org/wal.html),
|
|
/// or if the wal file is missing or truncated (size zero).
|
|
///
|
|
/// Related SQLite documentation: <https://www.sqlite.org/c3ref/snapshot_get.html>
|
|
///
|
|
/// - parameters:
|
|
/// - path: The path to the database file.
|
|
/// - configuration: A configuration.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public init(path: String, configuration: Configuration = Configuration()) throws {
|
|
var configuration = Self.configure(configuration)
|
|
|
|
// Acquire and hold WAL snapshot
|
|
var holderConfig = Configuration()
|
|
holderConfig.allowsUnsafeTransactions = true
|
|
snapshotHolder = try DatabaseQueue(path: path, configuration: holderConfig)
|
|
let walSnapshot = try snapshotHolder.inDatabase { db in
|
|
try db.beginTransaction(.deferred)
|
|
try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1")
|
|
return try WALSnapshot(db)
|
|
}
|
|
|
|
configuration.prepareDatabase { db in
|
|
try db.beginTransaction(.deferred)
|
|
try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1")
|
|
let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot)
|
|
guard code == SQLITE_OK else {
|
|
throw DatabaseError(resultCode: code)
|
|
}
|
|
}
|
|
|
|
self.configuration = configuration
|
|
self.path = path
|
|
self.walSnapshot = walSnapshot
|
|
|
|
var readerCount = 0
|
|
readerPool = Pool(
|
|
maximumCount: configuration.maximumReaderCount,
|
|
qos: configuration.readQoS,
|
|
makeElement: {
|
|
readerCount += 1 // protected by Pool (TODO: document this protection behavior)
|
|
return try SerializedDatabase(
|
|
path: path,
|
|
configuration: configuration,
|
|
defaultLabel: "GRDB.DatabaseSnapshotPool",
|
|
purpose: "snapshot.\(readerCount)")
|
|
})
|
|
}
|
|
|
|
private static func configure(_ configuration: Configuration) -> Configuration {
|
|
var configuration = configuration
|
|
|
|
// DatabaseSnapshotPool needs a non-empty pool of connections.
|
|
GRDBPrecondition(configuration.maximumReaderCount > 0, "configuration.maximumReaderCount must be at least 1")
|
|
|
|
// DatabaseSnapshotPool is read-only.
|
|
configuration.readonly = true
|
|
|
|
// DatabaseSnapshotPool uses deferred transactions by default.
|
|
// Other transaction kinds are forbidden by SQLite in read-only connections.
|
|
configuration.defaultTransactionKind = .deferred
|
|
|
|
// DatabaseSnapshotPool keeps a long-lived transaction.
|
|
configuration.allowsUnsafeTransactions = true
|
|
|
|
// DatabaseSnapshotPool requires the WAL mode.
|
|
// See <https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode>
|
|
if configuration.readonlyBusyMode == nil {
|
|
configuration.readonlyBusyMode = .timeout(10)
|
|
}
|
|
|
|
return configuration
|
|
}
|
|
}
|
|
|
|
extension DatabaseSnapshotPool: @unchecked Sendable { }
|
|
|
|
extension DatabaseSnapshotPool: DatabaseSnapshotReader {
|
|
public func close() throws {
|
|
try readerPool?.barrier {
|
|
defer { readerPool = nil }
|
|
|
|
try readerPool?.forEach { reader in
|
|
try reader.sync { try $0.close() }
|
|
}
|
|
}
|
|
}
|
|
|
|
public func interrupt() {
|
|
readerPool?.forEach { $0.interrupt() }
|
|
}
|
|
|
|
@_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails
|
|
public func read<T>(_ value: (Database) throws -> T) throws -> T {
|
|
GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.")
|
|
guard let readerPool else {
|
|
throw DatabaseError.connectionIsClosed()
|
|
}
|
|
|
|
let (reader, releaseReader) = try readerPool.get()
|
|
var completion: PoolCompletion!
|
|
defer {
|
|
releaseReader(completion)
|
|
}
|
|
return try reader.sync { db in
|
|
do {
|
|
let value = try value(db)
|
|
completion = poolCompletion(db)
|
|
return value
|
|
} catch {
|
|
completion = poolCompletion(db)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
public func asyncRead(_ value: @escaping (Result<Database, Error>) -> Void) {
|
|
guard let readerPool else {
|
|
value(.failure(DatabaseError.connectionIsClosed()))
|
|
return
|
|
}
|
|
|
|
readerPool.asyncGet { result in
|
|
do {
|
|
let (reader, releaseReader) = try result.get()
|
|
// Second async jump because that's how `Pool.async` has to be used.
|
|
reader.async { db in
|
|
value(.success(db))
|
|
releaseReader(self.poolCompletion(db))
|
|
}
|
|
} catch {
|
|
value(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
public func unsafeReentrantRead<T>(_ value: (Database) throws -> T) throws -> T {
|
|
if let reader = currentReader {
|
|
return try reader.reentrantSync { db in
|
|
let result = try value(db)
|
|
if snapshotIsLost(db) {
|
|
throw DatabaseError(resultCode: .SQLITE_ABORT, message: "Snapshot is lost.")
|
|
}
|
|
return result
|
|
}
|
|
} else {
|
|
// There is no unsafe access to a snapshot.
|
|
return try read(value)
|
|
}
|
|
}
|
|
|
|
public func _add<Reducer>(
|
|
observation: ValueObservation<Reducer>,
|
|
scheduling scheduler: some ValueObservationScheduler,
|
|
onChange: @escaping (Reducer.Value) -> Void)
|
|
-> AnyDatabaseCancellable where Reducer: ValueReducer
|
|
{
|
|
_addReadOnly(observation: observation, scheduling: scheduler, onChange: onChange)
|
|
}
|
|
|
|
/// Returns a reader that can be used from the current dispatch queue,
|
|
/// if any.
|
|
private var currentReader: SerializedDatabase? {
|
|
guard let readerPool else {
|
|
return nil
|
|
}
|
|
|
|
var readers: [SerializedDatabase] = []
|
|
readerPool.forEach { reader in
|
|
// We can't check for reader.onValidQueue here because
|
|
// Pool.forEach() runs its closure argument in some arbitrary
|
|
// dispatch queue. We thus extract the reader so that we can query
|
|
// it below.
|
|
readers.append(reader)
|
|
}
|
|
|
|
// Now the readers array contains some readers. The pool readers may
|
|
// already be different, because some other thread may have started
|
|
// a new read, for example.
|
|
//
|
|
// This doesn't matter: the reader we are looking for is already on
|
|
// its own dispatch queue. If it exists, is still in use, thus still
|
|
// in the pool, and thus still relevant for our check:
|
|
return readers.first { $0.onValidQueue }
|
|
}
|
|
|
|
private func poolCompletion(_ db: Database) -> PoolCompletion {
|
|
snapshotIsLost(db) ? .discard : .reuse
|
|
}
|
|
|
|
private func snapshotIsLost(_ db: Database) -> Bool {
|
|
do {
|
|
let currentSnapshot = try WALSnapshot(db)
|
|
if currentSnapshot.compare(walSnapshot) == 0 {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
#endif
|