diff --git a/docs/design-api.md b/docs/design-api.md new file mode 100644 index 0000000000..04b60fcfb4 --- /dev/null +++ b/docs/design-api.md @@ -0,0 +1,117 @@ +API Design + +# Client Wrapper API design doc + +## API requirements: +- The API will be thread-safe. +- The API will accept as inputs all of [RESP2 types](https://github.com/redis/redis-specifications/blob/master/protocol/RESP2.md). We plan to add support for RESP3 types when they are available. +- The API will attempt authentication, topology refreshes, reconnections, etc., automatically. In case of failures concrete errors will be returned to the user. + +## Command Interface + +### Unix Domain Socket solution +For clients based on Unix Domain Sockets (UDS), we will use the existing Rust-core protobuf messages for creating a connection, sending requests, and receiving responses. Supported commands are enumerated in the [protobuf definition for requests](../babushka-core/src/protobuf/redis_request.proto) and we will support them as they are added in the future. Note: the `CustomCommand` request type is also adequate for all commands. As defined in the [protobuf definition for responses](../babushka-core/src/protobuf/response.proto), client wrappers will receive data as a pointer, which can be passed to Rust to marshal the data back into the wrapper language’s native data types. In the future, we plan on optimizing this approach such that small data is returned as an object and large data is returned as a pointer in order to reduce the overhead of FFI calls in the case of small data. + +Transactions will be handled by adding a list of `Command`s to the protobuf request. The response will be a `redis::Value::Bulk`, which should be handled in the same Rust function that marshals the data back into the wrapper language's native data types. This is handled by storing the results in a collection type native to the wrapper language. + +When running Redis in Cluster Mode, several routing options will be provided. These are all specified in the protobuf request. The various options are detailed below in the ["Routing Options" section](#routing-options). We will also provide a separate client for handling Cluster Mode responses, which will convert the list of values and nodes into a map, as is done in existing client wrappers. + +### Raw FFI solution +For clients using a raw FFI solution, in Rust, we will expose a general command that is able to take any command and arguments as strings. + +Like in the UDS solution, we will support a separate client for Cluster Mode. + +We have 2 options for passing the command, arguments, and any additional configuration to the Rust core from the wrapper language: + +#### Protobuf +The wrapper language will pass the commands, arguments, and configuration as protobuf messages using the same definitions as in the UDS solution. + +Transactions will be handled by adding a list of `Command`s to the protobuf request. The response will be a `redis::Value::Bulk`, which can be marshalled into a C array of values before being passed from Rust to the wrapper language. The wrapper language is responsible for converting the array of results to its own native collection type. + +Cluster Mode support is the same here as in the UDS solution detailed above. + +Pros: +- We get to reuse the protobuf definitions, meaning fewer files to update if we make changes to the protobuf definitions +- May be simpler to implement compared to the C data types solution, since we do not need to define our own C data types + +Cons: +- There is additional overhead from marshalling data to and from protobuf, which could impact performance significantly + +#### C Data Types +The wrapper language will pass commands, arguments, and configuration as C data types. + +Transactions will be handled by passing a C array of an array of arguments to Rust from the wrapper language. The response will be a `redis::Value::Bulk`, which can be marshalled in the same way as explained in the protobuf solution. + +For Cluster Mode support, [routing options](#routing-options) will be defined as C enums and structs. Like in the protobuf solution, we will provide a separate client for handling Cluster Mode responses, which will convert the list of values and nodes into a map. + +Pros: +- No additional overhead from marshalling to and from protobuf, so this should perform better +- May be simpler to implement compared to protobuf solution, since it can be tricky to construct protobuf messages in a performant way and we have to add a varint to the messages as well + +Cons: +- Would add an additional file to maintain containing the C definitions (only one file though, since we could share between all raw FFI solutions), which we would need to update every time we want to update the existing protobuf definitions + +We will be testing both approaches to see which is easier to implement, as well as the performance impact before deciding on a solution. + +To marshal Redis data types back into the corresponding types for the wrapper language, we will convert them into appropriate C types, which can then be translated by the wrapper language into its native data types. Here is what a Redis result might look like: +``` +typedef struct redisValue { + enum {NIL, INT, DATA, STATUS, BULK, OKAY} kind; + union Payload { + long intValue; + unsigned char *dataValue; + char *statusValue; + struct redisValue *bulkValue; + } payload; +} RedisValue +``` + +## Routing Options +We will be supporting routing Redis requests to all nodes, all primary nodes, or a random node. For more specific routing to a node, we will also allow sending a request to a primary or replica node with a specified hash slot or key. When the wrapper given a key route, the key is passed to the Rust core, which will find the corresponding hash slot for it. + +## Supported Commands +We will be supporting all Redis commands. Commands with higher usage will be prioritized, as determined by usage numbers from AWS ElastiCache usage logs. + +Two different methods of sending commands will be supported: + +### Custom Command +We will expose an `executeRaw` method that does no validation of the input types or command on the client side, leaving it up to Redis to reject the command should it be malformed. This gives the user the flexibility to send any type of command they want, including ones not officially supported yet. + +For example, if a user wants to implement support for the Redis ZADD command in Java, their implementation might look something like this: +```java +public Long zadd(K key, double score, V member) throws RequestException { + string[] args = { key.toString(), score.toString(), member.toString() }; + return (Long) executeRaw(args); +} +``` + +where `executeRaw` has the following signature: +```java +public Object executeRaw(string[] args) throws RequestException +``` + +### Explicitly Supported Command +We will expose separate methods for each supported command. There will be a separate version of each method for transactions, as well as another version for Cluster Mode clients. For statically typed languages, we will leverage the compiler of the wrapper language to validate the types of the command arguments as much as possible. Since wrappers should be as lightweight as possible, we will be performing very few to no checks for proper typing for non-statically typed languages. + +## Errors +ClosingError: Errors that report that the client has closed and is no longer usable. + +RedisError: Errors that were reported during a request. + +TimeoutError: Errors that are thrown when a request times out. + +ExecAbortError: Errors that are thrown when a transaction is aborted. + +ConnectionError: Errors that are thrown when a connection disconnects. These errors can be temporary, as the client will attempt to reconnect. + +Errors returned are subject to change as we update the protobuf definitions. + +## Java Specific Details +We will be using the UDS solution for communication between the wrapper and the Rust core. This thin layer is implemented using the [jni-rs library](https://github.com/jni-rs/jni-rs) to start the socket listener and marshal Redis values into native Java data types. + +Errors in Rust are represented as Algebraic Data Types, which are not supported in Java by default (at least not in the versions of Java we want to support). Instead, we utilise the [jni-rs library](https://github.com/jni-rs/jni-rs) to throw Java `Exception`s where we receive errors from Redis. + +## Golang Specific Details +We will be using a raw FFI solution for communication between the wrapper and the Rust core. The FFI layer is implemented using `cgo`. + +Golang does not support Algebraic Data Types, so errors will need to be returned as struct values. diff --git a/docs/design-go-api.md b/docs/design-go-api.md new file mode 100644 index 0000000000..501e4aeb45 --- /dev/null +++ b/docs/design-go-api.md @@ -0,0 +1,252 @@ +# Go API Design documentation + +## Overview + +This document presents the high-level user API for the Go-Wrapper client library. Specifically, it demonstrates how the user connects to Redis, executes requests, receives responses, and checks for errors. + +## Requirements + +- The minimum supported Go version will be 1.18. This version introduces support for generics, including type constraints and type sets. +- The API will be thread-safe. +- The API will accept as inputs all [RESP3 types](https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md). +- The API will attempt authentication, topology refreshes, reconnections, etc., automatically. In case of failures concrete errors will be returned to the user. + +# Use Cases + +### Case 1: Create Redis client and connect + +```go +var config *api.RedisClientConfiguration = api.NewRedisClientConfiguration(). + WithAddress(&api.NodeAddress{"some_host", 1234}). + WithUseTLS(true) + +// Create a client and connect +var client *api.RedisClient +var err error +client, err = api.CreateClient(config) +``` + +### Case 2: Connection to Redis fails with ClosingError +```go +var client *api.RedisClient +var err error +client, err := api.CreateClient(config) + +// User can check specifically for a ClosingError: +if err != nil { + closingErr, isClosingErr := err.(api.ClosingError) + if isClosingErr { + log.Fatal("Failed to connect to Redis: " + closingErr.Error()) + } +} +``` + +### Case 3: Connect to Redis with deferred cleanup +```go +func connectAndGet(config *RedisClientConfiguration, key string) string { + var client *api.RedisClient + var err error + client, err = api.CreateClient(config) + if err != nil { + log.Fatal("Redis client failed with: " + err.Error()) + } + + // client.Close() is executed when the function exits. + // The client is available until the end of the function. + defer client.Close() + + result, err := client.Get(key) + if err != nil { + // If we enter this branch, client.Close() will be executed after logging this message. + log.Fatal("Redis Get failed with: " + err.Error()) + } + + // client.Close() will be executed when the result is returned. + return result +} +``` + +### Case 4: Connect to Redis cluster +```go +var config *api.RedisClusterClientConfiguration +config = api.NewRedisClusterClientConfiguration(). + WithAddress(&api.NodeAddress{Host: "host1", Port: 1234}). + WithAddress(&api.NodeAddress{Host: "host2", Port: 1234}). + WithUseTLS(true) + +var client *api.RedisClusterClient +var err error +client, err = api.CreateClusterClient(config) +``` + +### Case 5: Get(key) from connected RedisClient +```go +result, err := client.Get("apples") +fmt.Println("The value associated with 'apples' is: " + result) +``` + +### Case 6: Set(key, value) from connected RedisClient +```go +// Without setOptions +err := client.Set("apples", "oranges") + +// With setOptions +var setOptions *api.SetOptions +setOptions = api.NewSetOptions(). + WithReturnOldValue(true) +oldValue, err := client.SetWithOptions("apples", "oranges", setOptions) +``` + +### Case 7: Get(key) from a disconnected RedisClient +Return a api.ConnectionError if the RedisClient fails to connect to Redis +```go +result, err := client.Get("apples") +if err != nil { + connErr, isConnErr := err.(api.ConnectionError) + if isConnErr { + log.Fatal("RedisClient get failed with: " + connErr.Error()) + } +} +``` + +### Case 8: Send custom command to RedisClient +```go +var result interface{} +var err error + +result, err = client.CustomCommand([]{"GET", "apples"}) +if err != nil { + log.Fatal("RedisClient failed to execute custom command with: " + err.Error()) +} + +strResult, isString := result.(string) +if !isString { + log.Fatal("Expected result to be of type string but the actual type was: " + reflect.TypeOf(result)) +} +``` + +### Case 9: Send transaction to RedisClient +```go +transaction := api.NewTransaction() +transaction.Get("apples") +transaction.Get("pears") +transaction.Set("cherries", "Bing") + +var result []interface{} +var err error +result, err = client.Exec(transaction) +if err != nil { + log.Fatal("Redis client transaction failed with: " + err.Error()) +} + +firstResponse := result[0] // evaluates to a string +secondResponse := result[1] // evaluates to a string +thirdResponse := result[2] // evaluates to nil +``` + +### Case 10: Send Get request to a RedisClusterClient with one address +```go +var config *api.ClusterClientConfiguration +config = api.NewClusterClientConfiguration(). + WithAddress(&api.NodeAddress{Host: "some_host", Port: 1234}). + WithUseTLS(true) + +// Create a client and connect +var client *api.RedisClusterClient +var err error +client, err = api.CreateClusterClient(config) +if err != nil { + log.Fatal("Redis client failed with: " + err.Error()) +} + +result, err := client.Get("apples") +``` + +### Case 11: Send Ping request to a RedisClusterClient with multiple addresses +```go +var config *api.ClusterClientConfiguration +config = api.NewClusterClientConfiguration(). + WithAddress(&api.NodeAddress{Host: "host1", Port: 1234}). + WithAddress(&api.NodeAddress{Host: "host2", Port: 1234}). + WithUseTLS(true) + +// Create a client and connect +var client *api.RedisClusterClient +var err error +client, err = api.CreateClusterClient(config) +if err != nil { + log.Fatal("Redis client failed with: " + err.Error()) +} + +// Without message or route +result, err := client.Ping() + +// With message +result, err := client.PingWithMessage("Ping received") + +// With route +result, err := client.PingWithRoute(api.NewRoute(api.AllNodes)) + +// With message and route +result, err := client.PingWithMessageAndRoute("Ping received", api.NewRoute(api.AllNodes)) +``` + +### Case 12: Get(key) encounters a TimeoutError +```go +result, err := client.Get("apples") +if err != nil { + timeoutErr, isTimeoutErr := err.(api.TimeoutError) + if isTimeoutErr { + // Handle error as desired + } +} +``` + +### Case 13: Get(key) encounters a ConnectionError +```go +result, err := client.Get("apples") +if err != nil { + connErr, isConnErr := err.(api.ConnectionError) + if isConnErr { + // Handle error as desired + } +} +``` + +# API Design + +## Presentation + +![API Design](img/design-go-api.svg) + +# FFI Design + +## Client creation and connection + +### Sequence diagram + +![FFI Connection Sequence Diagram](img/FFI-conn-sequence.svg) + +### Struct diagram + +![FFI Connection Struct Diagram](img/FFI-conn-struct-diagram.svg) + +## Redis request succeeds + +### Sequence diagram + +![FFI Request Success Sequence Diagram](img/FFI-request-success-sequence.svg) + +### Struct diagram + +![FFI Request Success Struct Diagram](img/FFI-request-success-struct-diagram.svg) + +## Redis request fails + +### Sequence diagram + +![FFI Request Failure Sequence Diagram](img/FFI-request-failure-sequence.svg) + +### Struct diagram + +![FFI Request Failure Struct Diagram](img/FFI-request-failure-struct-diagram.svg) diff --git a/docs/design-java-api.md b/docs/design-java-api.md new file mode 100644 index 0000000000..d3af0571ad --- /dev/null +++ b/docs/design-java-api.md @@ -0,0 +1,269 @@ +Java API Design + +# Java API Design documentation + +## Overview + +This document is available to demonstrate the high-level and detailed design elements of the Java-Wrapper client library +interface. Specifically, it demonstrates how requests are received from the user, and responses with typing are delivered +back to the user. + +# Use Cases + +### Case 1: Connect to RedisClient + +```java +// create a client configuration for a standalone client and connect +babushka.client.api.RedisClientConfiguration configuration = + babushka.client.api.RedisClientConfiguration.builder() + .address(babushka.client.api.Addresses.builder() + .host(host) + .port(port) + .build()) + .useTLS(true) + .build(); + +// connect to Redis +CompletableFuture redisClientConnection = + babushka.client.api.RedisClient.CreateClient(configuration); + +// resolve the Future and get a RedisClient +babushka.client.api.RedisClient redisClient = redisClientConnection.get(); +``` + +### Case 2: Connection to RedisClient fails with ConnectionException +```java +// create a client configuration for a standalone client - check connection +CompletableFuture redisClientConnection = + babushka.client.api.RedisClient.CreateClient(configuration); + +// resolve the Future and get a RedisClient that is not connected +try{ + babushka.client.api.RedisClient redisClient=redisClientConnection.get(); +} catch (babushka.client.api.model.exceptions.RedisException redisException){ + if (redisException instanceOf babushka.client.api.model.exceptions.ConnectionException) { + throw new RuntimeException("Failed to connect to Redis: " + redisException.getMessage()); + } +} +``` + +### Case 3: Try RedisClient with resource +```java +try (RedisClient redisClient = RedisClient.CreateClient(configuration).get()) { + // use the RedisClient +} catch (babushka.client.api.model.exceptions.RedisException redisException) { + throw new RuntimeException("RedisClient failed with: " + redisException.getMessage()); +} +``` + +### Case 4: Connect to RedisClusterClient +```java +// create a client configuration for a standalone client and connect +babushka.client.api.RedisClusterClientConfiguration configuration = + babushka.client.api.RedisClusterClientConfiguration.builder() + .address(babushka.client.api.Addresses.builder() + .host(address_one) + .port(port_one) + .build()) + .address(babushka.client.api.Addresses.builder() + .host(address_two) + .port(port_two) + .build()) + .useTLS(true) + .build(); + +// connect to Redis +CompletableFuture redisClusterClientConnection = + babushka.client.api.RedisClusterClient.CreateClient(configuration); + +// resolve the Future and get a RedisClusterClient +babushka.client.api.RedisClusterClient redisClusterClient = redisClusterClientConnection.get(); +``` + +### Case 5: Connect to RedisClient and receive RESP2 responses (Future) +```java +// create a client configuration for a standalone client and connect +babushka.client.api.RedisClientConfiguration configuration = + babushka.client.api.RedisClientConfiguration.builder() + .address(babushka.client.api.Addresses.builder() + .host(host) + .port(port) + .build()) + .useTLS(true) + .useRESP2(true) + .build(); + +// connect to Redis +CompletableFuture redisClientConnection = + babushka.client.api.RedisClient.CreateClient(configuration); + +// resolve the Future and get a RedisClient +babushka.client.api.RedisClient redisClient = redisClientConnection.get(); +``` + +### Case 6: Get(key) from connected RedisClient +```java +CompletableFuture getRequest = redisClient.get("apples"); +String getValueStr = getRequest.get(); +``` + +### Case 7: Set(key, value) from connected RedisClient +```java +CompletableFuture setRequest = redisClient.set("apples", "oranges"); +setRequest.get(); // returns null when complete + +SetOptions setOptions = SetOptions.builder() + .returnOldValue(true) // returns a String + .build(); +CompletableFuture setRequest = redisClient.set("apples", "oranges", setOptions); +String oldValue = setRequest.get(); // returns a string unless .returnOldValue() is not true +``` + +### Case 8: Get(key) from a disconnected RedisClient +Throw a babushka.api.models.exceptions.ConnectionException if the RedisClient is closed/disconnected +```java +try { + CompletableFuture getRequest = redisClient.get("apples"); + String getValueStr = getRequest.get(); +} catch (babushka.client.api.model.exceptions.RedisException redisException) { + // handle RedisException + throw new RuntimeException("RedisClient get failed with: " + redisException.getMessage()); +} +``` + +### Case 9: Send customCommand to RedisClient and receive a RedisFuture (CompleteableFuture wrapper) +```java +// returns an Object: custom command requests don't have an associated return type +CompletableFuture customCommandRequest = redisClient.customCommand(StringCommands.GETSTRING, new String[]{"apples"}); +Object objectResponse = customCommandRequest.get(); +String stringResponse = (String) objectResponse; + +// We can use the RedisFuture wrapper to determine the typing instead +RedisFuture customCommandRedisFuture = redisClient.customCommand(StringCommands.GETSTRING, new String[]{"apples"}); +RedisResponse redisResponseObject = customCommandRedisFuture.getRedisResponse(); +// same as .get() +// Object objectResponse = redisResponseObject.getValue(); +switch(redisResponseObject.getValueType()) { + STRING: + String stringResponse = redisResponseObject.getString(); + break; + DEFAULT: + throw new RuntimeException("Unexpected value type returned"); +} + +``` + +### Case 10: Send transaction to RedisClient +```java +// submit three commands in a single transaction to Redis +Command getApplesRequest = Command.builder() + .requestType(GETSTRING) + .arguments(new String[]{apples"}) + .build(); +Command getPearsRequest = Command.builder() + .requestType(GETSTRING) + .arguments(new String[]{"pears"}) + .build(); +Command setCherriesRequest = Command.builder() + .requestType(SETSTRING) + .arguments(new String[]{"cherries", "Bing"}) + .build(); + +Transaction transaction = Transaction.builder() + .command(getAppleRequest) + .command(getPearRequest) + .command(setCherryRequest) + .build(); + +CompletableFuture> transactionRequest = redisClient.exec(transaction); +List genericResponse = transactionRequest.get(); +String firstResponse = (String) genericResponse.get(0); +String secondResponse = (String) genericResponse.get(1); +genericResponse.get(2); // returns null + +// calls .get() and returns a list of RedisResponse objects that can be type-checked +List transactionResponse = transactionRequest.getRedisResponseList(); + +// We can use the RedisFuture wrapper to determine the typing of each object in the list +transactionResponse.foreach( + r -> { switch(r.getValue()) { + VOID: + break; + STRING: + String stringResponse = r.getString(); + break; + DEFAULT: + throw new RuntimeException("Unexpected value type returned"); + } +}); +``` + +### Case 11: Send get request to a RedisClusterClient with one address +// TODO + +### Case 12: Send get request to a RedisClusterClient with multiple addresses +// TODO + +### Case 13: Request is interrupted +```java +CompletableFuture getRequest = redisClient.get("apples"); +try{ + RedisStringResponse getResponse = getRequest.get(); // throws InterruptedException +} catch (InterruptedException interruptedException) { + throw new RuntimeException("RedisClient was interrupted: " + interruptedException.getMessage()); +} +``` + +### Case 14: Request timesout +```java +CompletableFuture getRequest = redisClient.get("apples"); +try{ + RedisStringResponse getResponse = getRequest.get(); // throws TimeoutException +} catch (babushka.client.api.model.exceptions.RedisException redisException) { + if (redisException instanceOf babushka.client.api.model.exceptions.TimeoutException) { + throw new RuntimeException("RedisClient timedout: " + redisException.getMessage()); + } +} +``` + +# High-Level Architecture + +## Presentation + +![Architecture Overview](img/design-java-api-high-level.svg) + +## Responsibilities + +At a high-level the Java wrapper client has 3 layers: +1. The API layer that is exposed to the user +2. The service layer that deals with data mapping between the client models and data access models +3. The data access layer that is responsible for sending and receiving data from the Redis service + +# API Detailed Design + +## Presentation + +![API Design](img/design-java-api-detailed-level.svg) + +## Responsibilities + +1. A client library that can receive Redis service configuration, and connect to a standalone and clustered Redis service +2. Once connected, the client library can send single command requests to the Redis service +3. Once connected, the client library can send transactional/multi-command request to the Redis service +4. Success and Error feedback is returned to the user +5. Route descriptions are returned from cluster Redis services +6. The payload data in either RESP2 or RESP3 format is returned with the response + +# Response and Payload typing + +## Presentation + +![API Request and Response typing](img/design-java-api-sequence-datatypes.svg) + +## Responsibilities + +1. Data typing and verification is performed for known commands +2. Data is returned as a payload in the RedisResponse object on a success response +3. If no data payload is requested, the service returns an OK constant response +4. Otherwise, the service will cast to the specified type on a one-for-one mapping based on the command +5. If the casting fails, the Java-wrapper will report an Error diff --git a/docs/design-raw-ffi.md b/docs/design-raw-ffi.md new file mode 100644 index 0000000000..4e7d450710 --- /dev/null +++ b/docs/design-raw-ffi.md @@ -0,0 +1,240 @@ +# Babushka Core Wrappers + +## Summary + +The Babushka client allows Redis users to connect to Redis using a variety of commands through a thin-client optimized for +various languages. The client uses a performant core to establish and manage connections and communicate with Redis. The thin-client +wrapper talks to the core using an FFI (foreign function interface) to Rust. + +The following document discusses two primary communication protocol architectures for wrapping the Babushka clients. Specifically, +it details how Java-Babushka and Go-Babushka each use a different protocol and describes the advantages of each language-specific approach. + +# Unix Domain Socket Manager Connector + +## High-Level Design + +**Summary**: The Babushka "UDS" solution uses a socket listener to manage rust-to-wrapper worker threads, and unix domain sockets +to deliver command requests between the wrapper and redis-client threads. This works well because we allow the unix sockets to pass messages and manage threads +through the OS, and unix sockets are very performant. This results in simple/fast communication. The risk to avoid is that +unix sockets can become a bottleneck for data-intensive commands, and the library can spend too much time waiting on I/O +blocking operations. + +```mermaid +stateDiagram-v2 + direction LR + + Wrapper: Wrapper + UnixDomainSocket: Unix Domain Socket + RustCore: Rust-Core + + [*] --> Wrapper: User + Wrapper --> UnixDomainSocket + UnixDomainSocket --> Wrapper + RustCore --> UnixDomainSocket + UnixDomainSocket --> RustCore + RustCore --> Redis + Redis --> RustCore +``` + +## Decision to use UDS Sockets for a Java-Babushka Wrapper + +The decision to use Unix Domain Sockets (UDS) to manage the Java-wrapper to Babushka Redis-client communication was thus: +1. Java contains an efficient socket protocol library ([netty.io](https://netty.io/)) that provides a highly configurable environment to manage sockets. +2. Java objects serialization/de-serialization is an expensive operation, and a performing multiple io operations between raw-ffi calls would be inefficient. +3. The async FFI requests with callbacks requires that we manage multiple runtimes (Rust and Java Thread management), and JNI does not provide an out-of-box solution for this. + +### Decision Log + +| Protocol | Details | Pros | Cons | +|----------------------------------------------|-------------------------------------------------------------|-----------------------------|----------------------------------------------------| +| Unix Domain Sockets (jni/netty) | JNI to submit commands; netty.io for message passing; async | netty.io standard lib; | complex configuration; limited by socket interface | +| Raw-FFI (JNA, uniffi-rs, j4rs, interoptopus) | FFI to submit commands; Rust for message processing | reusable in other languages | slow performance and uses JNI under the hood | +| Panama/jextract | Performance similar to a raw-ffi using JNI | modern | lacks early Java support (JDK 18+); prototype | + +### Sequence Diagram + +```mermaid +sequenceDiagram + +participant Wrapper as Java-Wrapper +participant ffi as FFI +participant manager as Rust-Core +participant worker as Tokio Worker +participant SocketListener as Socket Listener +participant Socket as Unix Domain Socket +participant Client as Redis + +activate Wrapper +activate Client +Wrapper -)+ ffi: connect_to_redis +ffi -)+ manager: start_socket_listener(init_callback) + manager -) worker: Create Tokio::Runtime (count: CPUs) + activate worker + worker ->> SocketListener: listen_on_socket(init_callback) + SocketListener ->> SocketListener: loop: listen_on_client_stream + activate SocketListener + SocketListener -->> manager: + manager -->> ffi: socket_path +ffi -->>- Wrapper: socket_path + SocketListener -->> Socket: UnixStreamListener::new + activate Socket + SocketListener -->> Client: BabushkaClient::new +Wrapper ->> Socket: connect + Socket -->> Wrapper: +loop single_request + Wrapper ->> ffi: java_arg_to_redis + ffi -->> Wrapper: + Wrapper -> Wrapper: pack protobuf.redis_request + Wrapper ->> Socket: netty.writeandflush (protobuf.redis_request) + Socket -->> Wrapper: + Wrapper ->> Wrapper: wait + SocketListener ->> SocketListener: handle_request + SocketListener ->> Socket: read_values_loop(client_listener, client) + Socket -->> SocketListener: + SocketListener ->> Client: send(request) + Client -->> SocketListener: ClientUsageResult + SocketListener ->> Socket: write_result + Socket -->> SocketListener: + Wrapper ->> Socket: netty.read (protobuf.response) + Socket -->> Wrapper: + Wrapper ->> ffi: redis_value_to_java + ffi -->> Wrapper: + Wrapper ->> Wrapper: unpack protobuf.response +end +Wrapper ->> Socket: close() +Wrapper ->> SocketListener: shutdown + SocketListener ->> Socket: close() + deactivate Socket + SocketListener ->> Client: close() + SocketListener -->> Wrapper: + deactivate SocketListener + deactivate worker + deactivate Wrapper + deactivate Client +``` + +### Discussion +* `redis_value_to_java`: This ffi call is necessary to evaluate the Redis::Value response that Redis returns to Rust-core, +and needs to be converted to a `JObject` before it can be evaluated by Java. We are looking for alternatives to this call +to avoid an unnecessary ffi call. +* `java_arg_to_redis`: This ffi call is currently unnecessary, because all arguments sent are Strings. + + +### Elements +* **Java-Wrapper**: Our Babushka wrapper that exposes a client API (java, python, node, etc) +* **Babushka FFI**: Foreign Function Interface definitions from our wrapper to our Rust Babushka-Core +* **Babushka impl**: public interface layer and thread manager +* **Tokio Worker**: Tokio worker threads (number of CPUs) +* **SocketListener**: listens for work from the Socket, and handles commands +* **Unix Domain Socket**: Unix Domain Socket to handle incoming requests and response payloads between Rust-Core and Wrapper +* **Redis**: Our data store + +## Wrapper-to-Core Connector with raw-FFI calls + +**Summary**: Foreign Function Interface (FFI) calls are simple to implement, cross-language calls. The setup between Golang and the Rust-core +is fairly simple using the well-supported CGO library. While sending language calls is easy, setting it up in an async manner +requires that we handle async callbacks. Golang has a simple, light-weight solution to that, using goroutines and channels, +to pass callbacks and execution between the languages. + +```mermaid +stateDiagram-v2 + direction LR + + Wrapper: Golang Wrapper + FFI: Foreign Function Interface + RustCore: Rust-Core + + [*] --> Wrapper: User + Wrapper --> FFI + FFI --> Wrapper + RustCore --> FFI + FFI --> RustCore + RustCore --> Redis +``` + +## Decision to use Raw-FFI calls directly to Rust-Core for Golang Wrapper + +### Decision Log + +The decision to use raw FFI request from Golang to Rust-core was straight forward: +1. Golang contains goroutines as an alternative, lightweight, and performant solution serves as an obvious solution to pass request, even at scale. + +Due to lightweight thread management solution, we chose a solution that scales quickly and requires less configuration to achieve a performant solution +on par with existing industrial standards ([go-redis](https://github.com/redis/go-redis)). + +| Protocol | Details | Pros | Cons | +|--------------------------|---------|--------------------------------------------------------|--------------------------------------| +| Unix Domain Sockets | | UDS performance; consistent protocol between languages | complex configuration | +| Raw-FFI (CGO/goroutines) | | simplified and light-weight interface | separate management for each request | + +## Sequence Diagram - Raw-FFI Client + +**Summary**: If we make direct calls through FFI from our Wrapper to Rust, we can initiate commands to Redis. This allows us +to make on-demand calls directly to Rust-core solution. Since the calls are async, we need to manage and populate a callback +object with the response and a payload. + +We will need to avoid busy waits while waiting on the async response. The wrapper and Rust-core languages independently track +threads. On the Rust side, they use a Tokio runtime to manage threads. When the Rust-core is complete, and returning a Response, +we can use the Callback object to re-awake the wrapper thread manager and continue work. + +Go routines have a performant solution using light-weight go-routines and channels. Instead of busy-waiting, we awaken by +pushing goroutines to the result channel once the Tokio threads send back a callback. + +### Sequence Diagram + +```mermaid +sequenceDiagram + +participant Wrapper as Go-Wrapper +participant channel as Result Channel +participant ffi as Babushka FFI +participant manager as Babushka impl +participant worker as Tokio Worker +participant Client as Redis + +activate Wrapper +activate Client +Wrapper -)+ ffi: create_connection(connection_settings) +ffi ->>+ manager: start_thread_manager(init_callback) + manager ->> worker: Create Tokio::Runtime (count: CPUs) + activate worker + manager -->> Wrapper: Ok(BabushkaClient) + worker ->> Client: BabushkaClient::new + worker ->> worker: wait_for_work(init_callback) + +loop single_request +Wrapper ->> channel: make channel + activate channel +Wrapper -) ffi: command: single_command(protobuf.redis_request, &channel) +Wrapper ->> channel: wait + ffi ->> manager: cmd(protobuf.redis_request) + manager ->> worker: command: cmd(protobuf.redis_request) + worker ->> Client: send(command, args) + Client -->> worker: Result + worker -->> ffi: Ok(protobuf.response) + ffi -->> channel: Ok(protobuf.response) +channel ->> Wrapper: protobuf.response +Wrapper ->> channel: close + deactivate channel +end + +Wrapper -) worker: close_connection + worker -->> Wrapper: + deactivate worker + deactivate Wrapper + deactivate Client +``` + +### Discussion + +Message format interface: When passing messages between the Go-wrapper and Rust-core, we need to use a language-idiomatic +format. Protobuf, for example, passes messages in wire-frame. We could also pass messages using a custom C datatype. +Protobuf is available, but the overhead to encode and decode messages may make a custom C datatype more worthwhile. + +### Elements +* **Go-Wrapper**: Our Babushka wrapper that exposes a client API (Go, etc) +* **Result Channel**: Goroutine channel on the Babushka Wrapper +* **Babushka FFI**: Foreign Function Interface definitions from our wrapper to our Rust Babushka-Core +* **Babushka impl**: public interface layer and thread manager +* **Tokio Worker**: Tokio worker threads (number of CPUs) +* **Redis**: Our data store \ No newline at end of file diff --git a/docs/img/FFI-conn-sequence.svg b/docs/img/FFI-conn-sequence.svg new file mode 100644 index 0000000000..4e5039b333 --- /dev/null +++ b/docs/img/FFI-conn-sequence.svg @@ -0,0 +1,4 @@ + + + +
Application
Go-Wrapper
lib.rs
glide-core
CreateClient(config StandaloneClientConfig)
protobuf.ConnectionRequest
GlideClient::new
GlideClient
ConnectionResponse
RedisClient, RedisError
FFI Boundary
Go code
Rust code
Redis
GlideClient::new
GlideClient
\ No newline at end of file diff --git a/docs/img/FFI-conn-struct-diagram.svg b/docs/img/FFI-conn-struct-diagram.svg new file mode 100644 index 0000000000..06b49123b0 --- /dev/null +++ b/docs/img/FFI-conn-struct-diagram.svg @@ -0,0 +1,4 @@ + + + +
C struct: ConnectionResponse
connPtr: *const c_void
error: RedisErrorFFI
C struct: RedisErrorFFI
message: *const c_char
errorType: ErrorType
enum: ErrorType
ClosingError: int
RequestError: int
TimeoutError: int
ExecAbortError: int
ConnectionError: int
diff --git a/docs/img/FFI-request-failure-sequence.svg b/docs/img/FFI-request-failure-sequence.svg new file mode 100644 index 0000000000..642cd9bec5 --- /dev/null +++ b/docs/img/FFI-request-failure-sequence.svg @@ -0,0 +1,4 @@ + + + +
Go failureCallback
lib.rs
glide-core
Result Channel
executeCommand(protobuf.RedisRequest, &channel)
make channel
cmd(protobuf.RedisRequest)
wait
Redis
send(command, args)
Err
Err
failureCallback(
        RedisErrorFFI, 
        channelPtr C.uintptr_t)
