Building my own Redis in Go - Part 3

Building my own Redis in Go - Part 3

In the previous blog (part-2), we discussed the implementation of important commands like SET, GET, INCR, RPUSH, and LRANGE. In this blog, we will discuss two very important commands, EXPIRE and TTL. We will also look into how to write back to the client in RESP.

EXPIRE

The EXPIRE command in Redis sets a timeout for a key. If you recall our Keyvalue store struct from the previous blog, it had an Expirations map[string]time.Time map. This is where we store the keys with timeouts. Let's look at the code.

func executeEXPIRE(args []string, kv *KeyValueStore) string {
    if len(args) <= 1 {
        return "(error) ERR wrong number of arguments for 'EXPIRE' command"
    }
    key := args[0]
    expiration, err := strconv.Atoi(args[1])
    if err != nil {
        return "(error) ERR value is not an integer"
    }
    var key_exists bool
    kv.mu.Lock()
    defer kv.mu.Unlock()
    if _, exists := kv.Strings[key]; exists {
        key_exists = true
    } else if _, exists := kv.Lists[key]; exists {
        key_exists = true
    } else if _, exists := kv.Hashes[key]; exists {
        key_exists = true
    }

    if key_exists {
        kv.Expirations[key] = time.Now().Add(time.Duration(expiration) * time.Second)
        return "(integer) 1"
    }

    return "(integer) 0"
}

The function accepts more than 1 argument (the key and the expiration value). It first checks if the timeout is a number and then verifies if the key exists in any of the 3 maps in the key-value store (Strings, Lists, Hashmaps). It then stores the current timestamp plus the expiration value, indicating when the key should expire.

Active/Passive Expiration

Redis expires these keys both actively and passively.

A key undergoes passive expiration when a client tries to access it after it has timed out. So, whenever a command like GET, MGET, INCR, or DECR is executed, we need to check if the key has expired. Let's write a function for this.

func checkExpiredStrings(key string, kv *KeyValueStore) bool {
    if expirationTime, exists := kv.Expirations[key]; exists {
        if time.Now().After(expirationTime) {
            delete(kv.Expirations, key)
            delete(kv.Strings, key)
            return true
        }
    }
    return false
}

This function is now called before executing the commands mentioned above. If this function returns true, it returns (nil).

if checkExpiredStrings(key, kv) {
        return "(nil)"
}

There is another function, checkExpiredLists, used when executing commands that deal with Lists, such as LPUSH, RPUSH, LRANGE, and LPOP.

Regarding active expiration, Redis periodically checks keys to see if they have expired and deletes them if they have. I have not personally implemented this. You can read about Redis's implementation in their docs.

TTL

TTL returns the remaining time to live of a key that has a timeout. The function that executes this command first checks if the key exists in any of the maps (Strings, Lists, Hashmaps) and then checks if there is an expiration. If it exists, it returns the time to live of that key. If key is actually expired, it deletes it from the key-value store. This is also one more way of passive expiration. Here's the snippet where it returns the remaining time if the key exists.

if expirationTime, exists := kv.Expirations[key]; exists {
    if time.Now().Before(expirationTime) {
        ttl := time.Until(expirationTime).Seconds()
        return fmt.Sprintf("(integer) %d", int(ttl))
    delete(kv.Expirations, key)
    delete(kv.Strings, key)
    delete(kv.Lists, key)
    delete(kv.Hashes, key)
    return "(integer) -2"
}

TTL returns -2 if the key does not exist and -1 if the key exists but has no associated expiration.

Writing RESP

We have explored implementing some essential commands in Redis. Now let's see how we can write back to the client using RESP. To keep it simple, godisDB always sends a Bulk String to the client. As you may have noticed, all our commands were also returning strings as responses.

func WriteBulkString(s string, conn io.ReadWriter) error {
    val := []byte(s)
    conn.Write([]byte("$"))
    conn.Write(strconv.AppendUint(nil, uint64(len(val)), 10))
    conn.Write([]byte("\r\n"))
    conn.Write(val)
    _, err := conn.Write([]byte("\r\n"))
    return err
}

This function is quite simple. It starts by appending the $ symbol, which signifies Bulk Strings in RESP. Then, it appends the length of the response followed by CRLF. Next, it adds the actual string followed by another CRLF. Any Redis client will be able to read this as a Bulk String.

Conclusion

We have finished reading input in RESP, executing commands, and writing to the client in RESP. In the next blog, we will look at how to implement persistence. You can check out the code here. Thanks for reading, and happy coding!