Skip to content

Commit 2d626c8

Browse files
authored
Cherry pick GitLab 187: Graceful connection close without sending QUIT command (#85)
Cherry pick: https://gitlab.com/swift-server-community/RediStack/-/merge_requests/187 > Note: Clients should not use this command. Instead, clients should simply close the connection when they're not used anymore. Terminating a connection on the client side is preferable, as it eliminates TIME_WAIT lingering sockets on the server side. https://redis.io/commands/quit/
1 parent 99cd4a4 commit 2d626c8

File tree

2 files changed

+32
-53
lines changed

2 files changed

+32
-53
lines changed

Sources/RediStack/RedisConnection.swift

Lines changed: 16 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,17 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext {
170170
self.channel.closeFuture.whenSuccess {
171171
// if our state is still open, that means we didn't cause the closeFuture to resolve.
172172
// update state, metrics, and logging
173-
guard self.state.isConnected else { return }
174-
173+
let oldState = self.state
175174
self.state = .closed
176-
self.logger.error("connection was closed unexpectedly")
177175
RedisMetrics.activeConnectionCount.decrement()
178-
self.onUnexpectedClosure?()
176+
177+
switch oldState {
178+
case .shuttingDown, .closed:
179+
break
180+
case .open, .pubsub:
181+
logger.warning("connection was closed unexpectedly")
182+
self.onUnexpectedClosure?()
183+
}
179184
}
180185

181186
self.logger.trace("connection created")
@@ -333,62 +338,21 @@ extension RedisConnection {
333338
// we're now in a shutdown state, starting with the command queue.
334339
self.state = .shuttingDown
335340

336-
let notification = self.sendQuitCommand(logger: logger) // send "QUIT" so that all the responses are written out
337-
.flatMap { self.closeChannel() } // close the channel from our end
341+
// Inform ChannelHandler about close intent using "RedisGracefulConnectionCloseEvent"
342+
let closePromise = self.eventLoop.makePromise(of: Void.self)
343+
let closeFuture = closePromise.futureResult
344+
self.channel.triggerUserOutboundEvent(RedisGracefulConnectionCloseEvent(), promise: closePromise)
338345

339-
notification.whenFailure {
346+
closeFuture.whenFailure {
340347
logger.error("error while closing connection", metadata: [
341348
RedisLogging.MetadataKeys.error: "\($0)"
342349
])
343350
}
344-
notification.whenSuccess {
345-
self.state = .closed
351+
closeFuture.whenSuccess {
346352
logger.trace("connection is now closed")
347-
RedisMetrics.activeConnectionCount.decrement()
348353
}
349354

350-
return notification
351-
}
352-
353-
/// Bypasses everything for a normal command and explicitly just sends a "QUIT" command to Redis.
354-
/// - Note: If the command fails, the `NIO.EventLoopFuture` will still succeed - as it's not critical for the command to succeed.
355-
private func sendQuitCommand(logger: Logger) -> EventLoopFuture<Void> {
356-
let promise = channel.eventLoop.makePromise(of: RESPValue.self)
357-
let command = RedisCommand(
358-
message: .array([RESPValue(bulk: "QUIT")]),
359-
responsePromise: promise
360-
)
361-
362-
logger.trace("sending QUIT command")
363-
364-
return channel.writeAndFlush(command) // write the command
365-
.flatMap { promise.futureResult } // chain the callback to the response's
366-
.map { _ in logger.trace("sent QUIT command") } // ignore the result's value
367-
.recover { _ in logger.debug("recovered from error sending QUIT") } // if there's an error, just return to void
368-
}
369-
370-
/// Attempts to close the `NIO.Channel`.
371-
/// SwiftNIO throws a `NIO.EventLoopError.shutdown` if the channel is already closed,
372-
/// so that case is captured to let this method's `NIO.EventLoopFuture` still succeed.
373-
private func closeChannel() -> EventLoopFuture<Void> {
374-
let promise = self.channel.eventLoop.makePromise(of: Void.self)
375-
376-
self.channel.close(promise: promise)
377-
378-
// if we succeed, great, if not - check the error that happened
379-
return promise.futureResult
380-
.flatMapError { error in
381-
guard let e = error as? EventLoopError else {
382-
return self.eventLoop.makeFailedFuture(error)
383-
}
384-
385-
// if the error is that the channel is already closed, great - just succeed.
386-
// otherwise, fail the chain
387-
switch e {
388-
case .shutdown: return self.eventLoop.makeSucceededFuture(())
389-
default: return self.eventLoop.makeFailedFuture(e)
390-
}
391-
}
355+
return closeFuture
392356
}
393357
}
394358

Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ internal final class EmbeddedMockRedisServer {
6868
}
6969

7070
func createConnectedChannel() -> Channel {
71-
let channel = EmbeddedChannel(loop: self.loop)
71+
let channel = EmbeddedChannel(handler: GracefulShutdownToCloseHandler(), loop: self.loop)
7272
channel.closeFuture.whenComplete { _ in
7373
self.channels.removeAll(where: { $0 === channel })
7474
}
@@ -84,3 +84,18 @@ internal final class EmbeddedMockRedisServer {
8484
try self.loop.close()
8585
}
8686
}
87+
88+
/// A `ChannelHandler` that triggers a channel close once `RedisGracefulConnectionCloseEvent` is received
89+
private final class GracefulShutdownToCloseHandler: ChannelHandler, ChannelOutboundHandler {
90+
typealias OutboundIn = NIOAny
91+
92+
func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise<Void>?) {
93+
switch event {
94+
case is RedisGracefulConnectionCloseEvent:
95+
context.close(mode: .all, promise: promise)
96+
97+
default:
98+
context.triggerUserOutboundEvent(event, promise: promise)
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)