close
RedisResponse (nil),
RedisError
Application
Go-Wrapper
execute command
response (nil),
RedisError
RedisResponse (nil),
RedisError
FFI Boundary
Go code
Rust code
\ No newline at end of file diff --git a/docs/img/FFI-request-failure-struct-diagram.svg b/docs/img/FFI-request-failure-struct-diagram.svg new file mode 100644 index 0000000000..c2159c8384 --- /dev/null +++ b/docs/img/FFI-request-failure-struct-diagram.svg @@ -0,0 +1,4 @@ + + + +
C struct: RedisErrorFFI
message: *const c_char
errorType: ErrorType
enum: ErrorType
ClosingError: int
RequestError: int
TimeoutError: int
ExecAbortError: int
ConnectionError: int
diff --git a/docs/img/FFI-request-success-sequence.svg b/docs/img/FFI-request-success-sequence.svg new file mode 100644 index 0000000000..1f70b415e9 --- /dev/null +++ b/docs/img/FFI-request-success-sequence.svg @@ -0,0 +1,4 @@ + + + +
Go successCallback
lib.rs
glide-core
Result Channel
executeCommand(protobuf.RedisRequest, &channel)
make channel
cmd(protobuf.RedisRequest)
wait
Redis
send(command, args)
Result
Result
successCallback(
        RedisValue, 
        channelPtr C.uintptr_t)
