-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Design documentation for adding a raw-FFI thread manager #31
base: main
Are you sure you want to change the base?
Changes from 23 commits
38791cf
29e2244
32b751e
799b248
a86e424
265daa0
8d43095
11ea2c7
d7325a1
fe1690a
744e92f
56ebe90
64e034a
2d688c1
f6b702b
af4e2a4
df00c54
ce3eb6c
28c672b
91490bc
7058b02
f339390
c4a13da
8bb74ba
eece5f9
73ba55b
b88cbbd
30406bf
041bd39
a25467e
0062ed9
ae090e6
a852c04
50bc303
fdd659a
c3b7ae6
d5edd9d
3bd84ba
26bafc1
765d220
969a896
3896446
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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. | ||||||
acarbonetto marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
## 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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reword the first sentense - UDS is a transport, not a base for a client |
||||||
|
||||||
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 | ||||||
jonathanl-bq marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All Redis commands can be presented as a simple string array, so passing protobuf messages from the wrapper to the core adds unnecessary complication when we're talking about a raw FFI solution There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. e.g. we'll have a generic execute_command function in rust that excepts a string array and all FFI functions from the wrapper will call it |
||||||
- 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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
or
or
Also: future or present? (here and below) |
||||||
|
||||||
## 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reference where these errors came from |
||||||
ClosingError: Errors that report that the client has closed and is no longer usable. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
And below |
||||||
|
||||||
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. |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -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 = | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplify class identifiers for better readability
Suggested change
|
||||||||||
babushka.client.api.RedisClientConfiguration.builder() | ||||||||||
.address(babushka.client.api.Addresses.builder() | ||||||||||
.host(host) | ||||||||||
.port(port) | ||||||||||
Comment on lines
+20
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sync with valkey-io#737 everywhere
Suggested change
|
||||||||||
.build()) | ||||||||||
.useTLS(true) | ||||||||||
.build(); | ||||||||||
|
||||||||||
// connect to Redis | ||||||||||
CompletableFuture<babushka.client.api.RedisClient> 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<babushka.client.api.RedisClient> 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(); | ||||||||||
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
} 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<babushka.client.api.RedisClusterClient> 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<babushka.client.api.RedisClient> 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<String> getRequest = redisClient.get("apples"); | ||||||||||
String getValueStr = getRequest.get(); | ||||||||||
``` | ||||||||||
|
||||||||||
### Case 7: Set(key, value) from connected RedisClient | ||||||||||
```java | ||||||||||
CompletableFuture<Void> setRequest = redisClient.set("apples", "oranges"); | ||||||||||
setRequest.get(); // returns null when complete | ||||||||||
|
||||||||||
SetOptions setOptions = SetOptions.builder() | ||||||||||
.returnOldValue(true) // returns a String | ||||||||||
.build(); | ||||||||||
CompletableFuture<String> 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<String> 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<Object> 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<Object> 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"}) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
.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<List<Object>> transactionRequest = redisClient.exec(transaction); | ||||||||||
List<Object> 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<RedisResponse> 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<String> 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<String> 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) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update diagrams |
||||||||||
|
||||||||||
## 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
may be use present instead of future?