The commands that make it usable

CONFIG GET, INFO, DBSIZE, FLUSHALL, command line options, the TTL family, and the rest of the string API.

  • Part 10
  • advanced
  • about 50 minutes

You will build

CONFIG GET, INFO, DBSIZE, FLUSHALL, command line options, the TTL family, and the rest of the string API

You will understand

why a hardcoded port blocks testing, TTL's three-valued reply, and the three commands that exist only because doing them in two steps is a race

  • roughly 250 lines, one new class
  • Stages 24 and 25

Where we are going

Two instances, side by side, each reporting on itself:

$ java -cp target/classes com.example.redis.Main --port 6380 --dir /tmp
$ redis-cli -p 6380 config get dir
1) "dir"
2) "/tmp"
$ redis-cli -p 6380 info | head -3
# Server
redis_version:7.0.0
tcp_port:6380

And the commands a real client reaches for constantly:

$ redis-cli set session abc
$ redis-cli expire session 60
(integer) 1
$ redis-cli ttl session
(integer) 60
$ redis-cli mget session missing
1) "abc"
2) (nil)
flowchart LR
    A[Part 9<br/>every data type] --> B[Stage 24<br/>config and introspection]
    B --> C[Stage 25<br/>TTL family, string API]
    C --> D([Part 11<br/>persistence])

This is the least glamorous post in the series, and skipping it would leave the server unable to run two copies on one machine, which the next two posts both need.


Stage 24, configuration and introspection

Goal. The port stops being a literal, and the server can describe itself.

The idea

Two things stop being hardcoded here: the port, and the assumption that a server has no identity.

Until now 6379 was a literal in main, so you could not run two instances, and could not test the server on a port you picked. Part 12 runs a master and a replica at the same time. Without this stage, that is impossible.

It is also the stage that makes a running server legible. INFO and DBSIZE are how you look inside one instead of guessing.

Argument parsing

redis-server takes --name value pairs, so parsing is a loop in steps of two with defaults seeded first:

package com.example.redis;

import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

// Command line options, in the --name value form redis-server uses.
public class ServerConfig {

    private final Map<String, String> values = new LinkedHashMap<>();

    public ServerConfig(String... args) {
        values.put("port", "6379");
        values.put("dir", ".");
        values.put("dbfilename", "dump.rdb");

        for (int i = 0; i + 1 < args.length; i += 2) {
            if (!args[i].startsWith("--")) {
                throw new IllegalArgumentException("expected an option, got '" + args[i] + "'");
            }
            values.put(args[i].substring(2).toLowerCase(Locale.ROOT), args[i + 1]);
        }
    }

    public int port() {
        return Integer.parseInt(values.get("port"));
    }

    // null for an unknown parameter, which CONFIG GET reports as an empty result
    public String get(String name) {
        return values.get(name.toLowerCase(Locale.ROOT));
    }
}

dir and dbfilename do nothing yet. They are where an RDB file will live in Part 11, and they are seeded now because CONFIG GET dir is the standard way a client discovers them.

Reject an argument that does not start with -- rather than silently skipping it. A typo in a startup flag should fail loudly at boot, not produce a server quietly running on the wrong port.

CONFIG GET’s reply shape

A flat array of alternating names and values, not an array of pairs:

CONFIG GET dir dbfilename
  →  *4  "dir"  "/tmp"  "dbfilename"  "save.rdb"

Unknown parameters are simply absent from the reply. No error, no null placeholder. Asking for one unknown name returns *0, which is why the empty array and the null array had to be different things back in Part 7.

Only GET is implemented. CONFIG SET would need every parameter to have a live effect, and most of them do not exist here.

INFO

One bulk string of key:value lines, grouped under # Section headers. Clients parse it by splitting on newlines, so the format is the API.

// one bulk string of key:value lines, sections separated by # headers
String body = """
        # Server
        redis_version:7.0.0
        tcp_port:%d

        # Replication
        role:master
        connected_slaves:0

        # Keyspace
        db0:keys=%d
        """.formatted(config.port(), store.keys("*").size());

return RespWriter.bulkString(body);

role:master is the line replication tooling looks for. If you build Part 12, this is the first thing that has to change, and a client asking INFO replication is how a replica discovers its master’s state.

COMMAND

redis-cli sends COMMAND DOCS when it connects, to learn about available commands for tab completion. It does not need a real answer, and it must not get an error. An empty array satisfies it:

// redis-cli sends this on connect, an empty array keeps it happy
case "COMMAND" -> RespWriter.array();

