From 70c0cc53030807d86ba0bc7b552226fc73ed6362 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 13 Aug 2026 00:11:42 +0200 Subject: [PATCH] Added a connection pool timeout to the Configuration type in the Persistence package. --- .../Sources/Public/Methods/Service.swift | 3 ++- .../Sources/Public/Types/Configuration.swift | 18 ++++++++++++------ .../Cases/Public/Methods/ProbeTests.swift | 6 ++++-- .../Cases/Public/Methods/ServiceTests.swift | 6 ++++-- Services/Website/README.md | 3 ++- .../Extensions/ConfigReader+Properties.swift | 10 +++++++--- .../AbsoluteConfigKey+Constants.swift | 2 ++ .../Extensions/ConfigKey+Constants.swift | 2 ++ .../Public/Extensions/Int+Constants.swift | 2 ++ .../Controllers/HealthControllerTests.swift | 3 ++- 10 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift index bf2dec2..51cad79 100644 --- a/Packages/Persistence/Sources/Public/Methods/Service.swift +++ b/Packages/Persistence/Sources/Public/Methods/Service.swift @@ -77,7 +77,8 @@ public struct Service: Sendable { database: configuration.name, tls: tls ), - maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop + maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop, + connectionPoolTimeout: .init(configuration.poolTimeout) ), as: .psql, isDefault: true diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift index 1cdfc2f..1a771f8 100644 --- a/Packages/Persistence/Sources/Public/Types/Configuration.swift +++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift @@ -17,6 +17,9 @@ public struct Configuration: Sendable { /// The password the connection authenticates with. let password: String + /// The longest a query waits for a pooled connection to become available before failing. + let poolTimeout: Duration + /// The port the database server listens on. let port: Int @@ -37,6 +40,7 @@ public struct Configuration: Sendable { /// - password: the password the connection authenticates with. /// - tls: the TLS posture used when connecting. /// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop. + /// - poolTimeout: the longest a query waits for a pooled connection to become available before failing. public init( host: String, port: Int, @@ -44,15 +48,17 @@ public struct Configuration: Sendable { username: String, password: String, tls: TLS, - maxConnectionsPerEventLoop: Int + maxConnectionsPerEventLoop: Int, + poolTimeout: Duration ) { self.host = host - self.port = port - self.name = name - self.username = username - self.password = password - self.tls = tls self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop + self.name = name + self.password = password + self.poolTimeout = poolTimeout + self.port = port + self.tls = tls + self.username = username } } diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift index 72875c8..c01ab7e 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -42,7 +42,8 @@ struct ProbeTests { username: "nobody", password: "nothing", tls: .off, - maxConnectionsPerEventLoop: 1 + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) ) ), logger: Logger(label: "test") @@ -72,7 +73,8 @@ struct ProbeTests { username: "nobody", password: "nothing", tls: .off, - maxConnectionsPerEventLoop: 1 + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) ) ), logger: Logger(label: "test") diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift index 8c7afd7..a374cd9 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -43,7 +43,8 @@ struct ServiceTests { username: "loud", password: "loud", tls: .off, - maxConnectionsPerEventLoop: 1 + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) ) ), logger: Logger(label: "test") @@ -165,7 +166,8 @@ private let postgresDriver: Persistence.Driver? = { username: environment["POSTGRES_TEST_USERNAME"] ?? "loud", password: environment["POSTGRES_TEST_PASSWORD"] ?? "loud", tls: .off, - maxConnectionsPerEventLoop: 2 + maxConnectionsPerEventLoop: 2, + poolTimeout: .seconds(10) ) ) }() diff --git a/Services/Website/README.md b/Services/Website/README.md index 8c99460..266710b 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -111,8 +111,9 @@ A dotted config key maps to an environment variable by upper-casing, splitting c | `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. | | `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. | | `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. | +| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Longest wait, in seconds, for a pooled connection to become available before the query fails. Ignored for `inMemory`. | -> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. +> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. When every connection is busy, a query waits up to `database.pool.timeout` for one to free up and then fails — this bounds how long requests stall on an exhausted pool. See [Persistence](#persistence-1) below for the workflow. diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 4f5bb32..bfadf67 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -60,8 +60,8 @@ package extension ConfigReader { /// The persistence backend the service runs against, derived from the `database.*` keys. /// /// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`, - /// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, and - /// `database.pool.maxPerEventLoop` keys. Any other driver value falls back to the in-memory database. + /// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, + /// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database. var driver: Driver { switch string( forKey: .Database.driver, @@ -94,7 +94,11 @@ package extension ConfigReader { maxConnectionsPerEventLoop: int( forKey: .Database.poolMaxPerEventLoop, default: .Database.poolMaxPerEventLoop - ) + ), + poolTimeout: .seconds(int( + forKey: .Database.poolTimeout, + default: .Database.poolTimeout + )) ) ) default: diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index b44f742..8cd3628 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -37,6 +37,8 @@ extension AbsoluteConfigKey { public static let tls: AbsoluteConfigKey = .init(.Database.tls) /// The absolute configuration key for the maximum pooled connections per event loop. public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop) + /// The absolute configuration key for the longest wait, in seconds, for a pooled connection to become available. + public static let poolTimeout: AbsoluteConfigKey = .init(.Database.poolTimeout) } /// A namespace for the HTTP server configuration keys, as absolute keys. public enum HTTP { diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index a6a6492..646a9da 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -37,6 +37,8 @@ extension ConfigKey { public static let tls: ConfigKey = "database.tls" /// The configuration key for the maximum pooled connections per event loop. public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop" + /// The configuration key for the longest wait, in seconds, for a pooled connection to become available. + public static let poolTimeout: ConfigKey = "database.pool.timeout" } /// A namespace for the HTTP server configuration keys. public enum HTTP { diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index a7eeda8..82f4772 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -21,5 +21,7 @@ extension Int { public static let port = 5_432 /// The default maximum pooled connections per event loop. public static let poolMaxPerEventLoop = 4 + /// The default longest wait, in seconds, for a pooled connection to become available (the driver's own default). + public static let poolTimeout = 10 } } diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift index 3a89f10..00480d5 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift @@ -78,7 +78,8 @@ struct HealthControllerTests { username: "nobody", password: "nothing", tls: .off, - maxConnectionsPerEventLoop: 1 + maxConnectionsPerEventLoop: 1, + poolTimeout: .seconds(10) ) ), logger: Logger(label: "test")