Bytes become commands

A command dispatcher, plus PING and ECHO.

  • Part 4
  • intermediate
  • about 30 minutes

You will build

a command dispatcher, plus PING and ECHO

You will understand

why dispatch belongs in its own class, the difference between an error reply and a broken connection, and why error strings are an API

  • roughly 60 lines, one new class
  • Stages 11 to 13

Where we are going

$ redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> echo "hey there"
"hey there"
127.0.0.1:6379> foo
(error) ERR unknown command 'foo'
127.0.0.1:6379> ping
PONG

That last exchange is the interesting one. An error, and then the connection still works.

flowchart LR
    A[Part 3<br/>PONG to everything] --> B[Stages 11 and 12<br/>CommandDispatcher]
    B --> C[Stage 13<br/>ECHO]
    C --> D([Part 5<br/>a real database])

This is a short post, which is the point of it.


Stages 11 and 12, the dispatcher

Goal. The command name decides the reply.

The idea

Your Part 3 server parses RESP correctly and replies +PONG to everything. Send it GET, get PONG. Send it garbage, get PONG. It speaks the protocol and understands nothing.

The obvious fix is a chain of if statements inside the connection loop. Two reasons not to.

Testability. The connection loop needs a socket. A dispatcher takes String[] and returns byte[], with no I/O, so every command becomes a two-line test, exactly like the parser was.

One place that knows the command table. By the end of this series there are 67 commands. If dispatch lives in the connection loop, that method is enormous, and every new command means editing code that also handles sockets, buffers, and disconnects.

flowchart LR
    subgraph handleClient
        direction TB
        H["moves bytes<br/><i>knows nothing about<br/>what commands mean</i>"]
    end
    subgraph CommandDispatcher
        direction TB
        D["knows what commands mean<br/><i>knows nothing about sockets</i>"]
    end
    handleClient -->|"String[]"| CommandDispatcher
    CommandDispatcher -->|"byte[]"| handleClient

The code

src/main/java/com/example/redis/CommandDispatcher.java:

package com.example.redis;

import java.util.Locale;

// Turns a parsed command into a RESP reply. Knows nothing about sockets.
public class CommandDispatcher {

    public static byte[] execute(String[] command) {
        if (command.length == 0) {
            return RespWriter.error("ERR empty command");
        }

        // command names are case-insensitive, arguments never are
        String name = command[0].toUpperCase(Locale.ROOT);

        return switch (name) {
            case "PING" -> ping(command);
            default -> RespWriter.error("ERR unknown command '" + command[0] + "'");
        };
    }

    private static byte[] ping(String[] command) {
        if (command.length == 1) {
            return RespWriter.simpleString("PONG");
        }
        if (command.length == 2) {
            return RespWriter.bulkString(command[1]);
        }
        return RespWriter.error("ERR wrong number of arguments for 'ping' command");
    }
}

Use toUpperCase(Locale.ROOT), not the no-argument version. Part 3’s capture showed redis-cli sending ping in lowercase, so matching must be case-insensitive. The no-argument version uses the default locale, and under a Turkish locale "i" uppercases to "İ", so PING typed lowercase stops matching on a machine in Istanbul. Rare, real, free to avoid.

Arguments are never case-folded. Only the name. It is tempting to write Arrays.stream(command).map(String::toUpperCase), and that silently destroys every key and value in the system.

The switch expression with -> gives exhaustive-looking dispatch with no fallthrough bugs, and grows one line per command.

The connection loop collapses to two lines:

String[] command;
while ((command = parser.next()) != null) {
    outputStream.write(CommandDispatcher.execute(command));
    outputStream.flush();
}

No command names, no protocol knowledge. That is the boundary holding.

Two kinds of failure

An unknown command is not a server fault. The client sent something valid at the protocol level that means nothing at the command level, so it gets an error reply and the connection continues.

A malformed byte stream is different. Nothing after it can be trusted.

flowchart TD
    A[bytes arrive] --> B{valid RESP?}
    B -->|no| C["parser throws<br/>close the connection"]
    B -->|yes| D{known command?}
    D -->|no| E["-ERR unknown command<br/>connection continues"]
    D -->|yes| F{right arity?}
    F -->|no| G["-ERR wrong number of arguments<br/>connection continues"]
    F -->|yes| H[run it]

So handleClient needs a second catch:

} catch (IOException e) {
    System.err.println("Client error: " + e.getMessage());
} catch (IllegalStateException e) {
    // the byte stream is broken, nothing after this can be trusted
    System.err.println("Protocol error, closing connection: " + e.getMessage());
}

Run it

