Skip to content

Commit 18a58bb

Browse files
authored
[HTTP2] Improve performance of backoff timer done event (#464)
1 parent 60fef53 commit 18a58bb

File tree

4 files changed

+112
-15
lines changed

4 files changed

+112
-15
lines changed

Diff for: Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2Connections.swift

+32-13
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ extension HTTPConnectionPool {
4949
}
5050
}
5151

52+
var isStartingOrActive: Bool {
53+
switch self.state {
54+
case .starting, .active:
55+
return true
56+
case .draining, .backingOff, .closed:
57+
return false
58+
}
59+
}
60+
5261
/// A connection is established and can potentially execute requests if not all streams are leased
5362
var isActive: Bool {
5463
switch self.state {
@@ -439,24 +448,26 @@ extension HTTPConnectionPool {
439448
}
440449
}
441450

442-
/// used after backoff is done to determine if we need to create a new connection
443-
func hasStartingOrActiveConnection() -> Bool {
444-
self.connections.contains { connection in
445-
connection.canOrWillBeAbleToExecuteRequests
446-
}
447-
}
448-
449451
/// used after backoff is done to determine if we need to create a new connection
450452
/// - Parameters:
451453
/// - eventLoop: connection `EventLoop` to search for
452-
/// - Returns: true if at least one connection is starting or active for the given `eventLoop`
453-
func hasStartingOrActiveConnection(
454+
/// - Returns: if we have a starting or active general purpose connection and if we have also one for the given `eventLoop`
455+
func backingOffTimerDone(
454456
for eventLoop: EventLoop
455-
) -> Bool {
456-
self.connections.contains { connection in
457-
connection.eventLoop === eventLoop &&
458-
connection.canOrWillBeAbleToExecuteRequests
457+
) -> RetryConnectionCreationContext {
458+
var hasGeneralPurposeConnection: Bool = false
459+
var hasConnectionOnSpecifiedEventLoop: Bool = false
460+
for connection in self.connections {
461+
guard connection.isStartingOrActive else { continue }
462+
hasGeneralPurposeConnection = true
463+
guard connection.eventLoop === eventLoop else { continue }
464+
hasConnectionOnSpecifiedEventLoop = true
465+
break
459466
}
467+
return RetryConnectionCreationContext(
468+
hasGeneralPurposeConnection: hasGeneralPurposeConnection,
469+
hasConnectionOnSpecifiedEventLoop: hasConnectionOnSpecifiedEventLoop
470+
)
460471
}
461472

462473
mutating func createNewConnection(on eventLoop: EventLoop) -> Connection.ID {
@@ -675,6 +686,14 @@ extension HTTPConnectionPool {
675686

676687
// MARK: Result structs
677688

689+
struct RetryConnectionCreationContext {
690+
/// true if at least one connection is starting or active regardless of the event loop.
691+
let hasGeneralPurposeConnection: Bool
692+
693+
/// true if at least one connection is starting or active for the given `eventLoop`
694+
let hasConnectionOnSpecifiedEventLoop: Bool
695+
}
696+
678697
/// Information around a connection which is either in the .active or .draining state.
679698
struct EstablishedConnectionContext {
680699
/// number of streams which can be leased

Diff for: Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -321,18 +321,21 @@ extension HTTPConnectionPool {
321321
// per event loop for required event loop requests and only need one connection for
322322
// general purpose requests.
323323

324+
// precompute if we have starting or active connections to only iterate once over `self.connections`
325+
let context = self.connections.backingOffTimerDone(for: eventLoop)
326+
324327
// we need to start a new on connection in two cases:
325328
let needGeneralPurposeConnection =
326329
// 1. if we have general purpose requests
327330
!self.requests.isEmpty(for: nil) &&
328331
// and no connection starting or active
329-
!self.connections.hasStartingOrActiveConnection()
332+
!context.hasGeneralPurposeConnection
330333

331334
let needRequiredEventLoopConnection =
332335
// 2. or if we have requests for a required event loop
333336
!self.requests.isEmpty(for: eventLoop) &&
334337
// and no connection starting or active for the given event loop
335-
!self.connections.hasStartingOrActiveConnection(for: eventLoop)
338+
!context.hasConnectionOnSpecifiedEventLoop
336339

337340
guard needGeneralPurposeConnection || needRequiredEventLoopConnection else {
338341
// otherwise we can remove the connection

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP2StateMachineTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ extension HTTPConnectionPool_HTTP2StateMachineTests {
3939
("testMigrationFromHTTP1ToHTTP2", testMigrationFromHTTP1ToHTTP2),
4040
("testMigrationFromHTTP1ToHTTP2WithAlreadyStartedHTTP1Connections", testMigrationFromHTTP1ToHTTP2WithAlreadyStartedHTTP1Connections),
4141
("testHTTP2toHTTP1Migration", testHTTP2toHTTP1Migration),
42+
("testConnectionIsImmediatelyCreatedAfterBackoffTimerFires", testConnectionIsImmediatelyCreatedAfterBackoffTimerFires),
4243
]
4344
}
4445
}

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP2StateMachineTests.swift

+74
Original file line numberDiff line numberDiff line change
@@ -869,4 +869,78 @@ class HTTPConnectionPool_HTTP2StateMachineTests: XCTestCase {
869869
XCTAssertEqual(releaseAction.request, .none)
870870
XCTAssertNoThrow(try connections.closeConnection(http2Conn))
871871
}
872+
873+
func testConnectionIsImmediatelyCreatedAfterBackoffTimerFires() {
874+
let elg = EmbeddedEventLoopGroup(loops: 2)
875+
let el1 = elg.next()
876+
let el2 = elg.next()
877+
var connections = MockConnectionPool()
878+
var queuer = MockRequestQueuer()
879+
var state = HTTPConnectionPool.StateMachine(idGenerator: .init(), maximumConcurrentHTTP1Connections: 8)
880+
881+
var connectionIDs: [HTTPConnectionPool.Connection.ID] = []
882+
for el in [el1, el2, el2] {
883+
let mockRequest = MockHTTPRequest(eventLoop: el, requiresEventLoopForChannel: true)
884+
let request = HTTPConnectionPool.Request(mockRequest)
885+
let action = state.executeRequest(request)
886+
guard case .createConnection(let connID, let eventLoop) = action.connection else {
887+
return XCTFail("Unexpected connection action \(action.connection)")
888+
}
889+
connectionIDs.append(connID)
890+
XCTAssertTrue(eventLoop === el)
891+
XCTAssertEqual(action.request, .scheduleRequestTimeout(for: request, on: mockRequest.eventLoop))
892+
XCTAssertNoThrow(try connections.createConnection(connID, on: el))
893+
XCTAssertNoThrow(try queuer.queue(mockRequest, id: request.id))
894+
}
895+
896+
// fail the two connections for el2
897+
for connectionID in connectionIDs.dropFirst() {
898+
struct SomeError: Error {}
899+
XCTAssertNoThrow(try connections.failConnectionCreation(connectionID))
900+
let action = state.failedToCreateNewConnection(SomeError(), connectionID: connectionID)
901+
XCTAssertEqual(action.request, .none)
902+
guard case .scheduleBackoffTimer(connectionID, backoff: _, on: _) = action.connection else {
903+
return XCTFail("unexpected connection action \(connectionID)")
904+
}
905+
XCTAssertNoThrow(try connections.startConnectionBackoffTimer(connectionID))
906+
}
907+
let http2ConnID1 = connectionIDs[0]
908+
let http2ConnID2 = connectionIDs[1]
909+
let http2ConnID3 = connectionIDs[2]
910+
911+
// let the first connection on el1 succeed as a http2 connection
912+
let http2Conn1: HTTPConnectionPool.Connection = .__testOnly_connection(id: http2ConnID1, eventLoop: el1)
913+
XCTAssertNoThrow(try connections.succeedConnectionCreationHTTP2(http2ConnID1, maxConcurrentStreams: 10))
914+
let migrationAction1 = state.newHTTP2ConnectionCreated(http2Conn1, maxConcurrentStreams: 10)
915+
guard case .executeRequestsAndCancelTimeouts(let requests, http2Conn1) = migrationAction1.request else {
916+
return XCTFail("unexpected request action \(migrationAction1.request)")
917+
}
918+
XCTAssertEqual(migrationAction1.connection, .migration(createConnections: [], closeConnections: [], scheduleTimeout: nil))
919+
XCTAssertEqual(requests.count, 1)
920+
for request in requests {
921+
XCTAssertNoThrow(try queuer.get(request.id, request: request.__testOnly_wrapped_request()))
922+
XCTAssertNoThrow(try connections.execute(request.__testOnly_wrapped_request(), on: http2Conn1))
923+
}
924+
925+
// we now have 1 active connection on el1 and 2 backing off connections on el2
926+
// with 2 queued requests with a requirement to be executed on el2
927+
928+
// if the backoff timer fires for a connection on el2, we should immediately start a new connection
929+
XCTAssertNoThrow(try connections.connectionBackoffTimerDone(http2ConnID2))
930+
let action2 = state.connectionCreationBackoffDone(http2ConnID2)
931+
XCTAssertEqual(action2.request, .none)
932+
guard case .createConnection(let newHttp2ConnID2, let eventLoop2) = action2.connection else {
933+
return XCTFail("Unexpected connection action \(action2.connection)")
934+
}
935+
XCTAssertTrue(eventLoop2 === el2)
936+
XCTAssertNoThrow(try connections.createConnection(newHttp2ConnID2, on: el2))
937+
938+
// we now have a starting connection for el2 and another one backing off
939+
940+
// if the backoff timer fires now for a connection on el2, we should *not* start a new connection
941+
XCTAssertNoThrow(try connections.connectionBackoffTimerDone(http2ConnID3))
942+
let action3 = state.connectionCreationBackoffDone(http2ConnID3)
943+
XCTAssertEqual(action3.request, .none)
944+
XCTAssertEqual(action3.connection, .none)
945+
}
872946
}

0 commit comments

Comments
 (0)