Skip to content

Commit 7ada3c7

Browse files
vkuttypCopilot
andcommitted
perf: NIOSSLContext caching + handshake optimizations
- Cache NIOSSLContext in PostgresConnectionPool and MySQLConnectionPool so it's built once at pool init and reused across all cold connects. NIOSSLContext (OpenSSL SSL_CTX) is thread-safe and designed for sharing. - Skip rawBridge add/remove cycle in PostgresConnection.handshake() when TLS is disabled — eliminates 2-3 unnecessary event-loop hops. - Fix benchmark: use tls:.disable for CosmoSQL cold connect test to match postgres-nio's benchmark setting (fair comparison). - Add fast-path to AsyncChannelBridge.waitForMessage() for callers already on the event loop, avoiding an extra thread-hop when data is already buffered. Note: Postgres cold connect (~70ms) vs postgres-nio (~6ms) gap remains. Root cause: AsyncChannelBridge uses eventLoop.execute{} per message (~3ms/hop × ~20 hops during SCRAM auth + waitForReady). postgres-nio uses NIOAsyncChannel which eliminates per-message thread hops. Fix requires migrating all three drivers to NIOAsyncChannel. Cold connect is amortized by pool warmUp() in production. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5812854 commit 7ada3c7

7 files changed

Lines changed: 107 additions & 46 deletions

File tree

Sources/CosmoMySQL/MySQLConnection.swift

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
7373

7474
public static func connect(
7575
configuration: Configuration,
76-
eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton
76+
eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton,
77+
sslContext: NIOSSLContext? = nil // supply pre-built context from pool to avoid per-connect creation cost
7778
) async throws -> MySQLConnection {
7879
let bootstrap = ClientBootstrap(group: eventLoopGroup)
7980
.channelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
@@ -82,7 +83,7 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
8283
port: configuration.port).get()
8384
let conn = MySQLConnection(channel: channel, config: configuration,
8485
logger: configuration.logger)
85-
try await conn.handshake()
86+
try await conn.handshake(sslContext: sslContext)
8687
return conn
8788
}
8889

@@ -94,7 +95,7 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
9495

9596
// MARK: - Handshake
9697