close
RedisResponse,
RedisError (nil)
Application
Go-Wrapper
execute command
response,
RedisError (nil)
RedisResponse,
RedisError (nil)
FFI Boundary
Go code
Rust code
\ No newline at end of file diff --git a/docs/img/FFI-request-success-struct-diagram.svg b/docs/img/FFI-request-success-struct-diagram.svg new file mode 100644 index 0000000000..3eb858064d --- /dev/null +++ b/docs/img/FFI-request-success-struct-diagram.svg @@ -0,0 +1,4 @@ + + + +
C struct: RedisValue
data: union RedisData
type: enum RedisType
C union: RedisData
simpleString: []char
bulkString: []char
verbatimString: []char
intVal: long long int
array: []RedisValue
map: Map
doubleVal: double
boolVal: short
bigNumber: []char
set: []RedisValue
attribute: Attribute
push: Push
C struct: Map
keys: []RedisValue
values: []RedisValue
enum: RedisType
NIL
SIMPLE_STRING
BULK_STRING
VERBATIM_STRING
OK
INT
ARRAY
MAP
DOUBLE
BOOLEAN
BIG_NUMBER
SET
ATTRIBUTE
PUSH
C struct: Attribute
data: RedisValue
attributes: Map
C struct: Push
pushKind: enum PushKind
data: []RedisValue
enum: PushKind
INVALIDATE
MESSAGE
P_MESSAGE
S_MESSAGE
UNSUBSCRIBE
P_UNSUBSCRIBE
S_UNSUBSCRIBE
SUBSCRIBE
P_SUBSCRIBE
S_SUBSCRIBE
\ No newline at end of file diff --git a/docs/img/design-go-api.svg b/docs/img/design-go-api.svg new file mode 100644 index 0000000000..74ba930161 --- /dev/null +++ b/docs/img/design-go-api.svg @@ -0,0 +1,4 @@ + + + +glide.baseRedisClientconfig: *ClientConfigurationGet(string): string, errorSet(string, string): errorSetWithOptions(string, string, SetOptions): string, errorClose(): errorcreateBaseClient(*ClientConfiguration): *BaseRedisClient, errorexecuteCommand(*protobuf.RedisRequest): *RedisResponse, errorglide.RedisClientbaseClient: *BaseRedisClientCreateClient(*StandaloneClientConfiguration): *RedisClient, errorExec(*StandaloneTransaction): []interface{}, error
implements
glide.baseClientConfigurationaddresses: []AddressInfouseTLS: boolcredentials:  AuthenticationOptionsreadFrom: ReadFromStrategyclientCreationTimeout: intresponseTimeout: inttoProtobufConnRequest(): *protobuf.ConnectionRequestglide.RedisClusterClientbaseClient: *BaseRedisClientCreateClusterClient(*ClusterClientConfiguration): *RedisClusterClient, errorExec(*ClusterTransaction): []interface{}, errorExecWithRoute(*ClusterTransaction, Route): []interface, error
implements
glide.StandaloneClientConfigurationbaseConfig: *BaseClientConfigurationbackoffStrategy: *BackoffStrategydatabaseId: intNewClientConfiguration(): *StandaloneClientConfigurationWithAddress(AddressInfo): *StandaloneClientConfigurationWithUseTLS(bool): *StandaloneClientConfigurationWithCredentials(*AuthenticationOptions): *StandaloneClientConfigurationWithReadFromStrategy(ReadFromStrategy): *StandaloneClientConfigurationWithClientCreationTimeout(int): *StandaloneClientConfigurationWithResponseTimeout(int): *StandaloneClientConfigurationWithBackoffStrategy(*BackoffStrategy): *StandaloneClientConfigurationWithDatabaseId(int): *StandaloneClientConfigurationtoProtobufConnRequest(): *protobuf.ConnectionRequest
implements
glide.ClusterClientConfigurationbaseConfig: *BaseClientConfigurationNewClusterClientConfiguration(): *ClusterClientConfigurationWithAddress(AddressInfo): *ClusterClientConfigurationWithUseTLS(bool): *ClusterClientConfigurationWithCredentials(*AuthenticationOptions): *ClusterClientConfigurationWithReadFromStrategy(ReadFromStrategy): *ClusterClientConfigurationWithClientCreationTimeout(int): *ClusterClientConfigurationWithResponseTimeout(int): *ClusterClientConfigurationtoProtobufConnRequest(): *protobuf.ConnectionRequestinterface glide.ClientConfigurationtoProtobufConnRequest(): *protobuf.ConnectionRequestglide.ClosingErrorredisError: RedisErrorglide.RequestErrorredisError: RedisErrorglide.TimeoutErrorredisError: RedisErrorglide.ExecAbortErrorredisError: RedisErrorglide.ConnectionErrorredisError: RedisErrorglide.RedisErrormessage: StringError(): str
\ No newline at end of file diff --git a/docs/img/design-java-api-detailed-level.svg b/docs/img/design-java-api-detailed-level.svg new file mode 100644 index 0000000000..95aa1ca29e --- /dev/null +++ b/docs/img/design-java-api-detailed-level.svg @@ -0,0 +1,4 @@ + + + +
babushka.api.commands
babushka.api.commands
babushka.api.c.BaseCommands
babushka.api.c.BaseCommands
- exec(Transaction): CFuture<T>
- exec(Transaction): CFuture<T>
- customCommand(String, List): CFuture<T>
- customCommand(String, List): CFuture<T>
- exec(Command): CFuture<T>
- exec(Command): CFuture<T>
babushka.api.c.StringCommands
babushka.api.c.StringCommands
+ get(String): CompleteableFuture<String>
+ get(String): CompleteableFuture<String>
+ ping(String): CFuture<String>
+ ping(String): CFuture<String>
babushka.api.c.SortedSetCommands
babushka.api.c.SortedSetCommands
babushka.api.RedisClient
babushka.api.RedisClient
+ CreateClient(RedisClientConfiguration): CFuture<RedisClient>
+ CreateClient(RedisClientConfiguration): CFuture<RedisClient>
babushka.api.RedisClusterClient
babushka.api.RedisClusterClient
+ CreateClient(RedisClusterClientConfiguration): CFuture<RedisClusterClient>
+ CreateClient(RedisClusterClientConfiguration): CFuture<RedisClusterClient>
babushka.api.BaseClient
babushka.api.BaseClient
+ configuration: BaseClientConfiguration
+ configuration: BaseClientConfiguration
+ connection: ConnectionManager
+ connection: ConnectionManager
+ CreateClient(Config): CFuture<BaseClient>
+ CreateClient(Config): CFuture<BaseClient>
+ isConnected(): boolean
+ isConnected(): boolean
+ close(): CFuture<RedisClient>
+ close(): CFuture<RedisClient>
+ hasError(): boolean
+ hasError(): boolean
+ getError(): @nullable RedisException
+ getError(): @nullable RedisException
babushka.api.BaseClientConfiguration
babushka.api.BaseClientConfiguration
+ addresses: List<Address>
+ addresses: List<Address>
+ useTLS: boolean
+ useTLS: boolean
+ credentials: Credentials
+ credentials: Credentials
+ requestTimeout: long
+ requestTimeout: long
+ readFrom: ReadFromType
+ readFrom: ReadFromType
1
1
Extend all of
Extend all of
RedisClientConfiguration
RedisClientConfiguration
+ databaseId: integer
+ databaseId: integer
+ connectionBackoff: RetryStrategy
+ connectionBackoff: RetryStrategy
RedisClusterClientConfiguration
RedisClusterClientConfiguration
1
1
Extends
Extends
b.a.models.exceptions.RedisException
b.a.models.exceptions.RedisException
+ message: String
+ message: String
+ name: String
+ name: String
b.a.m.exceptions.ClosingException
b.a.m.exceptions.ClosingException
b.a.m.exceptions.RequestException
b.a.m.exceptions.RequestException
b.a.m.exceptions.TimeoutException
b.a.m.exceptions.TimeoutException
b.a.m.exceptions.ExecAbortException
b.a.m.exceptions.ExecAbortException
b.a.m.exceptions.ConnectionException
b.a.m.exceptions.ConnectionException
Command interfaces:
BaseRedisCommands, RedisAclCommands, RedisClusterCommands, RedisFunctionCommands, RedisGeoCommands, RedisHashCommands, RedisHLLCommands, RedisKeyCommands, RedisListCommands, RedisScriptingCommands, RedisServerCommands, RedisSetCommands, RedisSortedSetCommands, RedisStreamCommands, RedisStringCommands, RedisTransactionalCommands
Command interfaces:...
...
...
b.a.models.RedisTransactionResponse
b.a.models.RedisTransactionResponse
+ handleResponse(resp): List<Object>
+ handleResponse(resp): List<Object>
+ handleResp3Response(resp): HashMap
+ handleResp3Response(resp): HashMap
b.a.models.RedisClusterResponse
b.a.models.RedisClusterResponse
+ handleResponse(resp): ClusterValue
+ handleResponse(resp): ClusterValue
+ handleResp3Response(resp): ClusterValue
+ handleResp3Response(resp): ClusterValue
+ handlers: List<Object>
+ handlers: List<Object>
babushka.api.ClusterValue
babushka.api.ClusterValue
+ routedValue: HashMap<Object>
+ routedValue: HashMap<Object>
+ singleValue: Object
+ singleValue: Object

