Skip to content

RedisConnection.execute() throws UnsupportedOperationException for commands returning integer RESP type (e.g. RedisJSON JSON.DEL) #3370

@jayesh1126

Description

@jayesh1126

Summary

When using RedisConnection.execute(String command, byte[]... args) to call Redis module commands that return a non-bulk-string RESP response (e.g. integer replies), Lettuce's internal ByteArrayOutput throws:

java.lang.UnsupportedOperationException:
io.lettuce.core.output.ByteArrayOutput does not support set(long)

Commands returning bulk strings or status replies (e.g. JSON.GET, JSON.SET) work correctly through the same API. Integer-returning commands fail.


Environment

  • Spring Boot: 4.0 pulled in Spring boot starter data redis dependency
  • Redis Stack Server: latest so 7.x with RedisJSON module
  • Java: 25

Steps to Reproduce

I have a service that wraps RedisJSON commands using StringRedisTemplate and RedisCallback:

@RequiredArgsConstructor
class RedisJsonExecutor {

    private final StringRedisTemplate redisTemplate;

    /**
     * Works for JSON.SET (returns "+OK")
     * and JSON.GET (returns bulk string).
     */
    String executeForString(String command, String... args) {

        return redisTemplate.execute((RedisCallback<String>) connection -> {

            byte[][] rawArgs = new byte[args.length][];

            for (int i = 0; i < args.length; i++) {
                rawArgs[i] =
                        args[i].getBytes(StandardCharsets.UTF_8);
            }

            Object result =
                    connection.execute(command, rawArgs);

            if (result == null) return null;

            if (result instanceof byte[] bytes) {
                return new String(
                        bytes,
                        StandardCharsets.UTF_8
                );
            }

            return result.toString();
        });
    }
}

Using this for JSON.SET and JSON.GET works correctly:

// ✅ Works — JSON.SET returns "+OK"
executor.executeForString(
    "JSON.SET",
    key,
    "$",
    "{\"name\":\"test\"}"
);

// ✅ Works — JSON.GET returns bulk string
String json = executor.executeForString(
    "JSON.GET",
    key,
    "$"
);

Using the same approach for JSON.DEL fails:

// ❌ Fails — JSON.DEL returns integer (:1)
executor.executeForString(
    "JSON.DEL",
    key,
    "$.some.path"
);

Actual Behaviour

Calling:

connection.execute("JSON.DEL", args)

throws:

org.springframework.data.redis.RedisSystemException:
Unknown redis exception

Caused by:
java.lang.UnsupportedOperationException:
io.lettuce.core.output.ByteArrayOutput
does not support set(long)

Stack trace:

org.springframework.data.redis.RedisSystemException:
Unknown redis exception

    at o.s.d.r.FallbackExceptionTranslationStrategy.getFallback(FallbackExceptionTranslationStrategy.java:49)
    at o.s.d.r.connection.lettuce.LettuceConnection.convertLettuceAccessException(LettuceConnection.java:313)
    at o.s.d.r.connection.lettuce.LettuceConnection.await(LettuceConnection.java:1015)
    at o.s.d.r.connection.lettuce.LettuceConnection.execute(LettuceConnection.java:351)
    at o.s.d.r.connection.lettuce.LettuceConnection.execute(LettuceConnection.java:318)

Caused by:
java.lang.UnsupportedOperationException:
io.lettuce.core.output.ByteArrayOutput
does not support set(long)

    at io.lettuce.core.output.CommandOutput.set(CommandOutput.java:112)
    at io.lettuce.core.protocol.RedisStateMachine.safeSet(RedisStateMachine.java:783)
    at io.lettuce.core.protocol.RedisStateMachine.handleInteger(RedisStateMachine.java:434)
    at io.lettuce.core.protocol.RedisStateMachine$State$Type.handle(RedisStateMachine.java:211)
    at io.lettuce.core.protocol.RedisStateMachine.doDecode(RedisStateMachine.java:364)

Possible root Cause

