Skip to content

Commit 4147fd6

Browse files
authored
[HTTP2] Create new connections during migration if needed (#459)
1 parent 1361ecc commit 4147fd6

9 files changed

+504
-23
lines changed

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

+96-4
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ extension HTTPConnectionPool {
6969
}
7070
}
7171

72+
var canOrWillBeAbleToExecuteRequests: Bool {
73+
switch self.state {
74+
case .leased, .backingOff, .idle, .starting:
75+
return true
76+
case .closed:
77+
return false
78+
}
79+
}
80+
7281
var isLeased: Bool {
7382
switch self.state {
7483
case .leased:
@@ -281,6 +290,10 @@ extension HTTPConnectionPool {
281290
return connecting
282291
}
283292

293+
private var maximumAdditionalGeneralPurposeConnections: Int {
294+
self.maximumConcurrentConnections - (self.overflowIndex - 1)
295+
}
296+
284297
/// Is there at least one connection that is able to run requests
285298
var hasActiveConnections: Bool {
286299
self.connections.contains(where: { $0.isIdle || $0.isLeased })
@@ -530,8 +543,8 @@ extension HTTPConnectionPool {
530543
return migrationContext
531544
}
532545

533-
/// we only handle starting and backing off connection here.
534-
/// All running connections must be handled by the enclosing state machine
546+
/// We only handle starting and backing off connection here.
547+
/// All already running connections must be handled by the enclosing state machine.
535548
/// - Parameters:
536549
/// - starting: starting HTTP connections from previous state machine
537550
/// - backingOff: backing off HTTP connections from previous state machine
@@ -541,17 +554,96 @@ extension HTTPConnectionPool {
541554
) {
542555
for (connectionID, eventLoop) in starting {
543556
let newConnection = HTTP1ConnectionState(connectionID: connectionID, eventLoop: eventLoop)
544-
self.connections.append(newConnection)
557+
self.connections.insert(newConnection, at: self.overflowIndex)
558+
/// If we can grow, we mark the connection as a general purpose connection.
559+
/// Otherwise, it will be an overflow connection which is only used once for requests with a required event loop
560+
if self.canGrow {
561+
self.overflowIndex = self.connections.index(after: self.overflowIndex)
562+
}
545563
}
546564

547565
for (connectionID, eventLoop) in backingOff {
548566
var backingOffConnection = HTTP1ConnectionState(connectionID: connectionID, eventLoop: eventLoop)
549567
// TODO: Maybe we want to add a static init for backing off connections to HTTP1ConnectionState
550568
backingOffConnection.failedToConnect()
551-
self.connections.append(backingOffConnection)
569+
self.connections.insert(backingOffConnection, at: self.overflowIndex)
570+
/// If we can grow, we mark the connection as a general purpose connection.
571+
/// Otherwise, it will be an overflow connection which is only used once for requests with a required event loop
572+
if self.canGrow {
573+
self.overflowIndex = self.connections.index(after: self.overflowIndex)
574+
}
552575
}
553576
}
554577

578+
/// We will create new connections for each `requiredEventLoopOfPendingRequests`
579+
/// In addition, we also create more general purpose connections if we do not have enough to execute
580+
/// all requests on the given `preferredEventLoopsOfPendingGeneralPurposeRequests`
581+
/// until we reach `maximumConcurrentConnections`
582+
/// - Parameters:
583+
/// - requiredEventLoopsForPendingRequests:
584+
/// event loops for which we have requests with a required event loop.
585+
/// Duplicates are not allowed.
586+
/// - generalPurposeRequestCountPerPreferredEventLoop:
587+
/// request count with no required event loop,
588+
/// grouped by preferred event loop and ordered descending by number of requests
589+
/// - Returns: new connections that must be created
590+
mutating func createConnectionsAfterMigrationIfNeeded(
591+
requiredEventLoopOfPendingRequests: [(EventLoop, Int)],
592+
generalPurposeRequestCountGroupedByPreferredEventLoop: [(EventLoop, Int)]
593+
) -> [(Connection.ID, EventLoop)] {
594+
// create new connections for requests with a required event loop
595+
596+
// we may already start connections for those requests and do not want to start to many
597+
let startingRequiredEventLoopConnectionCount = Dictionary(
598+
self.connections[self.overflowIndex..<self.connections.endIndex].lazy.map {
599+
($0.eventLoop.id, 1)
600+
},
601+
uniquingKeysWith: +
602+
)
603+
var connectionToCreate = requiredEventLoopOfPendingRequests
604+
.flatMap { (eventLoop, requestCount) -> [(Connection.ID, EventLoop)] in
605+
// We need a connection for each queued request with a required event loop.
606+
// Therefore, we look how many request we have queued for a given `eventLoop` and
607+
// how many connections we are already starting on the given `eventLoop`.
608+
// If we have not enough, we will create additional connections to have at least
609+
// on connection per request.
610+
let connectionsToStart = requestCount - startingRequiredEventLoopConnectionCount[eventLoop.id, default: 0]
611+
return stride(from: 0, to: connectionsToStart, by: 1).lazy.map { _ in
612+
(self.createNewOverflowConnection(on: eventLoop), eventLoop)
613+
}
614+
}
615+
616+
// create new connections for requests without a required event loop
617+
618+
// TODO: improve algorithm to create connections uniformly across all preferred event loops
619+
// while paying attention to the number of queued request per event loop
620+
// Currently we start by creating new connections on the event loop with the most queued
621+
// requests. If we have create a enough connections to cover all requests for the given
622+
// event loop we will continue with the event loop with the second most queued requests
623+
// and so on and so forth. We do not need to sort the array because
624+
let newGeneralPurposeConnections: [(Connection.ID, EventLoop)] = generalPurposeRequestCountGroupedByPreferredEventLoop
625+
// we do not want to allocated intermediate arrays.
626+
.lazy
627+
// we flatten the grouped list of event loops by lazily repeating the event loop
628+
// for each request.
629+
// As a result we get one event loop per request (`[EventLoop]`).
630+
.flatMap { eventLoop, requestCount in
631+
repeatElement(eventLoop, count: requestCount)
632+
}
633+
// we may already start connections and do not want to start too many
634+
.dropLast(self.startingGeneralPurposeConnections)
635+
// we need to respect the used defined `maximumConcurrentConnections`
636+
.prefix(self.maximumAdditionalGeneralPurposeConnections)
637+
// we now create a connection for each remaining event loop
638+
.map { eventLoop in
639+
(self.createNewConnection(on: eventLoop), eventLoop)
640+
}
641+
642+
connectionToCreate.append(contentsOf: newGeneralPurposeConnections)
643+
644+
return connectionToCreate
645+
}
646+
555647
// MARK: Shutdown
556648

557649
mutating func shutdown() -> CleanupContext {

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

+16-8
Original file line numberDiff line numberDiff line change
@@ -84,32 +84,40 @@ extension HTTPConnectionPool {
8484
http2Connections: HTTP2Connections,
8585
requests: RequestQueue
8686
) -> ConnectionMigrationAction {
87-
precondition(self.connections.isEmpty)
88-
precondition(self.http2Connections == nil)
89-
precondition(self.requests.isEmpty)
87+
precondition(self.connections.isEmpty, "expected an empty state machine but connections are not empty")
88+
precondition(self.http2Connections == nil, "expected an empty state machine but http2Connections are not nil")
89+
precondition(self.requests.isEmpty, "expected an empty state machine but requests are not empty")
9090

91+
self.requests = requests
92+
93+
// we may have remaining open http1 connections from a pervious migration to http2
9194
if let http1Connections = http1Connections {
9295
self.connections = http1Connections
9396
}
9497

9598
var http2Connections = http2Connections
9699
let migration = http2Connections.migrateToHTTP1()
100+
97101
self.connections.migrateFromHTTP2(
98102
starting: migration.starting,
99103
backingOff: migration.backingOff
100104
)
101105

106+
let createConnections = self.connections.createConnectionsAfterMigrationIfNeeded(
107+
requiredEventLoopOfPendingRequests: requests.requestCountGroupedByRequiredEventLoop(),
108+
generalPurposeRequestCountGroupedByPreferredEventLoop: requests.generalPurposeRequestCountGroupedByPreferredEventLoop()
109+
)
110+
102111
if !http2Connections.isEmpty {
103112
self.http2Connections = http2Connections
104113
}
105114

106-
// TODO: Close all idle connections from context.close
107-
// TODO: Start new http1 connections for pending requests
108115
// TODO: Potentially cancel unneeded bootstraps (Needs cancellable ClientBootstrap)
109116

110-
self.requests = requests
111-
112-
return .init(closeConnections: [], createConnections: [])
117+
return .init(
118+
closeConnections: migration.close,
119+
createConnections: createConnections
120+
)
113121
}
114122

115123
// MARK: - Events

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

+27-2
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,8 @@ extension HTTPConnectionPool {
346346

347347
// MARK: Migration
348348

349-
/// we only handle starting and backing off connection here.
350-
/// All running connections must be handled by the enclosing state machine
349+
/// We only handle starting and backing off connection here.
350+
/// All already running connections must be handled by the enclosing state machine.
351351
/// - Parameters:
352352
/// - starting: starting HTTP connections from previous state machine
353353
/// - backingOff: backing off HTTP connections from previous state machine
@@ -368,6 +368,31 @@ extension HTTPConnectionPool {
368368
}
369369
}
370370

371+
/// We will create new connections for `requiredEventLoopsOfPendingRequests`
372+
/// if we do not already have a connection that can or will be able to execute requests on the given event loop.
373+
/// - Parameters:
374+
/// - requiredEventLoopsForPendingRequests: event loops for which we have requests with a required event loop. Duplicates are not allowed.
375+
/// - Returns: new connections that need to be created
376+
mutating func createConnectionsAfterMigrationIfNeeded(
377+
requiredEventLoopsOfPendingRequests: [EventLoop]
378+
) -> [(Connection.ID, EventLoop)] {
379+
// create new connections for requests with a required event loop
380+
let eventLoopsWithConnectionThatCanOrWillBeAbleToExecuteRequests = Set(
381+
self.connections.lazy
382+
.filter {
383+
$0.canOrWillBeAbleToExecuteRequests
384+
}.map {
385+
$0.eventLoop.id
386+
}
387+
)
388+
return requiredEventLoopsOfPendingRequests.compactMap { eventLoop -> (Connection.ID, EventLoop)? in
389+
guard !eventLoopsWithConnectionThatCanOrWillBeAbleToExecuteRequests.contains(eventLoop.id)
390+
else { return nil }
391+
let connectionID = self.createNewConnection(on: eventLoop)
392+
return (connectionID, eventLoop)
393+
}
394+
}
395+
371396
struct HTTP2ToHTTP1MigrationContext {
372397
var backingOff: [(Connection.ID, EventLoop)] = []
373398
var starting: [(Connection.ID, EventLoop)] = []

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

+14-9
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,13 @@ extension HTTPConnectionPool {
9292
http2Connections: HTTP2Connections?,
9393
requests: RequestQueue
9494
) -> ConnectionMigrationAction {
95-
precondition(self.http1Connections == nil)
96-
precondition(self.connections.isEmpty)
97-
precondition(self.requests.isEmpty)
95+
precondition(self.connections.isEmpty, "expected an empty state machine but connections are not empty")
96+
precondition(self.http1Connections == nil, "expected an empty state machine but http1Connections are not nil")
97+
precondition(self.requests.isEmpty, "expected an empty state machine but requests are not empty")
9898

99+
self.requests = requests
100+
101+
// we may have remaining open http2 connections from a pervious migration to http1
99102
if let http2Connections = http2Connections {
100103
self.connections = http2Connections
101104
}
@@ -107,17 +110,19 @@ extension HTTPConnectionPool {
107110
backingOff: context.backingOff
108111
)
109112

113+
let createConnections = self.connections.createConnectionsAfterMigrationIfNeeded(
114+
requiredEventLoopsOfPendingRequests: requests.eventLoopsWithPendingRequests()
115+
)
116+
110117
if !http1Connections.isEmpty {
111118
self.http1Connections = http1Connections
112119
}
113120

114-
self.requests = requests
115-
116-
// TODO: Close all idle connections from context.close
117-
// TODO: Start new http2 connections for pending requests
118121
// TODO: Potentially cancel unneeded bootstraps (Needs cancellable ClientBootstrap)
119-
120-
return .init(closeConnections: [], createConnections: [])
122+
return .init(
123+
closeConnections: context.close,
124+
createConnections: createConnections
125+
)
121126
}
122127

123128
mutating func executeRequest(_ request: Request) -> Action {

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

+51
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@
1414

1515
import NIOCore
1616

17+
private struct HashableEventLoop: Hashable {
18+
static func == (lhs: HashableEventLoop, rhs: HashableEventLoop) -> Bool {
19+
lhs.eventLoop === rhs.eventLoop
20+
}
21+
22+
init(_ eventLoop: EventLoop) {
23+
self.eventLoop = eventLoop
24+
}
25+
26+
let eventLoop: EventLoop
27+
func hash(into hasher: inout Hasher) {
28+
self.eventLoop.id.hash(into: &hasher)
29+
}
30+
}
31+
1732
extension HTTPConnectionPool {
1833
/// A struct to store all queued requests.
1934
struct RequestQueue {
@@ -131,6 +146,42 @@ extension HTTPConnectionPool {
131146
}
132147
return nil
133148
}
149+
150+
/// - Returns: event loops with at least one request with a required event loop
151+
func eventLoopsWithPendingRequests() -> [EventLoop] {
152+
self.eventLoopQueues.compactMap {
153+
/// all requests in `eventLoopQueues` are guaranteed to have a `requiredEventLoop`
154+
/// however, a queue can be empty
155+
$0.value.first?.requiredEventLoop!
156+
}
157+
}
158+
159+
/// - Returns: request count for requests with required event loop, grouped by required event loop without any particular order
160+
func requestCountGroupedByRequiredEventLoop() -> [(EventLoop, Int)] {
161+
self.eventLoopQueues.values.compactMap { requests -> (EventLoop, Int)? in
162+
/// all requests in `eventLoopQueues` are guaranteed to have a `requiredEventLoop`,
163+
/// however, a queue can be empty
164+
guard let requiredEventLoop = requests.first?.requiredEventLoop! else {
165+
return nil
166+
}
167+
return (requiredEventLoop, requests.count)
168+
}
169+
}
170+
171+
/// - Returns: request count with **no** required event loop, grouped by preferred event loop and ordered descending by number of requests
172+
func generalPurposeRequestCountGroupedByPreferredEventLoop() -> [(EventLoop, Int)] {
173+
let requestCountPerEventLoop = Dictionary(
174+
self.generalPurposeQueue.lazy.map { request in
175+
(HashableEventLoop(request.preferredEventLoop), 1)
176+
},
177+
uniquingKeysWith: +
178+
)
179+
return requestCountPerEventLoop.lazy
180+
.map { ($0.key.eventLoop, $0.value) }
181+
.sorted { lhs, rhs in
182+
lhs.1 > rhs.1
183+
}
184+
}
134185
}
135186
}
136187

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

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ extension HTTPConnectionPool_HTTP1ConnectionsTests {
3535
("testCloseConnectionIfIdleButLeasedRaceCondition", testCloseConnectionIfIdleButLeasedRaceCondition),
3636
("testCloseConnectionIfIdleButClosedRaceCondition", testCloseConnectionIfIdleButClosedRaceCondition),
3737
("testShutdown", testShutdown),
38+
("testMigrationFromHTTP2", testMigrationFromHTTP2),
39+
("testMigrationFromHTTP2WithPendingRequestsWithRequiredEventLoop", testMigrationFromHTTP2WithPendingRequestsWithRequiredEventLoop),
40+
("testMigrationFromHTTP2WithPendingRequestsWithPreferredEventLoop", testMigrationFromHTTP2WithPendingRequestsWithPreferredEventLoop),
41+
("testMigrationFromHTTP2WithAlreadyLeasedHTTP1Connection", testMigrationFromHTTP2WithAlreadyLeasedHTTP1Connection),
42+
("testMigrationFromHTTP2WithMoreStartingConnectionsThanMaximumAllowedConccurentConnections", testMigrationFromHTTP2WithMoreStartingConnectionsThanMaximumAllowedConccurentConnections),
43+
("testMigrationFromHTTP2StartsEnoghOverflowConnectionsForRequiredEventLoopRequests", testMigrationFromHTTP2StartsEnoghOverflowConnectionsForRequiredEventLoopRequests),
3844
]
3945
}
4046
}

0 commit comments

Comments
 (0)