Spring Data Redis also wants to provide a consistent exception experience so exception translation is as well part of what’s required when interacting with a Redis driver.

In contrast to direct calls, we also have pipelining and transactions. Both of these defer the actual result until a later synchronization point. What’s happening when calling a Redis command on the Spring Data Redis API is that we call a pipelined or transactional variant of the Redis driver’s command method. These return typically a handle (Response or Future) that needs to be collected and potentially post-processed (conversion, exception translation).

That has lead to code like the following:

    // Jedis
    @Override
    public DataType type(byte[] key) {

        Assert.notNull(key, "Key must not be null!");

        try {
            if (isPipelined()) {
                pipeline(
                        connection.newJedisResult(connection.getRequiredPipeline().type(key), JedisConverters.stringToDataType()));
                return null;
            }
            if (isQueueing()) {
                transaction(connection.newJedisResult(connection.getRequiredTransaction().type(key),
                        JedisConverters.stringToDataType()));
                return null;
            }
            return JedisConverters.toDataType(connection.getJedis().type(key));
        } catch (Exception ex) {
            throw connection.convertJedisAccessException(ex);
        }
    }
    
    @Override
    public Set<byte[]> keys(byte[] pattern) {

        Assert.notNull(pattern, "Pattern must not be null!");

        try {
            if (isPipelined()) {
                pipeline(connection.newJedisResult(connection.getRequiredPipeline().keys(pattern)));
                return null;
            }
            if (isQueueing()) {
                transaction(connection.newJedisResult(connection.getRequiredTransaction().keys(pattern)));
                return null;
            }
            return connection.getJedis().keys(pattern);
        } catch (Exception ex) {
            throw connection.convertJedisAccessException(ex);
        }
    }
    
    // Lettuce
    @Override
    public Long exists(byte[]... keys) {

        Assert.notNull(keys, "Keys must not be null!");
        Assert.noNullElements(keys, "Keys must not contain null elements!");

        try {
            if (isPipelined()) {
                pipeline(connection.newLettuceResult(getAsyncConnection().exists(keys)));
                return null;
            }
            if (isQueueing()) {
                transaction(connection.newLettuceResult(getAsyncConnection().exists(keys)));
                return null;
            }
            return getConnection().exists(keys);
        } catch (Exception ex) {
            throw convertLettuceAccessException(ex);
        }
    }

There are multiple issues with this code:

  1. Repetition of exception translation in each method (try…catch blocks)
  2. Decision of the execution mode in each command (isPipelined, isQueueing)
  3. Multiple method exists (return)
  4. Duplication of pipeline and newLettuceResult/newJedisResult calls
  5. Code is error-prone for subtle bugs. Either the wrong method is called, or the args list can be incomplete or a result conversion might be missing.
  6. Probable even more issues here.

As you see, the current arrangement isn’t ideal. We started investigating on how to improve and came up with various approaches:

    public Long exists(byte[]... keys) {
        Assert.notNull(keys, "Keys must not be null!");
        Assert.noNullElements(keys, "Keys must not contain null elements!");
        
        return doCall(connection -> connection.exists(keys), asyncConnection -> asyncConnection.exists(keys));
    }

Much better as a lot of the duplication is already gone. Still duplications as we operate on different APIs. Luckily, for Lettuce we can simplify the variant to a single method call. We don’t require a distinction of method calls between direct/pipelining/transactional invocations as Lettuce can return a RedisFuture in every case and Spring Data Redis can sort out the synchronization:

return doCall(connection -> connection.exists(keys));

Better. But we can do probably even better which can improve readability. While having two closing parenthesis at the end isn’t a major concern, it’s still something that we can improve on. Also, we end up with a capturing lambda here. So let’s turn this code into a non-capturing Lambda with improved readability.

return connection.invoke().just(RedisKeyAsyncCommands::exists, keys);

We end up with a method reference and pass on the keys argument without capturing it. Conversion of results clearly adds to the complexity. With a proper fluent API declaration, using conversions becomes as simple as using the Java 8 Stream API:

connection.invoke().from(RedisKeyAsyncCommands::type, key).get(LettuceConverters.stringToDataType())

// List example:

return connection.invoke().fromMany(RedisHashAsyncCommands::hmget, key, fields)
                .toList(source -> source.getValueOrElse(null));

What we’ve achieved is reduction of duplications and surface for potential bugs. However, that refactoring requires a bit of infrastructure. The key here is to capture the intent of the method invocation which can be applied in different contexts: Direct, transactional, and pipelining. The method invocation is only complete when we also capture how the result gets consumed – with or without a converter. That led to the introduction of LettuceInvoker (we started with Lettuce first). The invoker defines a series of method overloads and functional interfaces:

<R> R just(ConnectionFunction0<R> function);
<R, T1> R just(ConnectionFunction1<T1, R> function, T1 t1);
…
<R, T1, T2, T3, T4, T5> R just(ConnectionFunction5<T1, T2, T3, T4, T5, R> function, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5);

interface ConnectionFunction0<R> {
    RedisFuture<R> apply(RedisClusterAsyncCommands<byte[], byte[]> connection);
}

interface ConnectionFunction1<T1, R> {
    RedisFuture<R> apply(RedisClusterAsyncCommands<byte[], byte[]> connection, T1 t1);
}

The underlying implementation delegates the actual invocation to a single place that decides over the execution mode (direct/pipelining/transactional) simplifying maintenance in return.
Right now, LettuceInvoker works with capturing lambdas internally, but that’s an implementation detail that is isolated within LettuceInvoker. It doesn’t sprawl across the entire code base. It can be improved without touching all other methods.

Here’s the full code of LettuceInvoker

There’s still a bit of challenge to improve debugging experience with LettuceInvoker. As net result, we introduced about 600 lines of code to remove 2900 lines of code which is a better result than expected.