LettuceConnection.execute(String command, byte[]... args) resolves the expected CommandOutput using internal type hints:

@Override
public Object execute(String command, byte[]... args) {
    return execute(command, null, args);
}

public Object execute(String command,
        @Nullable CommandOutput commandOutputTypeHint,
        byte[]... args) {

    CommandOutput expectedOutput =
            commandOutputTypeHint != null
                    ? commandOutputTypeHint
                    : typeHints.getTypeHint(commandType);

    ...
}

For unknown/module commands, TypeHints falls back to ByteArrayOutput:

public CommandOutput getTypeHint(ProtocolKeyword type) {
    return getTypeHint(type, new ByteArrayOutput<>(CODEC));
}

This works for commands returning bulk strings or status replies, but fails for commands returning integer RESP replies.

Examples:

  • JSON.GET → bulk string → works
  • JSON.SET → status reply → works
  • JSON.DEL → integer reply → fails

This is not a Lettuce bug itself. ByteArrayOutput intentionally does not support integer responses, so when Redis returns an integer reply, Lettuce correctly fails with:

UnsupportedOperationException:
ByteArrayOutput does not support set(long)

The main limitation is that Spring Data Redis does not expose a way to specify the expected CommandOutput type through the RedisConnection abstraction.


Suggestion

Possible improvements:

Option 1 — Expose typed execute overload on RedisConnection

LettuceConnection already supports:

execute(String command,
        CommandOutput commandOutputTypeHint,
        byte[]... args)

Exposing this overload through RedisConnection would allow callers to provide the correct output type for module/custom commands.


Option 2 — Expose RedisJSON commands through Spring abstractions

Expose RedisJSON commands (e.g. via RedisJsonCommands) through the Spring Data Redis abstraction layer so module commands can use the correct typed outputs internally instead of relying on generic command execution.

I'd be happy to contribute a PR for this if the team agrees on the approach.


Workaround

I had to bypass connection.execute() entirely and access the native Lettuce connection to use dispatch() with IntegerOutput:

@SuppressWarnings({"unchecked", "deprecation"})
long executeForLong(String command, String... args) {

    return redisTemplate.execute((RedisCallback<Long>) connection -> {

        Object nativeConn =
                connection.getNativeConnection();

        // getNativeConnection() may return either
        // StatefulRedisConnection or RedisAsyncCommandsImpl
        // depending on pooling configuration

        StatefulRedisConnection<byte[], byte[]> stateful =
                switch (nativeConn) {

                    case StatefulRedisConnection<?, ?> s ->
                            (StatefulRedisConnection<byte[], byte[]>) s;

                    case io.lettuce.core.api.async.RedisAsyncCommands<?, ?> async ->
                            (StatefulRedisConnection<byte[], byte[]>)
                                    async.getStatefulConnection();

                    default -> throw new IllegalStateException(
                            "Unsupported native connection type: "
                                    + nativeConn.getClass().getName());
                };

        var commands = stateful.sync();

        CommandArgs<byte[], byte[]> cmdArgs =
                new CommandArgs<>(ByteArrayCodec.INSTANCE);

        for (String arg : args) {
            cmdArgs.add(
                    arg.getBytes(StandardCharsets.UTF_8)
            );
        }

        return commands.dispatch(
                asCommandType(command),
                new IntegerOutput<>(ByteArrayCodec.INSTANCE),
                cmdArgs
        );
    });
}

private ProtocolKeyword asCommandType(String command) {

    return new ProtocolKeyword() {

        @Override
        public byte[] getBytes() {
            return command.getBytes(StandardCharsets.UTF_8);
        }

        @Override
        public String name() {
            return command;
        }
    };
}

This workaround requires:

  • reaching into Lettuce internals via getNativeConnection()
  • handling multiple possible native connection types
  • using deprecated RedisAsyncCommands.getStatefulConnection()
  • constructing a custom ProtocolKeyword for module commands not present in CommandType

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: supersededAn issue that has been superseded by another

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions