Skip to content

Commit 3535a42

Browse files
authored
Merge pull request #1716 from groue/dev/issue-1715
Fix: DatabaseQueue restores its read/write abilities when an async read-only database access is cancelled.
2 parents 287c4be + 5a4d9b5 commit 3535a42

File tree

4 files changed

+70
-0
lines changed

4 files changed

+70
-0
lines changed

GRDB/Core/Database.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,12 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib
12881288
return
12891289
}
12901290

1291+
// Suspension should not prevent adjusting the read-only mode.
1292+
// See <https://github.com/groue/GRDB.swift/issues/1715>.
1293+
if statement.isQueryOnlyPragma {
1294+
return
1295+
}
1296+
12911297
// How should we interrupt the statement?
12921298
enum Interrupt {
12931299
case abort // Rollback and throw SQLITE_ABORT

GRDB/Core/Statement.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ public final class Statement {
9292
/// The effects on the database (reported by `sqlite3_set_authorizer`).
9393
private(set) var authorizerEventKinds: [DatabaseEventKind] = []
9494

95+
/// If true, the statement executes is a `PRAGMA QUERY_ONLY` statement.
96+
private(set) var isQueryOnlyPragma = false
97+
9598
/// A boolean value indicating if the prepared statement makes no direct
9699
/// changes to the content of the database file.
97100
///
@@ -160,6 +163,7 @@ public final class Statement {
160163
self.invalidatesDatabaseSchemaCache = authorizer.invalidatesDatabaseSchemaCache
161164
self.transactionEffect = authorizer.transactionEffect
162165
self.authorizerEventKinds = authorizer.databaseEventKinds
166+
self.isQueryOnlyPragma = authorizer.isQueryOnlyPragma
163167
}
164168

165169
deinit {

GRDB/Core/StatementAuthorizer.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ final class StatementAuthorizer {
4141
/// savepoint statement.
4242
var transactionEffect: Statement.TransactionEffect?
4343

44+
/// If true, the statement executes is a `PRAGMA QUERY_ONLY` statement.
45+
var isQueryOnlyPragma = false
46+
4447
private var isDropStatement = false
4548

4649
init(_ database: Database) {
@@ -67,6 +70,7 @@ final class StatementAuthorizer {
6770
databaseEventKinds = []
6871
invalidatesDatabaseSchemaCache = false
6972
transactionEffect = nil
73+
isQueryOnlyPragma = false
7074
isDropStatement = false
7175
}
7276

@@ -192,6 +196,11 @@ final class StatementAuthorizer {
192196
}
193197
return SQLITE_OK
194198

199+
case SQLITE_PRAGMA:
200+
if let cString1 {
201+
isQueryOnlyPragma = sqlite3_stricmp(cString1, "query_only") == 0
202+
}
203+
return SQLITE_OK
195204
default:
196205
return SQLITE_OK
197206
}

Tests/GRDBTests/DatabaseWriterTests.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,57 @@ class DatabaseWriterTests : GRDBTestCase {
415415

416416
// MARK: - Task Cancellation
417417

418+
// Regression test for <https://github.com/groue/GRDB.swift/issues/1715>.
419+
func test_write_is_possible_after_read_cancelled_after_database_access() async throws {
420+
// When a read access is cancelled, DatabaseQueue needs to execute
421+
// `PRAGMA query_only=0` in order to restore the read/write access.
422+
//
423+
// Here we test that this pragma can run from a cancelled read.
424+
//
425+
// Small difficulty: some SQLite versions (seen with 3.43.2) execute
426+
// the `query_only` pragma at compile time, not only at execution
427+
// time (yeah, that's an SQLite bug). The problem of this bug is
428+
// that even if the `PRAGMA query_only=0` is not executed due to
429+
// Task cancellation, its side effect is still executed when it is
430+
// compiled, unintentionally. A cancelled `PRAGMA query_only=0`
431+
// still works!
432+
//
433+
// To avoid this SQLite bug from messing with our test, we perform
434+
// two reads: one that compiles and cache `PRAGMA query_only`
435+
// statements, and a second read that we cancel. This time the
436+
// `PRAGMA query_only=0` triggers its side effect if and only if it
437+
// is actually executed (the behavior we are testing).
438+
func test(_ dbWriter: some DatabaseWriter) async throws {
439+
let semaphore = AsyncSemaphore(value: 0)
440+
let cancelledTaskMutex = Mutex<Task<Void, any Error>?>(nil)
441+
let task = Task {
442+
await semaphore.wait()
443+
444+
// First read, not cancelled, so that all `query_only`
445+
// pragma statements are compiled (see above).
446+
try await dbWriter.read { db in }
447+
448+
// Second read, cancelled.
449+
try await dbWriter.read { db in
450+
try XCTUnwrap(cancelledTaskMutex.load()).cancel()
451+
}
452+
}
453+
cancelledTaskMutex.store(task)
454+
semaphore.signal()
455+
// Wait until reads are completed
456+
try? await task.value
457+
458+
// Write access is restored after read cancellation (no error is thrown)
459+
try await dbWriter.write { db in
460+
try db.execute(sql: "CREATE TABLE test(a)")
461+
}
462+
}
463+
464+
try await test(makeDatabaseQueue())
465+
try await test(makeDatabasePool())
466+
try await test(AnyDatabaseWriter(makeDatabaseQueue()))
467+
}
468+
418469
func test_writeWithoutTransaction_is_cancelled_by_Task_cancellation_performed_before_database_access() async throws {
419470
func test(_ dbWriter: some DatabaseWriter) async throws {
420471
let semaphore = AsyncSemaphore(value: 0)

0 commit comments

Comments
 (0)