Check is_single_response
Check is_single_response
babushka.api.c.models.Transaction
babushka.api.c.models.Transaction
+ commands: List<Command>
+ commands: List<Command>
- responseHandler: RedisTra..Response
- responseHandler: RedisTra..Response
babushka.api.c.models.Command
babushka.api.c.models.Command
+ requestType: RequestType
+ requestType: RequestType
+ arguments: List<String>
+ arguments: List<String>
- responseHandler: T
- responseHandler: T
Extends
Extends
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/img/design-java-api-high-level.svg b/docs/img/design-java-api-high-level.svg new file mode 100644 index 0000000000..448e4dfd76 --- /dev/null +++ b/docs/img/design-java-api-high-level.svg @@ -0,0 +1,4 @@ + + + +
Client API
Client API

RedisClient.java


+ create(Configuration): RedisClient

+ isConnected(): boolean

RedisClient.java...
babushka.api
babushka.api

RedisClientConfiguration.java



RedisClientConfiguration.java...
Controllers
Controllers
babushka.managers
babushka.managers

ConnectionManager.java


+ connectToRedis

+ closeRedisConnection

ConnectionManager.java...

CommandManager.java


+ createRedisRequest

CommandManager.java...

CallbackManager.java


+ requestId: AtomicInteger

+ connectionRequest: Deque