Small thing, and exactly the kind of detail that separates “passes my tests” from “works with the real client”. Without it you see a spurious error on every interactive session.

Run it

$ java -cp target/classes com.example.redis.Main --port 6399 --dir /tmp
Listening on port 6399

$ redis-cli -p 6399 set a 1
$ redis-cli -p 6399 set b 2
$ redis-cli -p 6399 config get dir
1) "dir"
2) "/tmp"
$ redis-cli -p 6399 config get dir dbfilename
1) "dir"
2) "/tmp"
3) "dbfilename"
4) "dump.rdb"
$ redis-cli -p 6399 config get nonsense
(empty array)
$ redis-cli -p 6399 dbsize
(integer) 2
$ redis-cli -p 6399 info
# Server
redis_version:7.0.0
tcp_port:6399

# Replication
role:master
connected_slaves:0

# Keyspace
db0:keys=2
$ redis-cli -p 6399 flushall
OK
$ redis-cli -p 6399 dbsize
(integer) 0

Notice that

Running on 6399 is itself the test. It proves the port is no longer hardcoded, and it means your server and a real Redis can run side by side for comparison.

config get nonsense returned an empty array rather than an error, and tcp_port in INFO reports 6399 rather than a hardcoded 6379.

Try it yourself

  1. Start two instances on different ports at once. Set a key in each and confirm they do not see each other.
  2. Run a real redis-server --port 6380 alongside yours and diff the INFO output. Theirs is much longer, and the fields you implemented match.
  3. Pass a bad flag: --port with no value, or port 6399 without the dashes. Read what happens, and decide whether failing at boot is the behaviour you want.

What usually goes wrong

Your test passes against the wrong server. I hit this twice. My first run of this stage used port 6380, which a real redis-server already held, so INFO reported redis_version:8.2.0 while mine says 7.0.0. That version mismatch is what gave it away. Check the port is free before trusting a result.

redis-cli prints an error on connect. COMMAND is missing.


Stage 25, the TTL family and the rest of the string API

Goal. The commands a real client reaches for, on top of what you already have.

The idea

Every one of these is a few lines on machinery already built. Expiry from Part 5, compute() from Part 6, WRONGTYPE from Part 6.

That is the point of the stage. It demonstrates that the concepts were the hard part and the command surface is mostly variations, which is the honest answer to “how much of Redis is left”. Two hundred commands sounds enormous. Most of them are a new name over an existing mechanism.

TTL’s three-valued reply

:-2    the key does not exist
:-1    it exists and has no expiry
:N     seconds (TTL) or milliseconds (PTTL) remaining

Two negative sentinels rather than a null, because the reply type is an integer. A client distinguishing gone from permanent from expiring is exactly what the sentinels encode. Collapsing them would lose real information.

TTL rounds down to seconds… except it does not. This caught me:

$ redis-cli expire k 5
(integer) 1
$ redis-cli ttl k
(integer) 4        ← real redis says 5

Integer division truncating 4999ms. Redis rounds seconds up:

// redis rounds seconds up, so TTL right after EXPIRE 5 says 5 rather than 4
return RespWriter.integer((millis + divisor - 1) / divisor);

My unit tests never caught it, because they used exact multiples. Comparing against the real client did.

EXPIRE keeps the value

SET k v
EXPIRE k 5
GET k     →  "v"      still there, now with five seconds to live

It returns :1 if a deadline was set and :0 if the key did not exist. It goes through computeIfPresent for the same reason everything else does, because checking existence and then writing is a race.

// false when the key doesn't exist. keeps the value, replaces the deadline
public boolean expire(String key, long ttlMillis) {
    long now = clock.getAsLong();
    boolean[] applied = new boolean[1];

    data.computeIfPresent(key, (k, existing) -> {
        if (existing.isExpired(now)) {
            return null;
        }
        applied[0] = true;
        return new Entry(existing.value(), expiryFrom(ttlMillis));
    });
    return applied[0];
}

PEXPIRE is the same command in milliseconds, sharing an implementation with a multiplier, exactly as TTL and PTTL share one with a divisor:

case "TTL" -> ttl(command, 1000);
case "PTTL" -> ttl(command, 1);
case "EXPIRE" -> expire(command, 1000, "expire");
case "PEXPIRE" -> expire(command, 1, "pexpire");

PERSIST is the reverse, returning :1 only if there was a deadline to remove. :0 for a key that was already permanent, and :0 for a missing key. Two different reasons, one reply. Redis’s choice, not an oversight.

The three atomic ones