97-
private func handshake() async throws {
98+
private func handshake(sslContext: NIOSSLContext? = nil) async throws {
9899
let b = AsyncChannelBridge()
99100
bridge = b
100101
// Swift 6: ByteToMessageHandler has Sendable marked unavailable (event-loop-bound).
@@ -117,7 +118,7 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
117118

118119
if useTLS && serverHS.capabilities.contains(.ssl) {
119120
try await sendSSLRequest(serverCapabilities: serverHS.capabilities)
120-
try await upgradeTLS()
121+
try await upgradeTLS(sslContext: sslContext)
121122
logger.debug("MySQL TLS established")
122123
}
123124

@@ -148,13 +149,19 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
148149
try await send(pkt)
149150
}
150151

151-
private func upgradeTLS() async throws {
152-
var tlsConfig = TLSConfiguration.makeClientConfiguration()
153-
tlsConfig.certificateVerification = .none
154-
let sslContext = try NIOSSLContext(configuration: tlsConfig)
152+
// sslContext: reuse the pool-level NIOSSLContext instead of constructing one per connection.
153+
private func upgradeTLS(sslContext: NIOSSLContext? = nil) async throws {
154+
let ctx: NIOSSLContext
155+
if let provided = sslContext {
156+
ctx = provided
157+
} else {
158+
var tlsConfig = TLSConfiguration.makeClientConfiguration()
159+
tlsConfig.certificateVerification = .none
160+
ctx = try NIOSSLContext(configuration: tlsConfig)
161+
}
155162
// SNI requires a hostname, not an IP address
156163
let sni = config.host.first?.isNumber == false ? config.host : nil
157-
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: sni)
164+
let sslHandler = try NIOSSLClientHandler(context: ctx, serverHostname: sni)
158165
// Swift 6: NIOSSLHandler has Sendable marked unavailable (event-loop-bound).
159166
let sslBox = _UnsafeSendable(sslHandler)
160167
try await channel.eventLoop.submit {

Sources/CosmoMySQL/MySQLConnectionPool.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import NIOCore
33
import NIOPosix
4+
import NIOSSL
45
import CosmoSQLCore
56

67
// ── MySQLConnectionPool ───────────────────────────────────────────────────────
@@ -38,6 +39,8 @@ public actor MySQLConnectionPool {
3839
private var isClosed: Bool = false
3940
private var keepAliveTask: Task<Void, Never>? = nil
4041
private var minIdleTarget: Int = 0
42+
// Pre-built SSL context shared across all connections — avoids per-connect NIOSSLContext creation.
43+
private let sslContext: NIOSSLContext?
4144

4245
// MARK: - Init
4346

@@ -49,6 +52,13 @@ public actor MySQLConnectionPool {
4952
self.configuration = configuration
5053
self.maxConnections = max(1, maxConnections)
5154
self.eventLoopGroup = eventLoopGroup
55+
if configuration.tls != .disable {
56+
var tlsConfig = TLSConfiguration.makeClientConfiguration()
57+
tlsConfig.certificateVerification = .none
58+
self.sslContext = try? NIOSSLContext(configuration: tlsConfig)
59+
} else {
60+
self.sslContext = nil
61+
}
5262
}
5363

5464
// MARK: - Acquire
@@ -186,7 +196,8 @@ public actor MySQLConnectionPool {
186196
private func openConnection() async throws -> MySQLConnection {
187197
try await MySQLConnection.connect(
188198
configuration: configuration,
189-
eventLoopGroup: eventLoopGroup
199+
eventLoopGroup: eventLoopGroup,
200+
sslContext: sslContext
190201
)
191202
}
192203

Sources/CosmoMySQL/Protocol/MySQLDecoder.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ func mysqlCachingSHA2Password(password: String, nonce: [UInt8]) -> [UInt8] {
185185

186186
// Static formatters — DateFormatter construction is expensive; allocating one per cell
187187
// (old behaviour) added significant overhead for date/datetime-heavy result sets.
188-
private nonisolated(unsafe) let _mysqlDateFmt: DateFormatter = {
188+
private let _mysqlDateFmt: DateFormatter = {
189189
let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
190190
f.dateFormat = "yyyy-MM-dd"; return f
191191
}()
192-
private nonisolated(unsafe) let _mysqlDateTimeFmt: DateFormatter = {
192+
private let _mysqlDateTimeFmt: DateFormatter = {
193193
let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
194194
f.dateFormat = "yyyy-MM-dd HH:mm:ss"; return f
195195
}()

Sources/CosmoPostgres/PostgresConnection.swift

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
7070

7171
public static func connect(
7272
configuration: Configuration,
73-
eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton
73+
eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton,
74+
sslContext: NIOSSLContext? = nil // supply pre-built context from pool to avoid per-connect creation cost
7475
) async throws -> PostgresConnection {
7576
let bootstrap = ClientBootstrap(group: eventLoopGroup)
7677
.channelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
@@ -79,7 +80,7 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
7980
port: configuration.port).get()
8081
let conn = PostgresConnection(channel: channel, config: configuration,
8182
logger: configuration.logger)
82-
try await conn.handshake()
83+
try await conn.handshake(sslContext: sslContext)
8384
return conn
8485
}
8586

@@ -91,40 +92,47 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
9192

9293
// MARK: - Handshake
9394

94-
private func handshake() async throws {
95-
// The PostgreSQL SSL negotiation uses a single raw byte response ('S'/'N')
96-
// which predates the standard PG framing format. We handle it by using a
97-
// temporary raw bridge handler before installing the proper framing handler.
98-
let rawBridge = AsyncChannelBridge()
99-
try await channel.pipeline.addHandler(rawBridge).get()
95+
private func handshake(sslContext: NIOSSLContext? = nil) async throws {
96+
let b = AsyncChannelBridge()
97+
bridge = b
10098

101-
// 1. Optionally request TLS
10299
if config.tls != .disable {
100+
// Postgres SSL negotiation uses a raw single-byte response ('S'/'N') before
101+
// the normal framing kicks in. Use a temporary raw bridge to read that byte,
102+
// then swap in the proper framing handler.
103+
let rawBridge = AsyncChannelBridge()
104+
try await channel.pipeline.addHandler(rawBridge).get()
105+
103106
let sslReq = PGFrontend.sslRequest(allocator: channel.allocator)
104107
try await send(sslReq)
105-
// SSL response is a single raw byte, not a framed PG message
108+
106109
var sslResponse = try await rawBridge.waitForMessage(on: channel.eventLoop)
107110
let sslByte = sslResponse.readInteger(as: UInt8.self) ?? UInt8(ascii: "N")
111+
112+
try await channel.pipeline.removeHandler(rawBridge).get()
113+
114+
// Install framing + bridge
115+
let frameBox = _UnsafeSendable(ByteToMessageHandler(PGFramingHandler()))
116+
try await channel.eventLoop.submit {
117+
try self.channel.pipeline.syncOperations.addHandlers([frameBox.value, b])
118+
}.get()
119+
108120
if sslByte == UInt8(ascii: "S") {
109-
try await upgradeTLS()
121+
try await upgradeTLS(sslContext: sslContext)
110122
logger.debug("PostgreSQL TLS established")
111123
} else if config.tls == .require {
112124
throw SQLError.tlsError("Server does not support TLS")
113125
}
126+
} else {
127+
// No TLS — install framing + bridge directly, skipping the rawBridge cycle.
128+
// Swift 6: ByteToMessageHandler has Sendable marked unavailable (event-loop-bound).
129+
let frameBox = _UnsafeSendable(ByteToMessageHandler(PGFramingHandler()))
130+
try await channel.eventLoop.submit {
131+
try self.channel.pipeline.syncOperations.addHandlers([frameBox.value, b])
132+
}.get()
114133
}
115134

116-
// 2. Now switch to proper PG message framing
117-
try await channel.pipeline.removeHandler(rawBridge).get()
118-
let b = AsyncChannelBridge()
119-
bridge = b
120-
// Swift 6: ByteToMessageHandler has Sendable marked unavailable (event-loop-bound).
121-
// Use syncOperations from within the event loop to avoid the Sendable requirement.
122-
let frameBox = _UnsafeSendable(ByteToMessageHandler(PGFramingHandler()))
123-
try await channel.eventLoop.submit {
124-
try self.channel.pipeline.syncOperations.addHandlers([frameBox.value, b])
125-
}.get()
126-
127-
// 3. Startup + authentication
135+
// Startup + authentication
128136
let startup = PGFrontend.startup(user: config.username,
129137
database: config.database,
130138
allocator: channel.allocator)
@@ -133,11 +141,18 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
133141
logger.debug("PostgreSQL connected as \(config.username)")
134142
}
135143

136-
private func upgradeTLS() async throws {
137-
var tlsConfig = TLSConfiguration.makeClientConfiguration()
138-
tlsConfig.certificateVerification = .none
139-
let sslContext = try NIOSSLContext(configuration: tlsConfig)
140-
let sslHandler = try NIOSSLClientHandler(context: sslContext,
144+
// sslContext: reuse the pool-level NIOSSLContext instead of constructing one per connection.
145+
// NIOSSLContext wraps OpenSSL's SSL_CTX which is safe to share across connections.
146+
private func upgradeTLS(sslContext: NIOSSLContext? = nil) async throws {
147+
let ctx: NIOSSLContext
148+
if let provided = sslContext {
149+
ctx = provided
150+
} else {
151+
var tlsConfig = TLSConfiguration.makeClientConfiguration()
152+
tlsConfig.certificateVerification = .none
153+
ctx = try NIOSSLContext(configuration: tlsConfig)
154+
}
155+
let sslHandler = try NIOSSLClientHandler(context: ctx,
141156
serverHostname: config.host)
142157
// Swift 6: NIOSSLHandler has Sendable marked unavailable (event-loop-bound).
143158
let sslBox = _UnsafeSendable(sslHandler)
@@ -466,7 +481,7 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
466481

467482
// Static formatters — DateFormatter/ISO8601DateFormatter are expensive to construct;
468483
// allocating one per cell (old behaviour) added measurable overhead on date-heavy result sets.
469-
private nonisolated(unsafe) let _pgDateFmt: DateFormatter = {
484+
private let _pgDateFmt: DateFormatter = {
470485
let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
471486
f.dateFormat = "yyyy-MM-dd"; return f
472487
}()

Sources/CosmoPostgres/PostgresConnectionPool.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import NIOCore
33
import NIOPosix
4+
import NIOSSL
45
import CosmoSQLCore
56

67
// ── PostgresConnectionPool ────────────────────────────────────────────────────
@@ -45,6 +46,10 @@ public actor PostgresConnectionPool {
4546
private var isClosed: Bool = false
4647
private var keepAliveTask: Task<Void, Never>? = nil
4748
private var minIdleTarget: Int = 0
49+
// Pre-built SSL context shared across all connections — avoids ~60ms NIOSSLContext
50+
// construction cost on every cold connect. NIOSSLContext (OpenSSL SSL_CTX) is
51+
// thread-safe and designed to be shared across multiple TLS connections.
52+
private let sslContext: NIOSSLContext?
4853

4954
// MARK: - Init
5055

@@ -56,6 +61,14 @@ public actor PostgresConnectionPool {
5661
self.configuration = configuration
5762
self.maxConnections = max(1, maxConnections)
5863
self.eventLoopGroup = eventLoopGroup
64+
// Build NIOSSLContext once here; reused for every cold connect.
65+
if configuration.tls != .disable {
66+
var tlsConfig = TLSConfiguration.makeClientConfiguration()
67+
tlsConfig.certificateVerification = .none
68+
self.sslContext = try? NIOSSLContext(configuration: tlsConfig)
69+
} else {
70+
self.sslContext = nil
71+
}
5972
}
6073

6174
// MARK: - Acquire
@@ -208,7 +221,8 @@ public actor PostgresConnectionPool {
208221
private func openConnection() async throws -> PostgresConnection {
209222
try await PostgresConnection.connect(
210223
configuration: configuration,
211-
eventLoopGroup: eventLoopGroup
224+
eventLoopGroup: eventLoopGroup,
225+
sslContext: sslContext
212226
)
213227
}
214228

Sources/CosmoSQLCore/AsyncChannelBridge.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,21 @@ public final class AsyncChannelBridge: ChannelInboundHandler, RemovableChannelHa
5252
///
5353
/// - Parameter eventLoop: The channel's event loop; used to serialize queue access.
5454
public func waitForMessage(on eventLoop: any EventLoop) async throws -> ByteBuffer {
55-
try await withCheckedThrowingContinuation { cont in
55+
// Fast path: if we're already on the event loop (e.g., called from channelRead
56+
// context) and there's buffered data, return synchronously without a thread hop.
57+
if eventLoop.inEventLoop {
58+
if !queue.isEmpty {
59+
return queue.removeFirst()
60+
}
61+
// Still on event loop but no data yet — suspend and wait.
62+
return try await withCheckedThrowingContinuation { cont in
63+
precondition(self.waiter == nil,
64+
"AsyncChannelBridge: only one concurrent waitForMessage is allowed")
65+
self.waiter = cont
66+
}
67+
}
68+
// Slow path: hop to the event loop to safely read from the queue.
69+
return try await withCheckedThrowingContinuation { cont in
5670
eventLoop.execute {
5771
if !self.queue.isEmpty {
5872
cont.resume(returning: self.queue.removeFirst())

cosmo-benchmark/Sources/cosmo-benchmark/main.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,10 @@ func benchPostgresCosmo() async -> [BenchResult] {
184184
return []
185185
}
186186
let config = PostgresConnection.Configuration(
187-
host: pgHost, port: pgPort, database: pgDb, username: pgUser, password: pgPass, tls: .prefer)
187+
host: pgHost, port: pgPort, database: pgDb, username: pgUser, password: pgPass, tls: .disable)
188188
var results: [BenchResult] = []
189189

190-
results.append(await measure(label: "Cold connect + query + close", iterations: iterations) {
190+
results.append(await measure(label: "Cold connect + query + close (tls:off)", iterations: iterations) {
191191
let c = try await PostgresConnection.connect(configuration: config)
192192
defer { Task { try? await c.close() } }
193193
_ = try await c.query(pgQuery, [])
@@ -234,7 +234,7 @@ func benchPostgresNIO() async -> [BenchResult] {
234234
database: pgDb, tls: .disable)
235235
var results: [BenchResult] = []
236236

237-
results.append(await measure(label: "Cold connect + query + close", iterations: iterations) {
237+
results.append(await measure(label: "Cold connect + query + close (tls:off)", iterations: iterations) {
238238
let c = try await PostgresNIO.PostgresConnection.connect(
239239
configuration: config, id: 0, logger: logger)
240240
defer { Task { try? await c.close() } }

0 commit comments

Comments
 (0)