From 71964f93ad8126ace96ec324d4052a8853ff87d8 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Mon, 26 Jan 2026 18:40:08 +0100 Subject: [PATCH 01/14] 1.0.0-beta.2 updates --- Package.resolved | 10 +++++----- Package.swift | 2 +- README.md | 16 ++++++++++++---- .../SQLiteDatabaseClient.swift | 12 ++++++------ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Package.resolved b/Package.resolved index 393a8bd..bc9e65b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3f3c993362a6be1c507fa40f0e4113550a3f2c599ccc4396da554f3df56b6b51", + "originHash" : "816ee700ae5206734d35d787d67b0af726ff97f74c5e84dec48c842bb3679698", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "34b05e9ca725bf857c9bc6e29603a4e457f9969a", - "version" : "1.0.0-beta.1" + "revision" : "147c96803f398c50f5717ce08ebd78a9e3ab7ca7", + "version" : "1.0.0-beta.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", - "version" : "2.92.2" + "revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e", + "version" : "2.93.0" } }, { diff --git a/Package.swift b/Package.swift index 2ab00bc..5cd199f 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.0"), - .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"), ], targets: [ .target( diff --git a/README.md b/README.md index 84ee13b..0d7ce38 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ SQLite driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. -![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138) +[ + ![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138) +]( + https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.2 +) ## Features @@ -33,7 +37,7 @@ SQLite driver implementation for the abstract [Feather Database](https://github. Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.2"), ``` Then add `FeatherSQLiteDatabase` to your target dependencies: @@ -45,7 +49,11 @@ Then add `FeatherSQLiteDatabase` to your target dependencies: ## Usage -![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +[ + ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +]( + https://feather-framework.github.io/feather-sqlite-database/documentation/feathersqlitedatabase/ +) API documentation is available at the following link. @@ -104,7 +112,7 @@ The following database driver implementations are available for use: - Build: `swift build` - Test: - local: `swift test` - - using Docker: `swift docker-test` + - using Docker: `make docker-test` - Format: `make format` - Check: `make check` diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift index cd8c98b..95d7dc0 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift @@ -42,10 +42,10 @@ public struct SQLiteDatabaseClient: DatabaseClient { /// - Throws: A `DatabaseError` if the connection fails. /// - Returns: The query result produced by the closure. @discardableResult - public func connection( + public func connection( isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending SQLiteQueryResult - ) async throws(DatabaseError) -> sending SQLiteQueryResult { + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { do { return try await closure(connection) } @@ -66,10 +66,10 @@ public struct SQLiteDatabaseClient: DatabaseClient { /// - Throws: A `DatabaseError` if transaction handling fails. /// - Returns: The query result produced by the closure. @discardableResult - public func transaction( + public func transaction( isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending SQLiteQueryResult - ) async throws(DatabaseError) -> sending SQLiteQueryResult { + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { do { try await connection.execute(query: "BEGIN;") From ed3e519a4f887b14d8623a2d313ef2e1c4dbfb3d Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Mon, 26 Jan 2026 19:08:35 +0100 Subject: [PATCH 02/14] PoC SQLiteConnectionPool --- .../FeatherSQLiteDatabase/SQLiteClient.swift | 164 +++++++++++ .../SQLiteConnectionPool.swift | 161 +++++++++++ .../SQLiteConnectionPoolError.swift | 10 + .../SQLiteClientTestSuite.swift | 263 ++++++++++++++++++ 4 files changed, 598 insertions(+) create mode 100644 Sources/FeatherSQLiteDatabase/SQLiteClient.swift create mode 100644 Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift create mode 100644 Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift create mode 100644 Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift new file mode 100644 index 0000000..17030dd --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -0,0 +1,164 @@ +// +// SQLiteClient.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +import FeatherDatabase +import Logging +import SQLiteNIO + +/// A SQLite client backed by a connection pool. +/// +/// Use this client to execute queries and transactions concurrently. +public final class SQLiteClient: Sendable, DatabaseClient { + + public typealias Connection = SQLiteConnection + + public struct Configuration: Sendable { + public struct Pool: Sendable { + public var minimumConnections: Int + public var maximumConnections: Int + + public init( + minimumConnections: Int = 0, + maximumConnections: Int = 4 + ) { + self.minimumConnections = minimumConnections + self.maximumConnections = maximumConnections + } + } + + public var storage: SQLiteConnection.Storage + public var pool: Pool + public var logger: Logger + + public init( + storage: SQLiteConnection.Storage, + pool: Pool = .init(), + logger: Logger = .init(label: "codes.feather.sqlite") + ) { + self.storage = storage + self.pool = pool + self.logger = logger + } + } + + private let pool: SQLiteConnectionPool + + public init(configuration: Configuration) { + self.pool = SQLiteConnectionPool( + configuration: .init( + storage: configuration.storage, + minimumConnections: configuration.pool.minimumConnections, + maximumConnections: configuration.pool.maximumConnections, + logger: configuration.logger + ) + ) + } + + /// Pre-open the minimum number of connections. + public func run() async throws { + try await pool.warmup() + } + + /// Close all pooled connections and refuse new leases. + public func shutdown() async { + await pool.shutdown() + } + + // MARK: - database api + + @discardableResult + public func connection( + isolation: isolated (any Actor)? = #isolation, + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { + let connection = try await leaseConnection() + do { + let result = try await closure(connection) + await pool.releaseConnection(connection) + return result + } + catch let error as DatabaseError { + await pool.releaseConnection(connection) + throw error + } + catch { + await pool.releaseConnection(connection) + throw .connection(error) + } + } + + @discardableResult + public func transaction( + isolation: isolated (any Actor)? = #isolation, + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { + let connection = try await leaseConnection() + do { + try await connection.execute(query: "BEGIN;") + } + catch { + await pool.releaseConnection(connection) + throw DatabaseError.transaction( + SQLiteTransactionError(beginError: error) + ) + } + + var closureHasFinished = false + + do { + let result = try await closure(connection) + closureHasFinished = true + + do { + try await connection.execute(query: "COMMIT;") + } + catch { + await pool.releaseConnection(connection) + throw DatabaseError.transaction( + SQLiteTransactionError(commitError: error) + ) + } + + await pool.releaseConnection(connection) + return result + } + catch { + var txError = SQLiteTransactionError() + + if !closureHasFinished { + txError.closureError = error + + do { + try await connection.execute(query: "ROLLBACK;") + } + catch { + txError.rollbackError = error + } + } + else { + txError.commitError = error + } + + await pool.releaseConnection(connection) + throw DatabaseError.transaction(txError) + } + } + + func connectionCount() async -> Int { + await pool.connectionCount() + } + + private func leaseConnection() async throws(DatabaseError) + -> SQLiteConnection { + do { + return try await pool.leaseConnection() + } + catch { + throw .connection(error) + } + } +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift new file mode 100644 index 0000000..9a2dc41 --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift @@ -0,0 +1,161 @@ +// +// SQLiteConnectionPool.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +import Logging +import SQLiteNIO + +actor SQLiteConnectionPool { + + struct Configuration: Sendable { + let storage: SQLiteConnection.Storage + let minimumConnections: Int + let maximumConnections: Int + let logger: Logger + + init( + storage: SQLiteConnection.Storage, + minimumConnections: Int, + maximumConnections: Int, + logger: Logger + ) { + precondition(minimumConnections >= 0) + precondition(maximumConnections >= 1) + precondition(minimumConnections <= maximumConnections) + self.storage = storage + self.minimumConnections = minimumConnections + self.maximumConnections = maximumConnections + self.logger = logger + } + } + + private struct Waiter { + let id: Int + let continuation: CheckedContinuation + } + + private let configuration: Configuration + private var availableConnections: [SQLiteConnection] = [] + private var waiters: [Waiter] = [] + private var totalConnections = 0 + private var nextWaiterID = 0 + private var isShutdown = false + + init(configuration: Configuration) { + self.configuration = configuration + } + + func warmup() async throws { + guard !isShutdown else { return } + let target = configuration.minimumConnections + guard totalConnections < target else { return } + let newConnections = target - totalConnections + for _ in 0.. SQLiteConnection { + guard !isShutdown else { + throw SQLiteConnectionPoolError.shutdown + } + + if let connection = availableConnections.popLast() { + return connection + } + + if totalConnections < configuration.maximumConnections { + totalConnections += 1 + do { + return try await makeConnection() + } + catch { + totalConnections -= 1 + throw error + } + } + + let waiterID = nextWaiterID + nextWaiterID += 1 + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiters.append( + Waiter(id: waiterID, continuation: continuation) + ) + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + func releaseConnection(_ connection: SQLiteConnection) async { + if isShutdown { + await closeConnection(connection) + return + } + + if waiters.isEmpty { + availableConnections.append(connection) + return + } + + let waiter = waiters.removeFirst() + waiter.continuation.resume(returning: connection) + } + + func shutdown() async { + guard !isShutdown else { return } + isShutdown = true + + let connections = availableConnections + availableConnections.removeAll(keepingCapacity: false) + + for connection in connections { + await closeConnection(connection) + } + + for waiter in waiters { + waiter.continuation.resume( + throwing: SQLiteConnectionPoolError.shutdown + ) + } + waiters.removeAll(keepingCapacity: false) + } + + func connectionCount() -> Int { + totalConnections + } + + private func cancelWaiter(id: Int) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + return + } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + private func makeConnection() async throws -> SQLiteConnection { + try await SQLiteConnection.open( + storage: configuration.storage, + logger: configuration.logger + ) + } + + private func closeConnection(_ connection: SQLiteConnection) async { + do { + try await connection.close() + } + catch { + configuration.logger.warning( + "Failed to close SQLite connection", + metadata: ["error": "\(error)"] + ) + } + } +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift new file mode 100644 index 0000000..32093aa --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift @@ -0,0 +1,10 @@ +// +// SQLiteConnectionPoolError.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +enum SQLiteConnectionPoolError: Error, Sendable { + case shutdown +} diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift new file mode 100644 index 0000000..a37664c --- /dev/null +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -0,0 +1,263 @@ +// +// SQLiteClientTestSuite.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +import Logging +import Testing +import FeatherDatabase +import SQLiteNIO + +@testable import FeatherSQLiteDatabase + +@Suite +struct SQLiteClientTestSuite { + + private func makeTemporaryDatabasePath() -> String { + let suffix = UInt64.random(in: 0...UInt64.max) + return "/tmp/feather-sqlite-\(suffix).sqlite" + } + + private func randomTableSuffix() -> String { + String(UInt64.random(in: 0...UInt64.max)) + } + + private func runUsingTestClient( + _ closure: (SQLiteClient) async throws -> Void + ) async throws { + var logger = Logger(label: "test.sqlite.client") + logger.logLevel = .info + + let configuration = SQLiteClient.Configuration( + storage: .file(path: makeTemporaryDatabasePath()), + pool: .init(minimumConnections: 0, maximumConnections: 8), + logger: logger + ) + let client = SQLiteClient(configuration: configuration) + + try await client.run() + defer { Task { await client.shutdown() } } + + try await closure(client) + } + + @Test + func concurrentTransactionsUseMultipleConnections() async throws { + var logger = Logger(label: "test.sqlite.client") + logger.logLevel = .info + + let configuration = SQLiteClient.Configuration( + storage: .file(path: makeTemporaryDatabasePath()), + pool: .init(minimumConnections: 0, maximumConnections: 2), + logger: logger + ) + let client = SQLiteClient(configuration: configuration) + + try await client.run() + + try await client.execute( + query: "PRAGMA journal_mode = WAL;" + ) + try await client.execute( + query: #""" + CREATE TABLE "items" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + async let first: Void = client.transaction { connection in + try await connection.execute(query: "PRAGMA busy_timeout = 1000;") + try await Task.sleep(for: .milliseconds(200)) + try await connection.execute( + query: #""" + INSERT INTO "items" + ("id", "name") + VALUES + (1, 'alpha'); + """# + ) + } + + async let second: Void = client.transaction { connection in + try await connection.execute(query: "PRAGMA busy_timeout = 1000;") + try await Task.sleep(for: .milliseconds(200)) + try await connection.execute( + query: #""" + INSERT INTO "items" + ("id", "name") + VALUES + (2, 'beta'); + """# + ) + } + + do { + + _ = try await (first, second) + + let result = try await client.execute( + query: #""" + SELECT COUNT(*) AS "count" + FROM "items"; + """# + ) + let rows = try await result.collect() + + #expect(try rows[0].decode(column: "count", as: Int.self) == 2) + #expect(await client.connectionCount() == 2) + } + catch { + Issue.record(error) + } + + await client.shutdown() + } + + @Test + func concurrentTransactionUpdates() async throws { + try await runUsingTestClient { database in + let suffix = randomTableSuffix() + let table = "sessions_\(suffix)" + let sessionID = "session_\(suffix)" + + enum TestError: Error { + case missingRow + } + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)"; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" TEXT NOT NULL PRIMARY KEY, + "access_token" TEXT NOT NULL, + "access_expires_at" INTEGER NOT NULL, + "refresh_token" TEXT NOT NULL, + "refresh_count" INTEGER NOT NULL DEFAULT 0 + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") + VALUES + ( + \#(sessionID), + 'stale', + (strftime('%s','now') - 300), + 'refresh', + 0 + ); + """# + ) + + func getValidAccessToken(sessionID: String) async throws -> String { + try await database.transaction { connection in + try await connection.execute( + query: "PRAGMA busy_timeout = 1000;" + ) + + let updateResult = try await connection.execute( + query: #""" + UPDATE "\#(unescaped: table)" + SET + "refresh_count" = "refresh_count" + 1, + "access_token" = 'token_' || ("refresh_count" + 1), + "access_expires_at" = (strftime('%s','now') + 600) + WHERE + "id" = \#(sessionID) + AND "access_expires_at" + <= (strftime('%s','now') + 60) + RETURNING "access_token"; + """# + ) + let updatedRows = try await updateResult.collect() + if let updatedRow = updatedRows.first { + return try updatedRow.decode( + column: "access_token", + as: String.self + ) + } + + let result = try await connection.execute( + query: #""" + SELECT + "access_token", + "refresh_count", + "access_expires_at" > (strftime('%s','now') + 60) AS "is_valid" + FROM "\#(unescaped: table)" + WHERE "id" = \#(sessionID); + """# + ) + let rows = try await result.collect() + + guard let row = rows.first else { + throw TestError.missingRow + } + + let isValid = try row.decode( + column: "is_valid", + as: Bool.self + ) + #expect(isValid == true) + + return try row.decode( + column: "access_token", + as: String.self + ) + } + } + + let workerCount = 80 + var tokens: [String] = [] + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. strftime('%s','now') AS "is_valid" + FROM "\#(unescaped: table)" + WHERE "id" = \#(sessionID); + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "refresh_count", as: Int.self) + == 1 + ) + #expect( + try result[0].decode(column: "access_token", as: String.self) + == "token_1" + ) + #expect( + try result[0].decode(column: "is_valid", as: Bool.self) + == true + ) + } + } +} From 8d79d32d1832a325020a5d6f6869682999f60024 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:16:22 +0100 Subject: [PATCH 03/14] add docc comments --- README.md | 11 +-- .../FeatherSQLiteDatabase/SQLiteClient.swift | 42 +++++++- .../SQLiteDatabaseClient.swift | 97 +++++-------------- .../SQLiteClientTestSuite.swift | 18 ++-- .../SQLiteDatabaseTestSuite.swift | 14 +-- 5 files changed, 86 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 0d7ce38..1899589 100644 --- a/README.md +++ b/README.md @@ -69,15 +69,14 @@ import FeatherSQLiteDatabase var logger = Logger(label: "example") logger.logLevel = .info -let connection = try await SQLiteConnection.open( +let configuration = SQLiteClient.Configuration( storage: .file(path: "/Users/me/db.sqlite"), + pool: .init(minimumConnections: 1, maximumConnections: 4), logger: logger ) -let database = SQLiteDatabaseClient( - connection: connection, - logger: logger -) +let database = SQLiteDatabaseClient(configuration: configuration) +try await database.run() let result = try await database.execute( query: #""" @@ -93,7 +92,7 @@ for try await item in result { print(version) } -try await connection.close() +await database.shutdown() ``` > [!WARNING] diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index 17030dd..c335e6d 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -14,13 +14,22 @@ import SQLiteNIO /// Use this client to execute queries and transactions concurrently. public final class SQLiteClient: Sendable, DatabaseClient { + /// The SQLite connection type leased from the pool. public typealias Connection = SQLiteConnection + /// Configuration options for a SQLite client. public struct Configuration: Sendable { + /// Connection pool settings. public struct Pool: Sendable { + /// Minimum number of pooled connections to keep open. public var minimumConnections: Int + /// Maximum number of pooled connections to allow. public var maximumConnections: Int + /// Create a connection pool configuration. + /// - Parameters: + /// - minimumConnections: The minimum number of connections to keep open. + /// - maximumConnections: The maximum number of connections to allow. public init( minimumConnections: Int = 0, maximumConnections: Int = 4 @@ -30,14 +39,22 @@ public final class SQLiteClient: Sendable, DatabaseClient { } } + /// The SQLite storage to open connections against. public var storage: SQLiteConnection.Storage + /// The connection pool configuration. public var pool: Pool + /// The logger used for pool operations. public var logger: Logger + /// Create a SQLite client configuration. + /// - Parameters: + /// - storage: The SQLite storage to use. + /// - pool: The pool configuration. + /// - logger: The logger for database operations. public init( storage: SQLiteConnection.Storage, - pool: Pool = .init(), - logger: Logger = .init(label: "codes.feather.sqlite") + pool: Pool, + logger: Logger ) { self.storage = storage self.pool = pool @@ -47,6 +64,8 @@ public final class SQLiteClient: Sendable, DatabaseClient { private let pool: SQLiteConnectionPool + /// Create a SQLite client with a connection pool. + /// - Parameter configuration: The client configuration. public init(configuration: Configuration) { self.pool = SQLiteConnectionPool( configuration: .init( @@ -70,6 +89,14 @@ public final class SQLiteClient: Sendable, DatabaseClient { // MARK: - database api + /// Execute work using a leased connection. + /// + /// The connection is returned to the pool when the closure completes. + /// - Parameters: + /// - isolation: The actor isolation to use for the closure. + /// - closure: A closure that receives a SQLite connection. + /// - Throws: A `DatabaseError` if leasing or execution fails. + /// - Returns: The result produced by the closure. @discardableResult public func connection( isolation: isolated (any Actor)? = #isolation, @@ -91,6 +118,14 @@ public final class SQLiteClient: Sendable, DatabaseClient { } } + /// Execute work inside a SQLite transaction. + /// + /// The transaction is committed on success and rolled back on failure. + /// - Parameters: + /// - isolation: The actor isolation to use for the closure. + /// - closure: A closure that receives a SQLite connection. + /// - Throws: A `DatabaseError` if transaction handling fails. + /// - Returns: The result produced by the closure. @discardableResult public func transaction( isolation: isolated (any Actor)? = #isolation, @@ -153,7 +188,8 @@ public final class SQLiteClient: Sendable, DatabaseClient { } private func leaseConnection() async throws(DatabaseError) - -> SQLiteConnection { + -> SQLiteConnection + { do { return try await pool.leaseConnection() } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift index 95d7dc0..946be84 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift @@ -6,7 +6,6 @@ // import FeatherDatabase -import Logging import SQLiteNIO /// A SQLite-backed database client. @@ -14,28 +13,37 @@ import SQLiteNIO /// Use this client to execute queries and manage transactions on SQLite. public struct SQLiteDatabaseClient: DatabaseClient { - var connection: SQLiteConnection - var logger: Logger + private let client: SQLiteClient - /// Create a SQLite database client. + /// Create a SQLite database client backed by a connection pool. /// - /// Use this initializer to provide an already-open connection. - /// - Parameters: - /// - connection: The SQLite connection to use. - /// - logger: The logger for database operations. - public init( - connection: SQLiteConnection, - logger: Logger - ) { - self.connection = connection - self.logger = logger + /// - Parameter client: The SQLite client to use. + public init(client: SQLiteClient) { + self.client = client + } + + /// Create a SQLite database client backed by a connection pool. + /// + /// - Parameter configuration: The SQLite client configuration. + public init(configuration: SQLiteClient.Configuration) { + self.client = SQLiteClient(configuration: configuration) + } + + /// Pre-open the minimum number of connections. + public func run() async throws { + try await client.run() + } + + /// Close all pooled connections and refuse new leases. + public func shutdown() async { + await client.shutdown() } // MARK: - database api - /// Execute work using the stored connection. + /// Execute work using a leased connection. /// - /// The closure is executed with the current connection. + /// The closure is executed with a pooled connection. /// - Parameters: /// - isolation: The actor isolation to use for the closure. /// - closure: A closure that receives the SQLite connection. @@ -46,15 +54,7 @@ public struct SQLiteDatabaseClient: DatabaseClient { isolation: isolated (any Actor)? = #isolation, _ closure: (SQLiteConnection) async throws -> sending T ) async throws(DatabaseError) -> sending T { - do { - return try await closure(connection) - } - catch let error as DatabaseError { - throw error - } - catch { - throw .connection(error) - } + try await client.connection(isolation: isolation, closure) } /// Execute work inside a SQLite transaction. @@ -70,52 +70,7 @@ public struct SQLiteDatabaseClient: DatabaseClient { isolation: isolated (any Actor)? = #isolation, _ closure: (SQLiteConnection) async throws -> sending T ) async throws(DatabaseError) -> sending T { - - do { - try await connection.execute(query: "BEGIN;") - } - catch { - throw DatabaseError.transaction( - SQLiteTransactionError(beginError: error) - ) - } - - var closureHasFinished = false - - do { - let result = try await closure(connection) - closureHasFinished = true - - do { - try await connection.execute(query: "COMMIT;") - } - catch { - throw DatabaseError.transaction( - SQLiteTransactionError(commitError: error) - ) - } - - return result - } - catch { - var txError = SQLiteTransactionError() - - if !closureHasFinished { - txError.closureError = error - - do { - try await connection.execute(query: "ROLLBACK;") - } - catch { - txError.rollbackError = error - } - } - else { - txError.commitError = error - } - - throw DatabaseError.transaction(txError) - } + try await client.transaction(isolation: isolation, closure) } } diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift index a37664c..89d94ce 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -5,10 +5,10 @@ // Created by Tibor Bödecs on 2026. 01. 26.. // -import Logging -import Testing import FeatherDatabase +import Logging import SQLiteNIO +import Testing @testable import FeatherSQLiteDatabase @@ -94,19 +94,19 @@ struct SQLiteClientTestSuite { """# ) } - + do { - + _ = try await (first, second) - + let result = try await client.execute( query: #""" - SELECT COUNT(*) AS "count" - FROM "items"; - """# + SELECT COUNT(*) AS "count" + FROM "items"; + """# ) let rows = try await result.collect() - + #expect(try rows[0].decode(column: "count", as: Int.self) == 2) #expect(await client.connectionCount() == 2) } diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift index 3e700ec..d65dfe8 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift @@ -21,19 +21,19 @@ struct SQLiteDatabaseTestSuite { var logger = Logger(label: "test") logger.logLevel = .info - let connection = try await SQLiteConnection.open( + let configuration = SQLiteClient.Configuration( storage: .memory, + pool: .init(minimumConnections: 1, maximumConnections: 1), logger: logger ) - let database = SQLiteDatabaseClient( - connection: connection, - logger: logger - ) + let client = SQLiteClient(configuration: configuration) - try await closure(database) + let database = SQLiteDatabaseClient(client: client) - try await connection.close() + try await client.run() + try await closure(database) + await client.shutdown() } @Test From 80391001c4b8f809a14009f89238a696f2043c05 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:17:39 +0100 Subject: [PATCH 04/14] update readme --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1899589..c77ae93 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,14 @@ logger.logLevel = .info let configuration = SQLiteClient.Configuration( storage: .file(path: "/Users/me/db.sqlite"), - pool: .init(minimumConnections: 1, maximumConnections: 4), + pool: .init(minimumConnections: 1, maximumConnections: 1), logger: logger ) -let database = SQLiteDatabaseClient(configuration: configuration) -try await database.run() +let client = SQLiteClient(configuration: configuration) +try await client.run() + +let database = SQLiteDatabaseClient(client: client) let result = try await database.execute( query: #""" @@ -92,7 +94,7 @@ for try await item in result { print(version) } -await database.shutdown() +await client.shutdown() ``` > [!WARNING] From ebf399514ed728f0703dd7218ac719ac71541f00 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:19:05 +0100 Subject: [PATCH 05/14] fix docc --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 5cd199f..065ec5a 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.0"), .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"), + // [docc-plugin-placeholder] ], targets: [ .target( From 76cc61fdc90c69bbc0fe8c21cf5aeac12a7122f0 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:30:07 +0100 Subject: [PATCH 06/14] ci fix test attempt --- .../SQLiteClientTestSuite.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift index 89d94ce..74a8ac4 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -128,6 +128,12 @@ struct SQLiteClientTestSuite { case missingRow } + try await database.execute( + query: "PRAGMA journal_mode = WAL;" + ) + try await database.execute( + query: "PRAGMA busy_timeout = 5000;" + ) try await database.execute( query: #""" DROP TABLE IF EXISTS "\#(unescaped: table)"; @@ -163,7 +169,7 @@ struct SQLiteClientTestSuite { func getValidAccessToken(sessionID: String) async throws -> String { try await database.transaction { connection in try await connection.execute( - query: "PRAGMA busy_timeout = 1000;" + query: "PRAGMA busy_timeout = 5000;" ) let updateResult = try await connection.execute( From e4e00fc956a35526057277ef97662f1a5bd79c51 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:41:08 +0100 Subject: [PATCH 07/14] ci fix attempt --- .../SQLiteConnection.swift | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift index a74b7cf..fba96b7 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift @@ -20,15 +20,38 @@ extension SQLiteConnection: @retroactive DatabaseConnection { public func execute( query: SQLiteQuery ) async throws(DatabaseError) -> SQLiteQueryResult { - do { - let result = try await self.query( - query.sql, - query.bindings - ) - return SQLiteQueryResult(elements: result) - } - catch { - throw .query(error) + let maxAttempts = 8 + var attempt = 0 + + while true { + do { + let result = try await self.query( + query.sql, + query.bindings + ) + return SQLiteQueryResult(elements: result) + } + catch { + attempt += 1 + + if attempt >= maxAttempts || !isBusyError(error) { + throw .query(error) + } + + let delayMilliseconds = min(1000, 25 << (attempt - 1)) + + do { + try await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + catch { + throw .query(error) + } + } } } + + private func isBusyError(_ error: Error) -> Bool { + let message = String(describing: error).lowercased() + return message.contains("database is locked") || message.contains("busy") + } } From c5186b56533f30fc7b07aaa2969ef43d4992678b Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 09:59:39 +0100 Subject: [PATCH 08/14] better pool --- README.md | 1 - .../FeatherSQLiteDatabase/SQLiteClient.swift | 106 +++++++++++------- .../SQLiteConnection.swift | 41 ++----- .../SQLiteConnectionPool.swift | 55 ++++----- .../SQLiteConnectionPoolError.swift | 10 -- .../SQLiteClientTestSuite.swift | 4 +- .../SQLiteDatabaseTestSuite.swift | 1 - 7 files changed, 108 insertions(+), 110 deletions(-) delete mode 100644 Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift diff --git a/README.md b/README.md index c77ae93..6bd28f5 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ logger.logLevel = .info let configuration = SQLiteClient.Configuration( storage: .file(path: "/Users/me/db.sqlite"), - pool: .init(minimumConnections: 1, maximumConnections: 1), logger: logger ) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index c335e6d..671eb67 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -12,53 +12,66 @@ import SQLiteNIO /// A SQLite client backed by a connection pool. /// /// Use this client to execute queries and transactions concurrently. -public final class SQLiteClient: Sendable, DatabaseClient { +public final class SQLiteClient: Sendable { - /// The SQLite connection type leased from the pool. - public typealias Connection = SQLiteConnection - - /// Configuration options for a SQLite client. + /// Configuration values for a pooled SQLite client. public struct Configuration: Sendable { - /// Connection pool settings. - public struct Pool: Sendable { - /// Minimum number of pooled connections to keep open. - public var minimumConnections: Int - /// Maximum number of pooled connections to allow. - public var maximumConnections: Int - - /// Create a connection pool configuration. - /// - Parameters: - /// - minimumConnections: The minimum number of connections to keep open. - /// - maximumConnections: The maximum number of connections to allow. - public init( - minimumConnections: Int = 0, - maximumConnections: Int = 4 - ) { - self.minimumConnections = minimumConnections - self.maximumConnections = maximumConnections - } + + /// SQLite journal mode options for new connections. + public enum JournalMode: String, Sendable { + /// Roll back changes by copying the original content. + case delete = "DELETE" + /// Roll back changes by truncating the rollback journal. + case truncate = "TRUNCATE" + /// Roll back changes by zeroing the journal header. + case persist = "PERSIST" + /// Keep the journal in memory. + case memory = "MEMORY" + /// Use write-ahead logging to improve concurrency. + case wal = "WAL" + /// Disable the rollback journal. + case off = "OFF" } /// The SQLite storage to open connections against. - public var storage: SQLiteConnection.Storage - /// The connection pool configuration. - public var pool: Pool - /// The logger used for pool operations. - public var logger: Logger + public let storage: SQLiteConnection.Storage + /// Minimum number of pooled connections to keep open. + public let minimumConnections: Int + /// Maximum number of pooled connections to allow. + public let maximumConnections: Int + /// Logger used for pool operations. + public let logger: Logger + /// Journal mode applied to each pooled connection. + public let journalMode: JournalMode + /// Busy timeout, in milliseconds, applied to each pooled connection. + public let busyTimeoutMilliseconds: Int /// Create a SQLite client configuration. /// - Parameters: /// - storage: The SQLite storage to use. - /// - pool: The pool configuration. /// - logger: The logger for database operations. + /// - minimumConnections: The minimum number of pooled connections. + /// - maximumConnections: The maximum number of pooled connections. + /// - journalMode: The journal mode to apply to connections. + /// - busyTimeoutMilliseconds: The busy timeout to apply, in milliseconds. public init( storage: SQLiteConnection.Storage, - pool: Pool, - logger: Logger + logger: Logger, + minimumConnections: Int = 1, + maximumConnections: Int = 8, + journalMode: JournalMode = .wal, + busyTimeoutMilliseconds: Int = 1000 ) { + precondition(minimumConnections >= 0) + precondition(maximumConnections >= 1) + precondition(minimumConnections <= maximumConnections) + precondition(busyTimeoutMilliseconds >= 0) self.storage = storage - self.pool = pool + self.minimumConnections = minimumConnections + self.maximumConnections = maximumConnections self.logger = logger + self.journalMode = journalMode + self.busyTimeoutMilliseconds = busyTimeoutMilliseconds } } @@ -68,15 +81,12 @@ public final class SQLiteClient: Sendable, DatabaseClient { /// - Parameter configuration: The client configuration. public init(configuration: Configuration) { self.pool = SQLiteConnectionPool( - configuration: .init( - storage: configuration.storage, - minimumConnections: configuration.pool.minimumConnections, - maximumConnections: configuration.pool.maximumConnections, - logger: configuration.logger - ) + configuration: configuration ) } + // MARK: - pool service + /// Pre-open the minimum number of connections. public func run() async throws { try await pool.warmup() @@ -89,6 +99,24 @@ public final class SQLiteClient: Sendable, DatabaseClient { // MARK: - database api + /// Execute a query using a managed connection. + /// + /// This default implementation executes the query inside `connection(_:)`. + /// - Parameters: + /// - isolation: The actor isolation to use for the duration of the call. + /// - query: The query to execute. + /// - Throws: A `DatabaseError` if execution fails. + /// - Returns: The query result. + @discardableResult + public func execute( + isolation: isolated (any Actor)? = #isolation, + query: SQLiteConnection.Query, + ) async throws(DatabaseError) -> SQLiteConnection.Result { + try await connection(isolation: isolation) { connection in + try await connection.execute(query: query) + } + } + /// Execute work using a leased connection. /// /// The connection is returned to the pool when the closure completes. @@ -183,6 +211,8 @@ public final class SQLiteClient: Sendable, DatabaseClient { } } + // MARK: - pool + func connectionCount() async -> Int { await pool.connectionCount() } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift index fba96b7..a74b7cf 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift @@ -20,38 +20,15 @@ extension SQLiteConnection: @retroactive DatabaseConnection { public func execute( query: SQLiteQuery ) async throws(DatabaseError) -> SQLiteQueryResult { - let maxAttempts = 8 - var attempt = 0 - - while true { - do { - let result = try await self.query( - query.sql, - query.bindings - ) - return SQLiteQueryResult(elements: result) - } - catch { - attempt += 1 - - if attempt >= maxAttempts || !isBusyError(error) { - throw .query(error) - } - - let delayMilliseconds = min(1000, 25 << (attempt - 1)) - - do { - try await Task.sleep(for: .milliseconds(delayMilliseconds)) - } - catch { - throw .query(error) - } - } + do { + let result = try await self.query( + query.sql, + query.bindings + ) + return SQLiteQueryResult(elements: result) + } + catch { + throw .query(error) } - } - - private func isBusyError(_ error: Error) -> Bool { - let message = String(describing: error).lowercased() - return message.contains("database is locked") || message.contains("busy") } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift index 9a2dc41..0ad9edf 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift @@ -8,43 +8,25 @@ import Logging import SQLiteNIO -actor SQLiteConnectionPool { +enum SQLiteConnectionPoolError: Error, Sendable { + case shutdown +} - struct Configuration: Sendable { - let storage: SQLiteConnection.Storage - let minimumConnections: Int - let maximumConnections: Int - let logger: Logger - - init( - storage: SQLiteConnection.Storage, - minimumConnections: Int, - maximumConnections: Int, - logger: Logger - ) { - precondition(minimumConnections >= 0) - precondition(maximumConnections >= 1) - precondition(minimumConnections <= maximumConnections) - self.storage = storage - self.minimumConnections = minimumConnections - self.maximumConnections = maximumConnections - self.logger = logger - } - } +actor SQLiteConnectionPool { private struct Waiter { let id: Int let continuation: CheckedContinuation } - private let configuration: Configuration + private let configuration: SQLiteClient.Configuration private var availableConnections: [SQLiteConnection] = [] private var waiters: [Waiter] = [] private var totalConnections = 0 private var nextWaiterID = 0 private var isShutdown = false - init(configuration: Configuration) { + init(configuration: SQLiteClient.Configuration) { self.configuration = configuration } @@ -141,10 +123,33 @@ actor SQLiteConnectionPool { } private func makeConnection() async throws -> SQLiteConnection { - try await SQLiteConnection.open( + let connection = try await SQLiteConnection.open( storage: configuration.storage, logger: configuration.logger ) + do { + _ = try await connection.query( + "PRAGMA journal_mode = \(configuration.journalMode.rawValue);", + [] + ) + _ = try await connection.query( + "PRAGMA busy_timeout = \(configuration.busyTimeoutMilliseconds);", + [] + ) + } + catch { + do { + try await connection.close() + } + catch { + configuration.logger.warning( + "Failed to close SQLite connection after setup error", + metadata: ["error": "\(error)"] + ) + } + throw error + } + return connection } private func closeConnection(_ connection: SQLiteConnection) async { diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift deleted file mode 100644 index 32093aa..0000000 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPoolError.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// SQLiteConnectionPoolError.swift -// feather-sqlite-database -// -// Created by Tibor Bödecs on 2026. 01. 26.. -// - -enum SQLiteConnectionPoolError: Error, Sendable { - case shutdown -} diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift index 74a8ac4..8bc74b1 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -32,8 +32,7 @@ struct SQLiteClientTestSuite { let configuration = SQLiteClient.Configuration( storage: .file(path: makeTemporaryDatabasePath()), - pool: .init(minimumConnections: 0, maximumConnections: 8), - logger: logger + logger: logger, ) let client = SQLiteClient(configuration: configuration) @@ -50,7 +49,6 @@ struct SQLiteClientTestSuite { let configuration = SQLiteClient.Configuration( storage: .file(path: makeTemporaryDatabasePath()), - pool: .init(minimumConnections: 0, maximumConnections: 2), logger: logger ) let client = SQLiteClient(configuration: configuration) diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift index d65dfe8..c5dd7c0 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift @@ -23,7 +23,6 @@ struct SQLiteDatabaseTestSuite { let configuration = SQLiteClient.Configuration( storage: .memory, - pool: .init(minimumConnections: 1, maximumConnections: 1), logger: logger ) From 218fc103edb87abebb922b7077ff626fe618610f Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:03:44 +0100 Subject: [PATCH 09/14] try to fix ci --- .../FeatherSQLiteDatabase/SQLiteClient.swift | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index 671eb67..8d32cb2 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -102,6 +102,7 @@ public final class SQLiteClient: Sendable { /// Execute a query using a managed connection. /// /// This default implementation executes the query inside `connection(_:)`. + /// Busy errors are retried with an exponential backoff (up to 8 attempts). /// - Parameters: /// - isolation: The actor isolation to use for the duration of the call. /// - query: The query to execute. @@ -112,8 +113,29 @@ public final class SQLiteClient: Sendable { isolation: isolated (any Actor)? = #isolation, query: SQLiteConnection.Query, ) async throws(DatabaseError) -> SQLiteConnection.Result { - try await connection(isolation: isolation) { connection in - try await connection.execute(query: query) + let maxAttempts = 8 + var attempt = 0 + + while true { + do { + return try await connection(isolation: isolation) { + connection in + try await connection.execute(query: query) + } + } + catch let error as DatabaseError { + attempt += 1 + if attempt >= maxAttempts || !isBusyError(error) { + throw error + } + let delayMilliseconds = min(1000, 25 << (attempt - 1)) + do { + try await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + catch { + throw error + } + } } } @@ -227,4 +249,10 @@ public final class SQLiteClient: Sendable { throw .connection(error) } } + + private func isBusyError(_ error: DatabaseError) -> Bool { + let message = String(describing: error).lowercased() + return message.contains("database is locked") + || message.contains("busy") + } } From f1cf912e4a40cb78f8db6b5f6ccae60399b5f1be Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:17:12 +0100 Subject: [PATCH 10/14] try to fix ci --- Sources/FeatherSQLiteDatabase/SQLiteClient.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index 8d32cb2..d8e2a66 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -123,7 +123,7 @@ public final class SQLiteClient: Sendable { try await connection.execute(query: query) } } - catch let error as DatabaseError { + catch { attempt += 1 if attempt >= maxAttempts || !isBusyError(error) { throw error @@ -133,7 +133,7 @@ public final class SQLiteClient: Sendable { try await Task.sleep(for: .milliseconds(delayMilliseconds)) } catch { - throw error + throw .query(error) } } } From e02381944b17db0d432cccd9f2c5437565e8e1ee Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:23:36 +0100 Subject: [PATCH 11/14] try to fix ci --- .../FeatherSQLiteDatabase/SQLiteClient.swift | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index d8e2a66..02809bd 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -171,6 +171,7 @@ public final class SQLiteClient: Sendable { /// Execute work inside a SQLite transaction. /// /// The transaction is committed on success and rolled back on failure. + /// Busy errors are retried with an exponential backoff (up to 8 attempts). /// - Parameters: /// - isolation: The actor isolation to use for the closure. /// - closure: A closure that receives a SQLite connection. @@ -180,6 +181,59 @@ public final class SQLiteClient: Sendable { public func transaction( isolation: isolated (any Actor)? = #isolation, _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { + let maxAttempts = 8 + var attempt = 0 + + while true { + do { + return try await transactionOnce( + isolation: isolation, + closure + ) + } + catch { + attempt += 1 + if attempt >= maxAttempts || !isBusyError(error) { + throw error + } + let delayMilliseconds = min(1000, 25 << (attempt - 1)) + do { + try await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + catch { + throw .query(error) + } + } + } + } + + // MARK: - pool + + func connectionCount() async -> Int { + await pool.connectionCount() + } + + private func leaseConnection() async throws(DatabaseError) + -> SQLiteConnection + { + do { + return try await pool.leaseConnection() + } + catch { + throw .connection(error) + } + } + + private func isBusyError(_ error: DatabaseError) -> Bool { + let message = String(describing: error).lowercased() + return message.contains("database is locked") + || message.contains("busy") + } + + private func transactionOnce( + isolation: isolated (any Actor)? = #isolation, + _ closure: (SQLiteConnection) async throws -> sending T ) async throws(DatabaseError) -> sending T { let connection = try await leaseConnection() do { @@ -232,27 +286,4 @@ public final class SQLiteClient: Sendable { throw DatabaseError.transaction(txError) } } - - // MARK: - pool - - func connectionCount() async -> Int { - await pool.connectionCount() - } - - private func leaseConnection() async throws(DatabaseError) - -> SQLiteConnection - { - do { - return try await pool.leaseConnection() - } - catch { - throw .connection(error) - } - } - - private func isBusyError(_ error: DatabaseError) -> Bool { - let message = String(describing: error).lowercased() - return message.contains("database is locked") - || message.contains("busy") - } } From 7afa14c020c1ba46cdd0107bbb1cec74b7daf074 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:37:55 +0100 Subject: [PATCH 12/14] try to fix ci --- .../FeatherSQLiteDatabase/SQLiteClient.swift | 95 ++++--------------- .../SQLiteConnection.swift | 32 +++++-- 2 files changed, 42 insertions(+), 85 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index 02809bd..f2ab0ca 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -113,29 +113,8 @@ public final class SQLiteClient: Sendable { isolation: isolated (any Actor)? = #isolation, query: SQLiteConnection.Query, ) async throws(DatabaseError) -> SQLiteConnection.Result { - let maxAttempts = 8 - var attempt = 0 - - while true { - do { - return try await connection(isolation: isolation) { - connection in - try await connection.execute(query: query) - } - } - catch { - attempt += 1 - if attempt >= maxAttempts || !isBusyError(error) { - throw error - } - let delayMilliseconds = min(1000, 25 << (attempt - 1)) - do { - try await Task.sleep(for: .milliseconds(delayMilliseconds)) - } - catch { - throw .query(error) - } - } + try await connection(isolation: isolation) { connection in + try await connection.execute(query: query) } } @@ -181,59 +160,6 @@ public final class SQLiteClient: Sendable { public func transaction( isolation: isolated (any Actor)? = #isolation, _ closure: (SQLiteConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { - let maxAttempts = 8 - var attempt = 0 - - while true { - do { - return try await transactionOnce( - isolation: isolation, - closure - ) - } - catch { - attempt += 1 - if attempt >= maxAttempts || !isBusyError(error) { - throw error - } - let delayMilliseconds = min(1000, 25 << (attempt - 1)) - do { - try await Task.sleep(for: .milliseconds(delayMilliseconds)) - } - catch { - throw .query(error) - } - } - } - } - - // MARK: - pool - - func connectionCount() async -> Int { - await pool.connectionCount() - } - - private func leaseConnection() async throws(DatabaseError) - -> SQLiteConnection - { - do { - return try await pool.leaseConnection() - } - catch { - throw .connection(error) - } - } - - private func isBusyError(_ error: DatabaseError) -> Bool { - let message = String(describing: error).lowercased() - return message.contains("database is locked") - || message.contains("busy") - } - - private func transactionOnce( - isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending T ) async throws(DatabaseError) -> sending T { let connection = try await leaseConnection() do { @@ -286,4 +212,21 @@ public final class SQLiteClient: Sendable { throw DatabaseError.transaction(txError) } } + + // MARK: - pool + + func connectionCount() async -> Int { + await pool.connectionCount() + } + + private func leaseConnection() async throws(DatabaseError) + -> SQLiteConnection + { + do { + return try await pool.leaseConnection() + } + catch { + throw .connection(error) + } + } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift index a74b7cf..0a3f46f 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift @@ -20,15 +20,29 @@ extension SQLiteConnection: @retroactive DatabaseConnection { public func execute( query: SQLiteQuery ) async throws(DatabaseError) -> SQLiteQueryResult { - do { - let result = try await self.query( - query.sql, - query.bindings - ) - return SQLiteQueryResult(elements: result) - } - catch { - throw .query(error) + let maxAttempts = 8 + var attempt = 0 + while true { + do { + let result = try await self.query( + query.sql, + query.bindings + ) + return SQLiteQueryResult(elements: result) + } + catch { + attempt += 1 + if attempt >= maxAttempts { + throw .query(error) + } + let delayMilliseconds = min(1000, 25 << (attempt - 1)) + do { + try await Task.sleep(for: .milliseconds(delayMilliseconds)) + } + catch { + throw .query(error) + } + } } } } From def29a817f565d5af6cdd3b0012da66e79ceb8e7 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:43:24 +0100 Subject: [PATCH 13/14] minor changes --- .../SQLiteConnectionPool.swift | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift index 0ad9edf..e94d22b 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift @@ -26,7 +26,9 @@ actor SQLiteConnectionPool { private var nextWaiterID = 0 private var isShutdown = false - init(configuration: SQLiteClient.Configuration) { + init( + configuration: SQLiteClient.Configuration + ) { self.configuration = configuration } @@ -76,7 +78,9 @@ actor SQLiteConnectionPool { } } - func releaseConnection(_ connection: SQLiteConnection) async { + func releaseConnection( + _ connection: SQLiteConnection + ) async { if isShutdown { await closeConnection(connection) return @@ -114,7 +118,9 @@ actor SQLiteConnectionPool { totalConnections } - private func cancelWaiter(id: Int) { + private func cancelWaiter( + id: Int + ) { guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } @@ -128,13 +134,13 @@ actor SQLiteConnectionPool { logger: configuration.logger ) do { - _ = try await connection.query( - "PRAGMA journal_mode = \(configuration.journalMode.rawValue);", - [] + _ = try await connection.execute( + query: + "PRAGMA journal_mode = \(unescaped: configuration.journalMode.rawValue);" ) - _ = try await connection.query( - "PRAGMA busy_timeout = \(configuration.busyTimeoutMilliseconds);", - [] + _ = try await connection.execute( + query: + "PRAGMA busy_timeout = \(unescaped: String(configuration.busyTimeoutMilliseconds));" ) } catch { @@ -152,7 +158,9 @@ actor SQLiteConnectionPool { return connection } - private func closeConnection(_ connection: SQLiteConnection) async { + private func closeConnection( + _ connection: SQLiteConnection + ) async { do { try await connection.close() } From 9ec0f752fb90bd609b2c7ee1b12b5726ad84939a Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Tue, 27 Jan 2026 10:47:58 +0100 Subject: [PATCH 14/14] fk config --- .../FeatherSQLiteDatabase/SQLiteClient.swift | 13 +++++++++++++ .../SQLiteConnectionPool.swift | 4 ++++ .../SQLiteClientTestSuite.swift | 17 +---------------- .../SQLiteDatabaseTestSuite.swift | 4 ---- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift index f2ab0ca..ccb3364 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -33,6 +33,14 @@ public final class SQLiteClient: Sendable { case off = "OFF" } + /// SQLite foreign key enforcement options for new connections. + public enum ForeignKeysMode: String, Sendable { + /// Disable foreign key enforcement. + case off = "OFF" + /// Enable foreign key enforcement. + case on = "ON" + } + /// The SQLite storage to open connections against. public let storage: SQLiteConnection.Storage /// Minimum number of pooled connections to keep open. @@ -45,6 +53,8 @@ public final class SQLiteClient: Sendable { public let journalMode: JournalMode /// Busy timeout, in milliseconds, applied to each pooled connection. public let busyTimeoutMilliseconds: Int + /// Foreign key enforcement mode applied to each pooled connection. + public let foreignKeysMode: ForeignKeysMode /// Create a SQLite client configuration. /// - Parameters: @@ -53,6 +63,7 @@ public final class SQLiteClient: Sendable { /// - minimumConnections: The minimum number of pooled connections. /// - maximumConnections: The maximum number of pooled connections. /// - journalMode: The journal mode to apply to connections. + /// - foreignKeysMode: The foreign key enforcement mode to apply. /// - busyTimeoutMilliseconds: The busy timeout to apply, in milliseconds. public init( storage: SQLiteConnection.Storage, @@ -60,6 +71,7 @@ public final class SQLiteClient: Sendable { minimumConnections: Int = 1, maximumConnections: Int = 8, journalMode: JournalMode = .wal, + foreignKeysMode: ForeignKeysMode = .on, busyTimeoutMilliseconds: Int = 1000 ) { precondition(minimumConnections >= 0) @@ -71,6 +83,7 @@ public final class SQLiteClient: Sendable { self.maximumConnections = maximumConnections self.logger = logger self.journalMode = journalMode + self.foreignKeysMode = foreignKeysMode self.busyTimeoutMilliseconds = busyTimeoutMilliseconds } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift index e94d22b..a83cbab 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift @@ -142,6 +142,10 @@ actor SQLiteConnectionPool { query: "PRAGMA busy_timeout = \(unescaped: String(configuration.busyTimeoutMilliseconds));" ) + _ = try await connection.execute( + query: + "PRAGMA foreign_keys = \(unescaped: configuration.foreignKeysMode.rawValue);" + ) } catch { do { diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift index 8bc74b1..c2b8ebf 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -55,9 +55,6 @@ struct SQLiteClientTestSuite { try await client.run() - try await client.execute( - query: "PRAGMA journal_mode = WAL;" - ) try await client.execute( query: #""" CREATE TABLE "items" ( @@ -68,8 +65,7 @@ struct SQLiteClientTestSuite { ) async let first: Void = client.transaction { connection in - try await connection.execute(query: "PRAGMA busy_timeout = 1000;") - try await Task.sleep(for: .milliseconds(200)) + try await connection.execute( query: #""" INSERT INTO "items" @@ -81,8 +77,6 @@ struct SQLiteClientTestSuite { } async let second: Void = client.transaction { connection in - try await connection.execute(query: "PRAGMA busy_timeout = 1000;") - try await Task.sleep(for: .milliseconds(200)) try await connection.execute( query: #""" INSERT INTO "items" @@ -126,12 +120,6 @@ struct SQLiteClientTestSuite { case missingRow } - try await database.execute( - query: "PRAGMA journal_mode = WAL;" - ) - try await database.execute( - query: "PRAGMA busy_timeout = 5000;" - ) try await database.execute( query: #""" DROP TABLE IF EXISTS "\#(unescaped: table)"; @@ -166,9 +154,6 @@ struct SQLiteClientTestSuite { func getValidAccessToken(sessionID: String) async throws -> String { try await database.transaction { connection in - try await connection.execute( - query: "PRAGMA busy_timeout = 5000;" - ) let updateResult = try await connection.execute( query: #""" diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift index c5dd7c0..0b3231c 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift @@ -39,10 +39,6 @@ struct SQLiteDatabaseTestSuite { func foreignKeySupport() async throws { try await runUsingTestDatabaseClient { database in - try await database.execute( - query: "PRAGMA foreign_keys = ON" - ) - let result = try await database.execute( query: "PRAGMA foreign_keys"