Skip to content

Commit c9b6270

Browse files
authored
Add basic connection pool support (#5)
- basic connection pool support - update Feather Database dependency - fix readme - new tests
1 parent 62a73fd commit c9b6270

9 files changed

Lines changed: 761 additions & 113 deletions

File tree

Package.resolved

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ let package = Package(
3737
dependencies: [
3838
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
3939
.package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.0"),
40-
.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"),
40+
.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"),
41+
// [docc-plugin-placeholder]
4142
],
4243
targets: [
4344
.target(

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
SQLite driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package.
44

5-
![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138)
5+
[
6+
![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)
7+
](
8+
https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.2
9+
)
610

711
## Features
812

@@ -33,7 +37,7 @@ SQLite driver implementation for the abstract [Feather Database](https://github.
3337
Add the dependency to your `Package.swift`:
3438

3539
```swift
36-
.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.1"),
40+
.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.2"),
3741
```
3842

3943
Then add `FeatherSQLiteDatabase` to your target dependencies:
@@ -45,7 +49,11 @@ Then add `FeatherSQLiteDatabase` to your target dependencies:
4549

4650
## Usage
4751

48-
![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)
52+
[
53+
![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)
54+
](
55+
https://feather-framework.github.io/feather-sqlite-database/documentation/feathersqlitedatabase/
56+
)
4957

5058
API documentation is available at the following link.
5159

@@ -61,15 +69,15 @@ import FeatherSQLiteDatabase
6169
var logger = Logger(label: "example")
6270
logger.logLevel = .info
6371

64-
let connection = try await SQLiteConnection.open(
72+
let configuration = SQLiteClient.Configuration(
6573
storage: .file(path: "/Users/me/db.sqlite"),
6674
logger: logger
6775
)
6876

69-
let database = SQLiteDatabaseClient(
70-
connection: connection,
71-
logger: logger
72-
)
77+
let client = SQLiteClient(configuration: configuration)
78+
try await client.run()
79+
80+
let database = SQLiteDatabaseClient(client: client)
7381

7482
let result = try await database.execute(
7583
query: #"""
@@ -85,7 +93,7 @@ for try await item in result {
8593
print(version)
8694
}
8795

88-
try await connection.close()
96+
await client.shutdown()
8997
```
9098

9199
> [!WARNING]
@@ -104,7 +112,7 @@ The following database driver implementations are available for use:
104112
- Build: `swift build`
105113
- Test:
106114
- local: `swift test`
107-
- using Docker: `swift docker-test`
115+
- using Docker: `make docker-test`
108116
- Format: `make format`
109117
- Check: `make check`
110118

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
//
2+
// SQLiteClient.swift
3+
// feather-sqlite-database
4+
//
5+
// Created by Tibor Bödecs on 2026. 01. 26..
6+
//
7+
8+
import FeatherDatabase
9+
import Logging
10+
import SQLiteNIO
11+
12+
/// A SQLite client backed by a connection pool.
13+
///
14+
/// Use this client to execute queries and transactions concurrently.
15+
public final class SQLiteClient: Sendable {
16+
17+
/// Configuration values for a pooled SQLite client.
18+
public struct Configuration: Sendable {
19+
20+
/// SQLite journal mode options for new connections.
21+
public enum JournalMode: String, Sendable {
22+
/// Roll back changes by copying the original content.
23+
case delete = "DELETE"
24+
/// Roll back changes by truncating the rollback journal.
25+
case truncate = "TRUNCATE"
26+
/// Roll back changes by zeroing the journal header.
27+
case persist = "PERSIST"
28+
/// Keep the journal in memory.
29+
case memory = "MEMORY"
30+
/// Use write-ahead logging to improve concurrency.
31+
case wal = "WAL"
32+
/// Disable the rollback journal.
33+
case off = "OFF"
34+
}
35+
36+
/// SQLite foreign key enforcement options for new connections.
37+
public enum ForeignKeysMode: String, Sendable {
38+
/// Disable foreign key enforcement.
39+
case off = "OFF"
40+
/// Enable foreign key enforcement.
41+
case on = "ON"
42+
}
43+
44+
/// The SQLite storage to open connections against.
45+
public let storage: SQLiteConnection.Storage
46+
/// Minimum number of pooled connections to keep open.
47+
public let minimumConnections: Int
48+
/// Maximum number of pooled connections to allow.
49+
public let maximumConnections: Int
50+
/// Logger used for pool operations.
51+
public let logger: Logger
52+
/// Journal mode applied to each pooled connection.
53+
public let journalMode: JournalMode
54+
/// Busy timeout, in milliseconds, applied to each pooled connection.
55+
public let busyTimeoutMilliseconds: Int
56+
/// Foreign key enforcement mode applied to each pooled connection.
57+
public let foreignKeysMode: ForeignKeysMode
58+
59+
/// Create a SQLite client configuration.
60+
/// - Parameters:
61+
/// - storage: The SQLite storage to use.
62+
/// - logger: The logger for database operations.
63+
/// - minimumConnections: The minimum number of pooled connections.
64+
/// - maximumConnections: The maximum number of pooled connections.
65+
/// - journalMode: The journal mode to apply to connections.
66+
/// - foreignKeysMode: The foreign key enforcement mode to apply.
67+
/// - busyTimeoutMilliseconds: The busy timeout to apply, in milliseconds.
68+
public init(
69+
storage: SQLiteConnection.Storage,
70+
logger: Logger,
71+
minimumConnections: Int = 1,
72+
maximumConnections: Int = 8,
73+
journalMode: JournalMode = .wal,
74+
foreignKeysMode: ForeignKeysMode = .on,
75+
busyTimeoutMilliseconds: Int = 1000
76+
) {
77+
precondition(minimumConnections >= 0)
78+
precondition(maximumConnections >= 1)
79+
precondition(minimumConnections <= maximumConnections)
80+
precondition(busyTimeoutMilliseconds >= 0)
81+
self.storage = storage
82+
self.minimumConnections = minimumConnections
83+
self.maximumConnections = maximumConnections
84+
self.logger = logger
85+
self.journalMode = journalMode
86+
self.foreignKeysMode = foreignKeysMode
87+
self.busyTimeoutMilliseconds = busyTimeoutMilliseconds
88+
}
89+
}
90+
91+
private let pool: SQLiteConnectionPool
92+
93+
/// Create a SQLite client with a connection pool.
94+
/// - Parameter configuration: The client configuration.
95+
public init(configuration: Configuration) {
96+
self.pool = SQLiteConnectionPool(
97+
configuration: configuration
98+
)
99+
}
100+
101+
// MARK: - pool service
102+
103+
/// Pre-open the minimum number of connections.
104+
public func run() async throws {
105+
try await pool.warmup()
106+
}
107+
108+
/// Close all pooled connections and refuse new leases.
109+
public func shutdown() async {
110+
await pool.shutdown()
111+
}
112+
113+
// MARK: - database api
114+
115+
/// Execute a query using a managed connection.
116+
///
117+
/// This default implementation executes the query inside `connection(_:)`.
118+
/// Busy errors are retried with an exponential backoff (up to 8 attempts).
119+
/// - Parameters:
120+
/// - isolation: The actor isolation to use for the duration of the call.
121+
/// - query: The query to execute.
122+
/// - Throws: A `DatabaseError` if execution fails.
123+
/// - Returns: The query result.
124+
@discardableResult
125+
public func execute(
126+
isolation: isolated (any Actor)? = #isolation,
127+
query: SQLiteConnection.Query,
128+
) async throws(DatabaseError) -> SQLiteConnection.Result {
129+
try await connection(isolation: isolation) { connection in
130+
try await connection.execute(query: query)
131+
}
132+
}
133+
134+
/// Execute work using a leased connection.
135+
///
136+
/// The connection is returned to the pool when the closure completes.
137+
/// - Parameters:
138+
/// - isolation: The actor isolation to use for the closure.
139+
/// - closure: A closure that receives a SQLite connection.
140+
/// - Throws: A `DatabaseError` if leasing or execution fails.
141+
/// - Returns: The result produced by the closure.
142+
@discardableResult
143+
public func connection<T>(
144+
isolation: isolated (any Actor)? = #isolation,
145+
_ closure: (SQLiteConnection) async throws -> sending T
146+
) async throws(DatabaseError) -> sending T {
147+
let connection = try await leaseConnection()
148+
do {
149+
let result = try await closure(connection)
150+
await pool.releaseConnection(connection)
151+
return result
152+
}
153+
catch let error as DatabaseError {
154+
await pool.releaseConnection(connection)
155+
throw error
156+
}
157+
catch {
158+
await pool.releaseConnection(connection)
159+
throw .connection(error)
160+
}
161+
}
162+
163+
/// Execute work inside a SQLite transaction.
164+
///
165+
/// The transaction is committed on success and rolled back on failure.
166+
/// Busy errors are retried with an exponential backoff (up to 8 attempts).
167+
/// - Parameters:
168+
/// - isolation: The actor isolation to use for the closure.
169+
/// - closure: A closure that receives a SQLite connection.
170+
/// - Throws: A `DatabaseError` if transaction handling fails.
171+
/// - Returns: The result produced by the closure.
172+
@discardableResult
173+
public func transaction<T>(
174+
isolation: isolated (any Actor)? = #isolation,
175+
_ closure: (SQLiteConnection) async throws -> sending T
176+
) async throws(DatabaseError) -> sending T {
177+
let connection = try await leaseConnection()
178+
do {
179+
try await connection.execute(query: "BEGIN;")
180+
}
181+
catch {
182+
await pool.releaseConnection(connection)
183+
throw DatabaseError.transaction(
184+
SQLiteTransactionError(beginError: error)
185+
)
186+
}
187+
188+
var closureHasFinished = false
189+
190+
do {
191+
let result = try await closure(connection)
192+
closureHasFinished = true
193+
194+
do {
195+
try await connection.execute(query: "COMMIT;")
196+
}
197+
catch {
198+
await pool.releaseConnection(connection)
199+
throw DatabaseError.transaction(
200+
SQLiteTransactionError(commitError: error)
201+
)
202+
}
203+
204+
await pool.releaseConnection(connection)
205+
return result
206+
}
207+
catch {
208+
var txError = SQLiteTransactionError()
209+
210+
if !closureHasFinished {
211+
txError.closureError = error
212+
213+
do {
214+
try await connection.execute(query: "ROLLBACK;")
215+
}
216+
catch {
217+
txError.rollbackError = error
218+
}
219+
}
220+
else {
221+
txError.commitError = error
222+
}
223+
224+
await pool.releaseConnection(connection)
225+
throw DatabaseError.transaction(txError)
226+
}
227+
}
228+
229+
// MARK: - pool
230+
231+
func connectionCount() async -> Int {
232+
await pool.connectionCount()
233+
}
234+
235+
private func leaseConnection() async throws(DatabaseError)
236+
-> SQLiteConnection
237+
{
238+
do {
239+
return try await pool.leaseConnection()
240+
}
241+
catch {
242+
throw .connection(error)
243+
}
244+
}
245+
}

Sources/FeatherSQLiteDatabase/SQLiteConnection.swift

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,29 @@ extension SQLiteConnection: @retroactive DatabaseConnection {
2020
public func execute(
2121
query: SQLiteQuery
2222
) async throws(DatabaseError) -> SQLiteQueryResult {
23-
do {
24-
let result = try await self.query(
25-
query.sql,
26-
query.bindings
27-
)
28-
return SQLiteQueryResult(elements: result)
29-
}
30-
catch {
31-
throw .query(error)
23+
let maxAttempts = 8
24+
var attempt = 0
25+
while true {
26+
do {
27+
let result = try await self.query(
28+
query.sql,
29+
query.bindings
30+
)
31+
return SQLiteQueryResult(elements: result)
32+
}
33+
catch {
34+
attempt += 1
35+
if attempt >= maxAttempts {
36+
throw .query(error)
37+
}
38+
let delayMilliseconds = min(1000, 25 << (attempt - 1))
39+
do {
40+
try await Task.sleep(for: .milliseconds(delayMilliseconds))
41+
}
42+
catch {
43+
throw .query(error)
44+
}
45+
}
3246
}
3347
}
3448
}

0 commit comments

Comments
 (0)