SETNX, APPEND, and GETDEL exist because doing them in two steps is a race:

SETNX     check-absent then write     → compute(), one atomic step
APPEND    read, concatenate, write    → compute(), or two clients lose a suffix
GETDEL    read then delete            → computeIfPresent returning null

SETNX is the classic distributed lock primitive, and it works only because the check and the write cannot be separated. Written as if (!exists(k)) set(k, v) it is worse than useless. It looks like a lock and grants itself to two holders.

// set only if absent, the atomic version of a check followed by a write
public boolean setIfAbsent(String key, String value) {
    long now = clock.getAsLong();
    boolean[] stored = new boolean[1];

    data.compute(key, (k, existing) -> {
        if (existing != null && !existing.isExpired(now)) {
            return existing;
        }
        stored[0] = true;
        return new Entry(value, NEVER);
    });
    return stored[0];
}

APPEND also preserves the TTL, the same rule as INCR in Part 6. Mutating a value must not extend its life.

MGET never fails

RPUSH list x
MGET list    →  *1  $-1

A wrong-typed key reads as nil rather than raising WRONGTYPE. That is deliberate in Redis, because a bulk read of many keys should not be derailed by one of them being a list. It means MGET has to catch the exception the single-key path throws:

String value;
try {
    value = store.get(command[i]);
} catch (WrongTypeException e) {
    // MGET never fails, a wrong-typed key just reads as nil
    value = null;
}

Worth noting as an exception rather than assuming it generalises. APPEND, STRLEN, and GETDEL all do raise WRONGTYPE.

RPOP and LINDEX

Two list commands left over from Part 6. RPOP is LPOP from the other end, sharing an implementation with a boolean. LINDEX reads one position, with the same negative-index rules as LRANGE, and returns nil when out of range rather than erroring.

Run it

$ redis-cli set k v
$ redis-cli ttl k               # (integer) -1
$ redis-cli expire k 5          # (integer) 1
$ redis-cli ttl k               # (integer) 5
$ redis-cli persist k           # (integer) 1
$ redis-cli ttl k               # (integer) -1
$ redis-cli ttl nothing         # (integer) -2
$ redis-cli pexpire k 500       # (integer) 1
$ redis-cli pttl k              # (integer) 500

$ redis-cli mset a 1 b 2
OK
$ redis-cli mget a b nothing
1) "1"
2) "2"
3) (nil)
$ redis-cli setnx a 99          # (integer) 0
$ redis-cli append a "!"        # (integer) 2
$ redis-cli getdel a            # "1!"
$ redis-cli exists a            # (integer) 0

$ redis-cli rpush l a b c
$ redis-cli rpop l              # "c"
$ redis-cli lindex l -1         # "b"

Notice that

ttl k reported 5 immediately after expire k 5, not 4. That rounding is the difference between matching Redis and almost matching it.

setnx a 99 returned 0 and left the value alone. getdel returned the value and removed the key in one step, so exists is 0 straight after.

Try it yourself

  1. Set a key with EX 10, then run TTL a few times a second apart and watch it count down.
  2. Write SETNX the naive way as if (!exists) set, then hammer it from two threads on the same key and count how many times both got 1.
  3. APPEND to a key that has a TTL, then check TTL again. The deadline survives.

What usually goes wrong

TTL is always one lower than expected. Integer division truncating. Round up.

MGET throws on a list key. The catch is missing.

SETNX occasionally lets two clients win. You checked and wrote in separate operations.

Go deeper: SETNX as a lock, and why it is not enough

SETNX lock <token> is the basis of a distributed lock, and on its own it is broken. If the holder crashes, the lock is never released. Real usage pairs it with an expiry, which is why modern Redis prefers SET key value NX PX 30000 as a single atomic command.

Releasing safely then needs a check that you still own it, comparing the token before deleting, which cannot be done atomically without a script. That is why EVAL exists.


What you built

Seventeen commands, a configurable port, and a server that can describe itself. None of it was hard, which is the observation worth taking away.

Checkpoint

  1. Why does TTL use -2 and -1 rather than a null reply?
  2. Why is CONFIG GET’s reply flat rather than an array of pairs?
  3. Why must SETNX be one atomic operation to be useful as a lock?
  4. Why does MGET swallow WRONGTYPE when STRLEN does not?
  5. PERSIST returns 0 for two different situations. Which, and why is that acceptable?

Resources

Next

Everything you have built lives in memory and dies with the process. Time to write it to disk, in a format the real Redis can read.

Part 11: Surviving a Restart →