+ callbacks: Map<Int, CompFuture>

+ registerConnection(CompFuture)

+ registerRequest(CompFuture)


CallbackManager.java...
Models
Models
babushka.connections.models.protobuf
babushka.connections.models.protobuf
protobuf generated content
protobuf generated content

ConnectionRequest.java



ConnectionRequest.java...

RedisRequest.java



RedisRequest.java...

Response.java



Response.java...
BaseRedisResponse
BaseRedisResponse
babushka.api.models
babushka.api.models
ClosingError
ClosingError
RequestError
RequestError
TimeoutError
TimeoutError
ExecAbortError
ExecAbortError
ConnectionError
ConnectionError
Connections
Connections
babushka.connectors
babushka.connectors

SocketConnection.java


- getSocket

- isMacOs

- Channel channel

- EventLoopGroup group

- NettySocketManager INSTANCE

+ getInstance(): NettySocketManager

+ write(Msg)

+ writeAndFlush(Msg)

+ close()

class ShutdownHook


SocketConnection.java...
babushka.connection.handlers
babushka.connection.handlers

ChannelHandler.java


+ initChannel

ChannelHandler.java...

ChannelInboundHandler.java


+ channelRead

ChannelInboundHandler.java...

ChannelOutboundHandler.java


+ write

ChannelOutboundHandler.java...

