Skip to content

Commit 30a43b0

Browse files
authored
Close connection pool even if some connections are leased/in creation (#86)
Currently closing a pool, that has leased connections or currently creates connections fails. Such a close attempt brings the pool in an unrecoverable closing state and may lead to crashes. This patch changes the pool shutdown behavior to allow closing the pool even if the pool is not a predefined state.
1 parent 2d626c8 commit 30a43b0

File tree

3 files changed

+187
-60
lines changed

3 files changed

+187
-60
lines changed

Sources/RediStack/ConnectionPool/ConnectionPool.swift

+98-59
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ internal final class ConnectionPool {
143143
}
144144

145145
/// Deactivates this connection pool. Once this is called, no further connections can be obtained
146-
/// from the pool. Leased connections are not deactivated and can continue to be used.
146+
/// from the pool. Leased connections are not deactivated and can continue to be used. All waiters
147+
/// are failed with a pool is closed error.
147148
func close(promise: EventLoopPromise<Void>? = nil, logger: Logger) {
148149
if self.loop.inEventLoop {
149150
self.closePool(promise: promise, logger: logger)
@@ -228,7 +229,7 @@ extension ConnectionPool {
228229
switch self.state {
229230
case .closing:
230231
// We don't want this anymore, drop it.
231-
_ = connection.close()
232+
self.closeConnectionForShutdown(connection)
232233
case .closed:
233234
// This is programmer error, we shouldn't have entered this state.
234235
logger.critical("new connection created on a closed pool", metadata: [
@@ -250,10 +251,21 @@ extension ConnectionPool {
250251
RedisLogging.MetadataKeys.error: "\(error)"
251252
])
252253

253-
guard case .active = self.state else {
254-
// No point continuing connection creation if we're not active.
255-
logger.trace("not creating new connections due to inactivity")
254+
switch self.state {
255+
case .active:
256+
break // continue further down
257+
258+
case .closing(let remaining, let promise):
259+
if remaining == 1 {
260+
self.state = .closed
261+
promise?.succeed()
262+
} else {
263+
self.state = .closing(remaining: remaining - 1, promise)
264+
}
256265
return
266+
267+
case .closed:
268+
preconditionFailure("Invalid state: \(self.state)")
257269
}
258270

259271
// Ok, we're still active. Before we do anything, we want to check whether anyone is still waiting
@@ -315,39 +327,42 @@ extension ConnectionPool {
315327
private func closePool(promise: EventLoopPromise<Void>?, logger: Logger) {
316328
self.loop.preconditionInEventLoop()
317329

318-
// Pool closure must be monotonic.
319-
guard case .active = self.state else {
320-
logger.info("received duplicate request to close connection pool")
321-
promise?.succeed(())
322-
return
323-
}
330+
switch self.state {
331+
case .active:
332+
self.state = .closing(remaining: self.activeConnectionCount, promise)
324333

325-
self.state = .closing
334+
case .closing(let count, let existingPromise):
335+
if let existingPromise = existingPromise {
336+
existingPromise.futureResult.cascade(to: promise)
337+
} else {
338+
self.state = .closing(remaining: count, promise)
339+
}
340+
return
326341

327-
// To close the pool we need to drop all active connections.
328-
let connections = self.availableConnections
329-
self.availableConnections = []
330-
let closeFutures = connections.map { $0.close() }
342+
case .closed:
343+
promise?.succeed()
344+
return
345+
}
331346

332347
// We also cancel all pending leases.
333348
while let pendingLease = self.connectionWaiters.popFirst() {
334349
pendingLease.fail(RedisConnectionPoolError.poolClosed)
335350
}
336351

337-
guard self.activeConnectionCount == 0 else {
338-
logger.debug("not closing pool, waiting for all connections to be returned", metadata: [
339-
RedisLogging.MetadataKeys.poolConnectionCount: "\(self.activeConnectionCount)"
340-
])
341-
promise?.fail(RedisConnectionPoolError.poolHasActiveConnections)
352+
if self.activeConnectionCount == 0 {
353+
// That was all the connections, so this is now closed.
354+
logger.trace("pool is now closed")
355+
self.state = .closed
356+
promise?.succeed()
342357
return
343358
}
344359

345-
// That was all the connections, so this is now closed.
346-
logger.trace("pool is now closed")
347-
self.state = .closed
348-
EventLoopFuture<Void>
349-
.andAllSucceed(closeFutures, on: self.loop)
350-
.cascade(to: promise)
360+
// To close the pool we need to drop all active connections.
361+
let connections = self.availableConnections
362+
self.availableConnections = []
363+
for connection in connections {
364+
self.closeConnectionForShutdown(connection)
365+
}
351366
}
352367

353368
/// This is the on-thread implementation for leasing connections out to users. Here we work out how to get a new
@@ -401,51 +416,66 @@ extension ConnectionPool {
401416
private func _returnLeasedConnection(_ connection: RedisConnection, logger: Logger) {
402417
self.loop.assertInEventLoop()
403418
self.leasedConnectionCount -= 1
404-
self._returnConnection(connection, logger: logger)
419+
420+
switch self.state {
421+
case .active:
422+
self._returnConnection(connection, logger: logger)
423+
424+
case .closing:
425+
return self.closeConnectionForShutdown(connection)
426+
427+
case .closed:
428+
preconditionFailure("Invalid state: \(self.state)")
429+
}
405430
}
406431

407432
/// This is the on-thread implementation for returning connections to the pool. Here we work out what to do with a newly-acquired
408433
/// connection.
409434
private func _returnConnection(_ connection: RedisConnection, logger: Logger) {
410435
self.loop.assertInEventLoop()
436+
precondition(self.state.isActive)
411437

412438
guard connection.isConnected else {
413439
// This connection isn't active anymore. We'll dump it and potentially kick off a reconnection.
414440
self.refillConnections(logger: logger)
415441
return
416442
}
417443

418-
switch self.state {
419-
case .active:
420-
// If anyone is waiting for a connection, let's give them this one. Otherwise, if there's room
421-
// in the pool, we'll put it there. Otherwise, we'll close it.
422-
if let waiter = self.connectionWaiters.popFirst() {
423-
self.leaseConnection(connection, to: waiter)
424-
} else if self.canAddConnectionToPool {
425-
self.availableConnections.append(connection)
426-
} else if let evictable = self.availableConnections.popFirst() {
427-
// We have at least one pooled connection. The returned is more recently active, so kick out the pooled
428-
// connection in favour of this one and close the recently evicted one.
429-
self.availableConnections.append(connection)
430-
_ = evictable.close()
431-
} else {
432-
// We don't need it, close it.
433-
_ = connection.close()
434-
}
435-
case .closed:
436-
// In general we shouldn't see leased connections return in .closed, as we should only be able to
437-
// transition to .closed when all the leases are back. We tolerate this in production builds by just closing the
438-
// connection, but in debug builds we assert to be sure.
439-
logger.warning("connection returned to closed pool", metadata: [
440-
RedisLogging.MetadataKeys.connectionID: "\(connection.id)"
441-
])
442-
assertionFailure("Returned connection to closed pool")
443-
fallthrough
444-
case .closing:
445-
// We don't need this connection, close it.
444+
// If anyone is waiting for a connection, let's give them this one. Otherwise, if there's room
445+
// in the pool, we'll put it there. Otherwise, we'll close it.
446+
if let waiter = self.connectionWaiters.popFirst() {
447+
self.leaseConnection(connection, to: waiter)
448+
} else if self.canAddConnectionToPool {
449+
self.availableConnections.append(connection)
450+
} else if let evictable = self.availableConnections.popFirst() {
451+
// We have at least one pooled connection. The returned is more recently active, so kick out the pooled
452+
// connection in favour of this one and close the recently evicted one.
453+
self.availableConnections.append(connection)
454+
_ = evictable.close()
455+
} else {
456+
// We don't need it, close it.
446457
_ = connection.close()
447-
guard self.leasedConnectionCount == 0 else { return }
448-
self.state = .closed
458+
}
459+
}
460+
461+
private func closeConnectionForShutdown(_ connection: RedisConnection) {
462+
connection.close().whenComplete { _ in
463+
self.loop.preconditionInEventLoop()
464+
465+
switch self.state {
466+
case .closing(let remaining, let promise):
467+
if remaining == 1 {
468+
self.state = .closed
469+
promise?.succeed()
470+
} else {
471+
self.state = .closing(remaining: remaining - 1, promise)
472+
}
473+
474+
case .closed, .active:
475+
// The state must not change if we are closing a connection, while we are
476+
// closing the pool.
477+
preconditionFailure("Invalid state: \(self.state)")
478+
}
449479
}
450480
}
451481
}
@@ -457,10 +487,19 @@ extension ConnectionPool {
457487

458488
/// The user has requested the connection pool to close, but there are still active connections leased to users
459489
/// and in the pool.
460-
case closing
490+
case closing(remaining: Int, EventLoopPromise<Void>?)
461491

462492
/// The connection pool is closed: no connections are outstanding
463493
case closed
494+
495+
var isActive: Bool {
496+
switch self {
497+
case .active:
498+
return true
499+
case .closing, .closed:
500+
return false
501+
}
502+
}
464503
}
465504
}
466505

Sources/RediStack/ConnectionPool/RedisConnectionPool.swift

-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ extension RedisConnectionPool {
104104
/// Closes all connections in the pool and deactivates the pool from creating new connections.
105105
///
106106
/// This method is safe to call multiple times.
107-
/// - Important: If the pool has connections in active use, the close process will not complete.
108107
/// - Parameters:
109108
/// - promise: A notification promise to resolve once the close process has completed.
110109
/// - logger: An optional logger to use for any log statements generated while closing the pool.

Tests/RediStackTests/ConnectionPoolTests.swift

+89
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,95 @@ final class ConnectionPoolTests: XCTestCase {
402402
connectionPromise?.fail(ConnectionPoolTestError.connectionFailedForSomeReason)
403403
}
404404

405+
func testShutdownPoolThatHasConnectionThatFailsInCreation() {
406+
var connectionPromise: EventLoopPromise<RedisConnection>? = nil
407+
let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in
408+
XCTAssertTrue(loop === self.server.loop)
409+
connectionPromise = self.server.loop.makePromise()
410+
return connectionPromise!.futureResult
411+
}
412+
pool.activate()
413+
XCTAssertNoThrow(try self.server.runWhileActive())
414+
XCTAssertEqual(self.server.channels.count, 0)
415+
XCTAssertNotNil(connectionPromise)
416+
417+
var failedLastConnection = false
418+
let closePromise = self.server.loop.makePromise(of: Void.self)
419+
420+
pool.close(promise: closePromise)
421+
closePromise.futureResult.whenComplete { _ in
422+
XCTAssertTrue(failedLastConnection)
423+
}
424+
425+
failedLastConnection = true
426+
connectionPromise?.fail(ConnectionPoolTestError.connectionFailedForSomeReason)
427+
XCTAssertNoThrow(try closePromise.futureResult.wait())
428+
}
429+
430+
func testShutdownPoolThatHasConnectionThatSucceedsInCreation() {
431+
var connectionPromise: EventLoopPromise<RedisConnection>? = nil
432+
let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in
433+
XCTAssertTrue(loop === self.server.loop)
434+
connectionPromise = self.server.loop.makePromise()
435+
return connectionPromise!.futureResult
436+
}
437+
pool.activate()
438+
XCTAssertNoThrow(try self.server.runWhileActive())
439+
XCTAssertEqual(self.server.channels.count, 0)
440+
XCTAssertNotNil(connectionPromise)
441+
442+
var lastConnectionCreated = false
443+
let closePromise = self.server.loop.makePromise(of: Void.self)
444+
445+
pool.close(promise: closePromise)
446+
closePromise.futureResult.whenComplete { _ in
447+
XCTAssertTrue(lastConnectionCreated)
448+
}
449+
450+
let singleConnection = self.createAConnection()
451+
lastConnectionCreated = true
452+
connectionPromise?.succeed(singleConnection)
453+
self.server.loop.run()
454+
XCTAssertNoThrow(try singleConnection.channel.closeFuture.wait(), "New connection is closed right away.")
455+
XCTAssertNoThrow(try closePromise.futureResult.wait())
456+
}
457+
458+
func testShutdownPoolThatHasLeasedConnection() {
459+
var connectionPromise: EventLoopPromise<RedisConnection>? = nil
460+
let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in
461+
XCTAssertTrue(loop === self.server.loop)
462+
connectionPromise = self.server.loop.makePromise()
463+
return connectionPromise!.futureResult
464+
}
465+
pool.activate()
466+
XCTAssertNoThrow(try self.server.runWhileActive())
467+
XCTAssertEqual(self.server.channels.count, 0)
468+
XCTAssertNotNil(connectionPromise)
469+
470+
var leasedConnectionReturned = false
471+
let closePromise = self.server.loop.makePromise(of: Void.self)
472+
473+
let singleConnection = self.createAConnection()
474+
connectionPromise?.succeed(singleConnection)
475+
476+
var leasedConnection: RedisConnection?
477+
XCTAssertNoThrow(leasedConnection = try pool.leaseConnection(deadline: .distantFuture).wait())
478+
XCTAssert(singleConnection === leasedConnection)
479+
480+
pool.close(promise: closePromise)
481+
closePromise.futureResult.whenComplete { _ in
482+
XCTAssertTrue(leasedConnectionReturned)
483+
}
484+
485+
leasedConnectionReturned = true
486+
pool.returnConnection(singleConnection)
487+
488+
self.server.loop.run()
489+
XCTAssertNoThrow(try singleConnection.channel.closeFuture.wait(), "New connection is closed right away.")
490+
XCTAssertNoThrow(try closePromise.futureResult.wait())
491+
}
492+
493+
405494
func testNonLeakyBucketWillKeepConnectingIfThereIsSpaceAndWaiters() throws {
406495
var connectionPromise: EventLoopPromise<RedisConnection>? = nil
407496
let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, leaky: false) { loop in

0 commit comments

Comments
 (0)