The commands that make it usable
CONFIG GET, INFO, DBSIZE, FLUSHALL, command line options, the TTL family, and the rest of the string API.
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
- Start two instances on different ports at once. Set a key in each and confirm they do not see each other.
- Run a real
redis-server --port 6380alongside yours and diff theINFOoutput. Theirs is much longer, and the fields you implemented match. - Pass a bad flag:
--portwith no value, orport 6399without 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
- Set a key with
EX 10, then runTTLa few times a second apart and watch it count down. - Write
SETNXthe naive way asif (!exists) set, then hammer it from two threads on the same key and count how many times both got1. APPENDto a key that has a TTL, then checkTTLagain. 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.
- Redis SETNX, including the note recommending
SETwithNX - Distributed locks with Redis
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
- Why does TTL use
-2and-1rather than a null reply? - Why is
CONFIG GET’s reply flat rather than an array of pairs? - Why must
SETNXbe one atomic operation to be useful as a lock? - Why does
MGETswallowWRONGTYPEwhenSTRLENdoes not? PERSISTreturns 0 for two different situations. Which, and why is that acceptable?
Resources
- Redis TTL and EXPIRE
- Redis SETNX
- Redis INFO
- Java text blocks, used for the INFO body
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.