Skip to content

Commit 0120933

Browse files
committed
Add support for HTTP/1 connection pre-warming
Motivation This patch adds support for HTTP/1 connection pre-warming. This allows the user to request that the HTTP/1 connection pool create and maintain extra connections, above-and-beyond those strictly needed to run the pool. This pool can be used to absorb small spikes in request traffic without increasing latency to account for connection creation. Modifications - Added new configuration properties for pre-warmed connections. - Amended the HTTP/1 state machine to create new connections where necessary. - Added state machine tests. Results Pre-warmed connections are available.
1 parent 2edac1d commit 0120933

9 files changed

+893
-90
lines changed

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ final class HTTPConnectionPool:
7979
.concurrentHTTP1ConnectionsPerHostSoftLimit,
8080
retryConnectionEstablishment: clientConfiguration.connectionPool.retryConnectionEstablishment,
8181
preferHTTP1: clientConfiguration.httpVersion == .http1Only,
82-
maximumConnectionUses: clientConfiguration.maximumUsesPerConnection
82+
maximumConnectionUses: clientConfiguration.maximumUsesPerConnection,
83+
preWarmedHTTP1ConnectionCount: clientConfiguration.connectionPool.preWarmedHTTP1ConnectionCount
8384
)
8485
}
8586

@@ -104,6 +105,7 @@ final class HTTPConnectionPool:
104105
enum Unlocked {
105106
case createConnection(Connection.ID, on: EventLoop)
106107
case closeConnection(Connection, isShutdown: StateMachine.ConnectionAction.IsShutdown)
108+
case closeConnectionAndCreateConnection(close: Connection, newConnectionID: Connection.ID, on: EventLoop, isShutdown: StateMachine.ConnectionAction.IsShutdown)
107109
case cleanupConnections(CleanupContext, isShutdown: StateMachine.ConnectionAction.IsShutdown)
108110
case migration(
109111
createConnections: [(Connection.ID, EventLoop)],
@@ -185,12 +187,19 @@ final class HTTPConnectionPool:
185187
self.locked.connection = .scheduleBackoffTimer(connectionID, backoff: backoff, on: eventLoop)
186188
case .scheduleTimeoutTimer(let connectionID, on: let eventLoop):
187189
self.locked.connection = .scheduleTimeoutTimer(connectionID, on: eventLoop)
190+
case .scheduleTimeoutTimerAndCreateConnection(let timeoutID, let newConnectionID, let eventLoop):
191+
self.locked.connection = .scheduleTimeoutTimer(timeoutID, on: eventLoop)
192+
self.unlocked.connection = .createConnection(newConnectionID, on: eventLoop)
188193
case .cancelTimeoutTimer(let connectionID):
189194
self.locked.connection = .cancelTimeoutTimer(connectionID)
195+
case .createConnectionAndCancelTimeoutTimer(createdID: let createdID, on: let eventLoop, cancelTimerID: let cancelID):
196+
self.unlocked.connection = .createConnection(createdID, on: eventLoop)
197+
self.locked.connection = .cancelTimeoutTimer(cancelID)
190198
case .closeConnection(let connection, let isShutdown):
191199
self.unlocked.connection = .closeConnection(connection, isShutdown: isShutdown)
200+
case .closeConnectionAndCreateConnection(let closeConnection, let isShutdown, let newConnectionID, let eventLoop):
201+
self.unlocked.connection = .closeConnectionAndCreateConnection(close: closeConnection, newConnectionID: newConnectionID, on: eventLoop, isShutdown: isShutdown)
192202
case .cleanupConnections(var cleanupContext, let isShutdown):
193-
//
194203
self.locked.connection = .cancelBackoffTimers(cleanupContext.connectBackoff)
195204
cleanupContext.connectBackoff = []
196205
self.unlocked.connection = .cleanupConnections(cleanupContext, isShutdown: isShutdown)
@@ -287,6 +296,27 @@ final class HTTPConnectionPool:
287296
self.delegate.connectionPoolDidShutdown(self, unclean: unclean)
288297
}
289298

299+
case .closeConnectionAndCreateConnection(let connectionToClose, let newConnectionID, let eventLoop, let isShutdown):
300+
self.logger.trace(
301+
"closing and creating connection",
302+
metadata: [
303+
"ahc-connection-id": "\(connectionToClose.id)"
304+
]
305+
)
306+
307+
// If the pool is shutdown, let's just not create this new connection.
308+
if case .no = isShutdown {
309+
self.createConnection(newConnectionID, on: eventLoop)
310+
}
311+
312+
// we are not interested in the close promise...
313+
connectionToClose.close(promise: nil)
314+
315+
// This isn't really reachable.
316+
if case .yes(let unclean) = isShutdown {
317+
self.delegate.connectionPoolDidShutdown(self, unclean: unclean)
318+
}
319+
290320
case .cleanupConnections(let cleanupContext, let isShutdown):
291321
for connection in cleanupContext.close {
292322
connection.close(promise: nil)
@@ -400,7 +430,7 @@ final class HTTPConnectionPool:
400430
self.modifyStateAndRunActions { stateMachine in
401431
if self._idleTimer.removeValue(forKey: connectionID) != nil {
402432
// The timer still exists. State Machines assumes it is alive
403-
return stateMachine.connectionIdleTimeout(connectionID)
433+
return stateMachine.connectionIdleTimeout(connectionID, on: eventLoop)
404434
}
405435
return .none
406436
}

Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1Connections.swift

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -262,32 +262,30 @@ extension HTTPConnectionPool {
262262
private var overflowIndex: Array<HTTP1ConnectionState>.Index
263263
/// The number of times each connection can be used before it is closed and replaced.
264264
private let maximumConnectionUses: Int?
265-
266-
init(maximumConcurrentConnections: Int, generator: Connection.ID.Generator, maximumConnectionUses: Int?) {
265+
/// How many pre-warmed connections we should create.
266+
private let preWarmedConnectionCount: Int
267+
268+
init(
269+
maximumConcurrentConnections: Int,
270+
generator: Connection.ID.Generator,
271+
maximumConnectionUses: Int?,
272+
preWarmedHTTP1ConnectionCount: Int
273+
) {
267274
self.connections = []
268275
self.connections.reserveCapacity(min(maximumConcurrentConnections, 1024))
269276
self.overflowIndex = self.connections.endIndex
270277
self.maximumConcurrentConnections = maximumConcurrentConnections
271278
self.generator = generator
272279
self.maximumConnectionUses = maximumConnectionUses
280+
self.preWarmedConnectionCount = preWarmedHTTP1ConnectionCount
273281
}
274282

275283
var stats: Stats {
276-
var stats = Stats()
277-
// all additions here can be unchecked, since we will have at max self.connections.count
278-
// which itself is an Int. For this reason we will never overflow.
279-
for connectionState in self.connections {
280-
if connectionState.isConnecting {
281-
stats.connecting &+= 1
282-
} else if connectionState.isBackingOff {
283-
stats.backingOff &+= 1
284-
} else if connectionState.isLeased {
285-
stats.leased &+= 1
286-
} else if connectionState.isIdle {
287-
stats.idle &+= 1
288-
}
289-
}
290-
return stats
284+
self.connectionStats(in: self.connections.startIndex..<self.connections.endIndex)
285+
}
286+
287+
var generalPurposeStats: Stats {
288+
self.connectionStats(in: self.connections.startIndex..<self.overflowIndex)
291289
}
292290

293291
var isEmpty: Bool {
@@ -328,6 +326,24 @@ extension HTTPConnectionPool {
328326
}
329327
}
330328

329+
private func connectionStats(in range: Range<Int>) -> Stats {
330+
var stats = Stats()
331+
// all additions here can be unchecked, since we will have at max self.connections.count
332+
// which itself is an Int. For this reason we will never overflow.
333+
for connectionState in self.connections[range] {
334+
if connectionState.isConnecting {
335+
stats.connecting &+= 1
336+
} else if connectionState.isBackingOff {
337+
stats.backingOff &+= 1
338+
} else if connectionState.isLeased {
339+
stats.leased &+= 1
340+
} else if connectionState.isIdle {
341+
stats.idle &+= 1
342+
}
343+
}
344+
return stats
345+
}
346+
331347
// MARK: - Mutations -
332348

333349
/// A connection's use. Did it serve in the pool or was it specialized for an `EventLoop`?
@@ -836,6 +852,10 @@ extension HTTPConnectionPool {
836852
var leased: Int = 0
837853
var connecting: Int = 0
838854
var backingOff: Int = 0
855+
856+
var nonLeased: Int {
857+
self.idle + self.connecting + self.backingOff
858+
}
839859
}
840860
}
841861
}

Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,23 @@ extension HTTPConnectionPool {
3333
/// The property was introduced to fail fast during testing.
3434
/// Otherwise this should always be true and not turned off.
3535
private let retryConnectionEstablishment: Bool
36+
private let preWarmedConnectionCount: Int
3637

3738
init(
3839
idGenerator: Connection.ID.Generator,
3940
maximumConcurrentConnections: Int,
4041
retryConnectionEstablishment: Bool,
4142
maximumConnectionUses: Int?,
43+
preWarmedHTTP1ConnectionCount: Int,
4244
lifecycleState: StateMachine.LifecycleState
4345
) {
4446
self.connections = HTTP1Connections(
4547
maximumConcurrentConnections: maximumConcurrentConnections,
4648
generator: idGenerator,
47-
maximumConnectionUses: maximumConnectionUses
49+
maximumConnectionUses: maximumConnectionUses,
50+
preWarmedHTTP1ConnectionCount: preWarmedHTTP1ConnectionCount
4851
)
52+
self.preWarmedConnectionCount = preWarmedHTTP1ConnectionCount
4953
self.retryConnectionEstablishment = retryConnectionEstablishment
5054

5155
self.requests = RequestQueue()
@@ -145,9 +149,25 @@ extension HTTPConnectionPool {
145149

146150
private mutating func executeRequestOnPreferredEventLoop(_ request: Request, eventLoop: EventLoop) -> Action {
147151
if let connection = self.connections.leaseConnection(onPreferred: eventLoop) {
152+
// Cool, a connection is available. If using this would put us below our needed extra set, we
153+
// should create another.
154+
let stats = self.connections.generalPurposeStats
155+
let needExtraConnection = stats.nonLeased < (self.requests.count + self.preWarmedConnectionCount) && self.connections.canGrow
156+
let action: StateMachine.ConnectionAction
157+
158+
if needExtraConnection {
159+
action = .createConnectionAndCancelTimeoutTimer(
160+
createdID: self.connections.createNewConnection(on: eventLoop),
161+
on: eventLoop,
162+
cancelTimerID: connection.id
163+
)
164+
} else {
165+
action = .cancelTimeoutTimer(connection.id)
166+
}
167+
148168
return .init(
149169
request: .executeRequest(request, connection, cancelTimeout: false),
150-
connection: .cancelTimeoutTimer(connection.id)
170+
connection: action
151171
)
152172
}
153173

@@ -294,7 +314,20 @@ extension HTTPConnectionPool {
294314
}
295315
}
296316

297-
mutating func connectionIdleTimeout(_ connectionID: Connection.ID) -> Action {
317+
mutating func connectionIdleTimeout(_ connectionID: Connection.ID, on eventLoop: any EventLoop) -> Action {
318+
// Don't close idle connections if we need pre-warmed connections. Instead, re-arm the idle timer.
319+
// We still want the idle timers to make sure we eventually fall below the pre-warmed limit.
320+
if self.preWarmedConnectionCount > 0 {
321+
let stats = self.connections.generalPurposeStats
322+
if stats.idle <= self.preWarmedConnectionCount {
323+
return .init(
324+
request: .none,
325+
connection: .scheduleTimeoutTimer(connectionID, on: eventLoop)
326+
)
327+
}
328+
}
329+
330+
// Ok, we do actually want the connection count to go down.
298331
guard let connection = self.connections.closeConnectionIfIdle(connectionID) else {
299332
// because of a race this connection (connection close runs against trigger of timeout)
300333
// was already removed from the state machine.
@@ -410,11 +443,7 @@ extension HTTPConnectionPool {
410443
case .running:
411444
// Close the connection if it's expired.
412445
if context.shouldBeClosed {
413-
let connection = self.connections.closeConnection(at: index)
414-
return .init(
415-
request: .none,
416-
connection: .closeConnection(connection, isShutdown: .no)
417-
)
446+
return self.nextActionForToBeClosedIdleConnection(at: index, context: context)
418447
} else {
419448
switch context.use {
420449
case .generalPurpose:
@@ -446,28 +475,53 @@ extension HTTPConnectionPool {
446475
at index: Int,
447476
context: HTTP1Connections.IdleConnectionContext
448477
) -> EstablishedAction {
478+
var requestAction = HTTPConnectionPool.StateMachine.RequestAction.none
479+
var parkedConnectionDetails: (HTTPConnectionPool.Connection.ID, any EventLoop)? = nil
480+
449481
// 1. Check if there are waiting requests in the general purpose queue
450482
if let request = self.requests.popFirst(for: nil) {
451-
return .init(
452-
request: .executeRequest(request, self.connections.leaseConnection(at: index), cancelTimeout: true),
453-
connection: .none
454-
)
483+
requestAction = .executeRequest(request, self.connections.leaseConnection(at: index), cancelTimeout: true)
455484
}
456485

457486
// 2. Check if there are waiting requests in the matching eventLoop queue
458-
if let request = self.requests.popFirst(for: context.eventLoop) {
459-
return .init(
460-
request: .executeRequest(request, self.connections.leaseConnection(at: index), cancelTimeout: true),
461-
connection: .none
462-
)
487+
if case .none = requestAction, let request = self.requests.popFirst(for: context.eventLoop) {
488+
requestAction = .executeRequest(request, self.connections.leaseConnection(at: index), cancelTimeout: true)
463489
}
464490

465491
// 3. Create a timeout timer to ensure the connection is closed if it is idle for too
466-
// long.
467-
let (connectionID, eventLoop) = self.connections.parkConnection(at: index)
492+
// long, assuming we don't already have a use for it.
493+
if case .none = requestAction {
494+
parkedConnectionDetails = self.connections.parkConnection(at: index)
495+
}
496+
497+
// 4. We may need to create another connection to make sure we have enough pre-warmed ones.
498+
// We need to do that if we have fewer non-leased connections than we need pre-warmed ones _and_ the pool can grow.
499+
// Note that in this case we don't need to account for the number of pending requests, as that is 0: step 1
500+
// confirmed that.
501+
let connectionAction: EstablishedConnectionAction
502+
503+
if self.connections.generalPurposeStats.nonLeased < self.preWarmedConnectionCount && self.connections.canGrow {
504+
// Re-use the event loop of the connection that just got created.
505+
if let parkedConnectionDetails {
506+
let newConnectionID = self.connections.createNewConnection(on: parkedConnectionDetails.1)
507+
connectionAction = .scheduleTimeoutTimerAndCreateConnection(
508+
timeoutID: parkedConnectionDetails.0,
509+
newConnectionID: newConnectionID,
510+
on: parkedConnectionDetails.1
511+
)
512+
} else {
513+
let newConnectionID = self.connections.createNewConnection(on: context.eventLoop)
514+
connectionAction = .createConnection(connectionID: newConnectionID, on: context.eventLoop)
515+
}
516+
} else if let parkedConnectionDetails {
517+
connectionAction = .scheduleTimeoutTimer(parkedConnectionDetails.0, on: parkedConnectionDetails.1)
518+
} else {
519+
connectionAction = .none
520+
}
521+
468522
return .init(
469-
request: .none,
470-
connection: .scheduleTimeoutTimer(connectionID, on: eventLoop)
523+
request: requestAction,
524+
connection: connectionAction
471525
)
472526
}
473527

@@ -495,6 +549,38 @@ extension HTTPConnectionPool {
495549
)
496550
}
497551

552+
private mutating func nextActionForToBeClosedIdleConnection(
553+
at index: Int,
554+
context: HTTP1Connections.IdleConnectionContext
555+
) -> EstablishedAction {
556+
// Step 1: Tell the connection pool to drop what it knows about this object.
557+
let connectionToClose = self.connections.closeConnection(at: index)
558+
559+
// Step 2: Check whether we need a connection to replace this one. We do if we have fewer non-leased connections
560+
// than we requests + minimumPrewarming count _and_ the pool can grow. Note that in many cases the above closure
561+
// will have made some space, which is just fine.
562+
let nonLeased = self.connections.generalPurposeStats.nonLeased
563+
let neededNonLeased = self.requests.generalPurposeCount + self.preWarmedConnectionCount
564+
565+
let connectionAction: EstablishedConnectionAction
566+
if nonLeased < neededNonLeased && self.connections.canGrow {
567+
// We re-use the EL of the connection we just closed.
568+
let newConnectionID = self.connections.createNewConnection(on: connectionToClose.eventLoop)
569+
connectionAction = .closeConnectionAndCreateConnection(
570+
closeConnection: connectionToClose,
571+
isShutdown: .no,
572+
newConnectionID: newConnectionID,
573+
on: connectionToClose.eventLoop
574+
)
575+
} else {
576+
connectionAction = .closeConnection(connectionToClose, isShutdown: .no)
577+
}
578+
return .init(
579+
request: .none,
580+
connection: connectionAction
581+
)
582+
}
583+
498584
// MARK: Failed/Closed connection management
499585

500586
private mutating func nextActionForFailedConnection(
@@ -530,7 +616,8 @@ extension HTTPConnectionPool {
530616
at index: Int,
531617
context: HTTP1Connections.FailedConnectionContext
532618
) -> Action {
533-
if context.connectionsStartingForUseCase < self.requests.generalPurposeCount {
619+
let needConnectionForRequest = context.connectionsStartingForUseCase < (self.requests.generalPurposeCount + self.preWarmedConnectionCount)
620+
if needConnectionForRequest {
534621
// if we have more requests queued up, than we have starting connections, we should
535622
// create a new connection
536623
let (newConnectionID, newEventLoop) = self.connections.replaceConnection(at: index)

0 commit comments

Comments
 (0)