Consider new RedisCommand protocol API
Problem Statement
1. Memory
The current RedisCommand
API allocates memory extremely often. To understand this we must first look at the code for RedisCommand
and RESPValue
:
public struct RedisCommand<ResultType> {
public let keyword: String
public let arguments: [RESPValue]
/// Serializes the entire command into a single value for sending to Redis.
/// - Returns: A `RESPValue.array` value of the keyword and its arguments.
public func serialized() -> RESPValue {
var message: [RESPValue] = [.init(bulk: self.keyword)]
message.append(contentsOf: self.arguments)
return .array(message)
}
// ...
}
public enum RESPValue {
case null
case simpleString(ByteBuffer)
case bulkString(ByteBuffer?)
case error(RedisError)
case integer(Int)
case array([RESPValue])
}
If we consider a simple command like GET
here, we can clearly see four allocations:
- For allocating the
arguments: [RESPValue]
array - For allocating the
RESPValue.bulkString
ByteBuffer - For creating the
[RESPValue]
array in the serialization with a capacity of one - For creating the
RESPValue.bulkString
for the keyword - A realloc when appending the arguments to the
[RESPValue]
array
This does not seem very efficient.
2. Forward looking consideration (Cluster/Connection commands)
Currently RediStack mostly exposes an API for dealing with connections to single Redis instances. If we move forward with #117 we will have operations that can not be run on a cluster, but must be run on a connection to a given endpoint. We should add marker protocols to determine where a command can be run. This will ensure that users only use ClusterCommands that can be run against a Cluster with the new RedisClusterClient.
Proposed Solution
We should consider making the RedisCommand a protocol:
protocol RedisCommand {
/// the response type for this command
associatedtype Response
/// encode the command into the outbound buffer
/// TODO: We might want to make it easier for adopters to correctly encode a RESPValue.array without actually creating one.
func encode(into inout: ByteBuffer)
/// decode the returned RESPValue to the response type
func decode(from: RESPValue) throws -> Response
}
If we now look at a GET command, we can see the following implementation:
struct GET: RedisCommand {
typealias Response = String?
var key: String
init(_ key: String) {
self.key = key
}
func encode(into inout: ByteBuffer) {
buffer.writeBytes("*2\r\n$3\r\nGET\r\n$".utf8)
buffer.writeBytes(self.key.utf8.count.description.utf8)
buffer.writeBytes("\r\n".utf8)
buffer.writeBytes(self.key.utf8)
buffer.writeBytes("\r\n".utf8)
}
func decode(from respValue: RESPValue) throws -> Response {
String(fromRESP: respValue)
}
}
On the encoding path, we can see that we could reduce the number of allocations from 5 down to 0. Even more importantly we were able to move the encoding of the command into a larger static chunks that can be moved copied into the outbound buffer in a single operation.
Further if we now add an API like this to a RedisConnection:
final class RedisConnection {
func execute<Command: RedisCommand, Response>(_ command: Command, logger: Logger) -> EventLoopFuture<Response> where RedisCommand.Response == Response
}
We have quite a nice extensible API without needing to explicitly extend RedisClient
. Lastly for a RedisClusterClient
we could constrain the commands that can be sent on it like this:
protocol RedisClusterCommand: RedisCommand {
func hashSlot() throws -> UInt16
}
final class RedisClusterClient {
func execute<Command: RedisClusterCommand, Response>(_ command: Command, logger: Logger) -> EventLoopFuture<Response> where RedisCommand.Response == Response
}
Now users can not send "regular" RedisCommand
s on the Cluster anymore.
Alternatives
RESPValue.simpleString
and RESPValue.bulkString
to Strings.
Convert the ByteBuffers in This would save us two allocations in the above example when encoding the value to the wire. However on the receiving side this might lead to increased allocations. It would just solve the memory allocation aspect. Users would still be able to send all commands on all clients (connection and cluster clients).
@Mordil Wdyt?