Note: This article is an AI-generated write-up based on the captions and transcript of the video above. Watch the embedded video for the full visual walk-through and nuances.
Implementing Redis GET, SET, and TTL Commands with Expiry Logic
In the journey of building our custom Redis-like server, we previously achieved concurrency, allowing multiple clients to connect simultaneously. Building upon that foundation, this article focuses on implementing three of Redis’s most fundamental and critical commands: GET, SET, and TTL (Time-To-Live), including robust handling of key expiration.
Our approach will involve first observing the behavior of a standard Redis server to understand its responses and edge cases, then diving deep into the Go implementation of these commands, detailing the necessary data structures and logic.
Understanding Redis Command Behavior
Before implementing, it’s crucial to understand how a real Redis server reacts to GET, SET, and TTL commands, especially concerning key expiration and error handling. This observation helps us define the exact behavior our custom server needs to replicate.
The SET Command
The SET command is used to store a key-value pair. Its basic form is SET <key> <value>. For example:
SET K V
OK
GET K
"V"
A critical feature of SET is the ability to set an expiration time using the EX option, specifying the duration in seconds after which the key should be automatically deleted.
SET K V EX 5
OK
GET K
"V" (immediately after setting)
If GET K is executed repeatedly, after approximately 5 seconds, the server will return nil, indicating the key has expired and is no longer available.
Edge Cases for SET:
The GET Command
The GET command retrieves the value associated with a key. As observed, if the key exists and is not expired, it returns the value as a bulk string. However, its behavior changes for non-existent or expired keys.
- Non-existent key: If
GET is called on a key that has never been set, it returns nil.
- Expired key: As demonstrated with
SET ... EX, if GET is called on a key that has expired, it also returns nil. From the client’s perspective, an expired key is functionally equivalent to a non-existent key.
The TTL Command
The TTL command returns the remaining time to live of a key that has an expiration set. The value returned is an integer representing the number of seconds left until the key expires.
SET K V EX 10
OK
TTL K
(integer) 9 (example output, decrements over time)
Key Return Values for TTL:
Redis defines specific integer responses for TTL based on the key’s state:
- Positive Integer (
> 0): The number of seconds remaining until the key expires.
-1: The key exists, but no expiration time has been set for it.
SET K1 V1
OK
TTL K1
(integer) -1
-2: The key does not exist, or it has already expired.
TTL non_existent_key
(integer) -2
SET K V EX 1
OK
(wait 2 seconds)
TTL K
(integer) -2
These observations form the blueprint for our implementation, ensuring our server behaves consistently with Redis.
Core Data Structures: The store.go File
To manage key-value pairs and their associated expiration times, we need a robust internal data structure. This is handled within our store.go file, which acts as the central repository for all data in our Redis implementation.
The Object Struct
Instead of just storing raw values, we introduce a custom Object struct to encapsulate both the value and its expiration metadata. This allows us to associate additional properties with each key-value pair.
// store.go
type Object struct {
Value interface{}
ExpiresAt int64 // Absolute epoch milliseconds, -1 if no expiry
}
Value interface{}: This field holds the actual data associated with the key. Using interface{} provides flexibility, allowing us to store any Go data type (though for now, it will primarily be strings).
ExpiresAt int64: This is crucial for expiration. It stores the absolute Unix epoch time in milliseconds at which the key is scheduled to expire. Storing an absolute time (rather than a duration) simplifies expiration checks, as we only need to compare ExpiresAt with the current time. A special value of -1 indicates that the key has no expiration set.
The Store (Hash Map)
The primary data structure for our key-value store is a simple Go map (hash table), mapping string keys to pointers of our Object struct.
// store.go
var store = make(map[string]*Object)
This store variable is initialized when the store.go file loads and serves as our in-memory database.
Helper Functions
To interact with the store and Object struct, we define a few helper functions:
-
newObject(value interface{}, durationMilliseconds int64) *Object:
This function creates a new Object instance. It takes the value and an expirationDurationMilliseconds. If durationMilliseconds is -1 (no expiry), ExpiresAt is set to -1. Otherwise, it calculates the absolute ExpiresAt by adding the durationMilliseconds to the current Unix epoch time in milliseconds (time.Now().UnixMilli()). This centralizes the logic for calculating absolute expiration times.
-
put(key string, obj *Object):
This function adds or updates an entry in our store. It simply assigns the obj to store[key]. Encapsulating this in a function keeps the store interaction abstract, allowing for future extensions (e.g., multiple hash maps).
-
get(key string) *Object:
This function retrieves an Object from the store given a key. If the key does not exist in the map, it returns nil, which is a desired behavior for non-existent keys.
Implementing Commands in eval.go
The eval.go file is where the core logic for processing client commands resides. We’ll add support for SET, GET, and TTL here, similar to how PING was implemented previously.
Each command function typically takes args (an array of strings representing the command and its parameters) and a writer (to send responses back to the client).
SET Command Implementation
The SET command is responsible for parsing arguments, validating them, creating an Object, and storing it.
Logic Flow:
- Argument Validation: Check if the number of arguments is sufficient (at least key and value). If
len(args) <= 1, return a syntax error.
- Extract Key and Value:
args[0] is the key, args[1] is the value.
- Parse Optional Arguments: Iterate through
args starting from index 2 to check for optional parameters like EX.
- If
EX (case-insensitive) is found:
- Advance the argument index to read the duration value.
- Validation: Check if the duration argument exists and can be parsed as a 64-bit integer. If not, return a syntax error or