ChannelInitializer.java


+ connect()

+ close()

ChannelInitializer.java...
babushka.ffi.resolvers
babushka.ffi.resolvers

SocketListenerResolver.java


+ startSocketListenerExternal

SocketListenerResolver.java...
Socket
Socket
FFI.Resolvers
FFI.Resolvers

RedisValueResolver.java


+ valueFromPointer(Long ptr): Obj

RedisValueResolver.java...

LoggingResolver.java


+ log(String): Obj

+ init(): Obj

LoggingResolver.java...
lib.rs
lib.rs
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/img/design-java-api-sequence-datatypes.svg b/docs/img/design-java-api-sequence-datatypes.svg new file mode 100644 index 0000000000..94bdeee069 --- /dev/null +++ b/docs/img/design-java-api-sequence-datatypes.svg @@ -0,0 +1,4 @@ + + + +
:RedisBaseCommand
:RedisBaseCommand
:RedisClientCommands
:RedisClientCommands
exec(args)
exec(args)
exec(RequestType, args)
exec(RequestType, args)
submitNewCommand
(Command, <T>)
submitNewCommand...
:CommandManager
:CommandManager
:CompletableFuture
:CompletableFuture
getResponseHandler(<T>)
getResponseHandler(<T>)
<T>
<T>
supplyAsync()
supplyAsync()
CompletableFuture<BaseRedisResponse<T>>
CompletableFuture<BaseRedisResponse<T>>
get()
get()
:BaseRedisResponse
:BaseRedisResponse
(T)(value_from_pointer)
(T)(value_from_...
<T>
<T>
protobuf.RedisRequest
protobuf.RedisRequest
protobuf.RedisResponse
protobuf.RedisResponse
handleResponse
(protobuf.RedisResponse)
handleResponse...
Socket Read/Write
Socket Read/Write
Text is not SVG - cannot display
\ No newline at end of file