$ mvn test
[INFO] Tests run: 30, Failures: 0, Errors: 0

One session, error then success:

127.0.0.1:6379> foo
(error) ERR unknown command 'foo'
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> ping hello
"hello"
127.0.0.1:6379> ping a b
(error) ERR wrong number of arguments for 'ping' command

Malformed bytes:

$ printf 'garbage\r\n' | nc localhost 6379
Protocol error, closing connection: expected array, got byte 103
Client disconnected

Notice that

Byte 103 is g. The parser rejected the very first byte, the connection closed cleanly, no stack trace, and the server kept listening.

Notice the second ping after the error too. Three separate redis-cli invocations would not have proved that, because each one opens its own connection. The interactive session does.

Try it yourself

  1. Change the catch to IllegalArgumentException and send garbage again. Nothing is caught, and the worker thread dies with a stack trace and no clean close. This was my bug. They are sibling classes, not parent and child, so the catch never matched. Unit tests cannot reach it. Only printf 'garbage' found it.
  2. Add case "TIME" -> RespWriter.integer(System.currentTimeMillis() / 1000);. One line, one new command. That is the return on building a dispatcher.
  3. Uppercase the arguments as well as the name, then run redis-cli ping hello. You get HELLO. Imagine that applied to every key in a database.

What usually goes wrong

Every command returns PONG. The loop still writes a hardcoded reply instead of calling the dispatcher.

A typo in a value kills the connection. An exception is escaping handleClient. Catch it where the failure belongs.

ping works but PING does not, or the reverse. You compared without normalising case.

Error strings are an API

-ERR unknown command 'foo'
-ERR wrong number of arguments for 'get' command

Copy Redis’s wording exactly, including the quotes and the word order. Clients and test suites match on these strings. It feels like pedantry until something greps for wrong number of arguments and finds nothing.


Stage 13, ECHO

Goal. ECHO hello returns hello.

The idea

This stage is five lines, and that is its whole purpose. Adding a command should now touch one class and nothing else.

before      handleClient: read loop, framing, response bytes, command meaning
after       CommandDispatcher: one case, one method

If it takes more than a few minutes, the boundary from the previous stage leaked, and this is the moment to notice rather than at command thirty.

The code

case "ECHO" -> echo(command);
private static byte[] echo(String[] command) {
    if (command.length != 2) {
        return RespWriter.error("ERR wrong number of arguments for 'echo' command");
    }
    return RespWriter.bulkString(command[1]);
}

Always a bulk string here, never a simple string. ECHO is the first command that hands user data straight back, which makes the choice concrete. Encode the argument as +a\r\nbc\r\n and a client reads a as a complete reply, then tries to parse bc\r\n as the start of the next one. Every later reply on that connection is wrong.

Why PING and ECHO differ

They look similar. Both return their argument as a bulk string. They differ with no argument:

PING   →  +PONG\r\n     a status the server invented
ECHO   →  error         there is nothing to echo

PING answers with something the server made up, so a simple string is right. ECHO only ever returns what the client sent.

Run it

$ redis-cli -t 2 echo "hey there"
"hey there"
$ redis-cli -t 2 echo ""
""
$ redis-cli -t 2 echo
(error) ERR wrong number of arguments for 'echo' command

Byte level, to prove the length is a byte count:

$ printf '*2\r\n$4\r\nECHO\r\n$5\r\ncaf\xc3\xa9\r\n' | nc localhost 6379 | xxd
00000000: 2435 0d0a 6361 66c3 a90d 0a    $5..caf....

Notice that

$5 for four characters, because é is two bytes in UTF-8. Your writer already handled this in Part 3. This is the first command where a real client can prove it.

Also echo "" returns "", not (nil). Empty and null are different values, and the tests keep them apart.

Try it yourself

  1. Echo a value containing CRLF and confirm it survives intact.
  2. Change echo to return simpleString(command[1]) and send that same value. Watch redis-cli get confused. Change it back.
  3. Which files did you touch to add ECHO? Which did you not? That is the answer to whether the dispatcher was worth building.

What you built

A dispatcher with two commands, and a shape that will carry sixty-five more without changing.

Your server still has no memory. SET is an unknown command.

Checkpoint

  1. Why is an unknown command an error reply, while malformed RESP closes the connection?
  2. Why uppercase the command name but never the arguments?
  3. Why does PING return a simple string but PING hello a bulk string?
  4. Why can the dispatcher be tested without a socket?
  5. What would the connection loop look like at thirty commands without one?

Resources

Next

The server stops being a protocol demo and becomes a database, which means confronting shared mutable state and time.

Part 5: A Database, and Time →