Logo
Published on

Rebuilding Redis in Go, Part 2: Lists, Blocking, and My First Race Condition

Authors
  • avatar
    Name
    Ryan Flores
    Twitter

Section 2 is done. If you read part 1, you'll remember I ended it staring at a pile of spaghetti in main.go and promising myself a refactor. That refactor happened first, and then the section threw lists, blocking operations, and my first real concurrency bug at me. Here's how it went.

Refactoring

I started by creating a Command struct with a callback func. I could've gone with a plain map[string, func], but I wanted more type structure because down the road I might want a description or help text attached to each command, and a struct leaves the door open for that.

While writing the file for the ping command, I had a thought: would this be a good place for the Factory pattern? I sat with it for a minute and then decided against it. It would've been clever, but clever wasn't the goal, a clean, simple refactor was. Keeping the complexity ended up being a bigger win for me at the moment.

The result is a global registry of allowed commands, where each entry is a struct with an Execute func on it. Here's the model:

type Command struct {
	Name    string
	Execute func(connection net.Conn, args []string) error
}

var Ping = Command{Name: "PING", Execute: basic.Ping}
var RPush = Command{Name: "RPUSH", Execute: lists.RPush}
// ... one of these per command

var Registry = map[string]Command{
	"PING":   Ping,
	"GET":    Get,
	"SET":    Set,
	"ECHO":   Echo,
	"RPUSH":  RPush,
	"LRANGE": LRange,
	"LPUSH":  LPush,
	"LLEN":   Llen,
	"LPOP":   LPop,
	"BLPOP":  BLPop,
}

My giant if/else switch statement collapsed into a single line:

err = commands.Registry[args[0]].Execute(c, args[1:])

The first word of the argument list gets matched to a command in the map, and the rest of the arguments get passed along. Pretty clean if you ask me!

One design question I sat with for a bit was how to give every command a reference to the client's connection, so I just put it in as an argument for the commands. Responses go back out through a little helper that wraps the connection and speaks RESP, so command code never touches the wire format directly:

type Connection struct {
	net.Conn
}

func (c Connection) Send(message string) error {
	_, err := fmt.Fprintf(c.Conn, "+%s\r\n", message)
	return err
}

func (c Connection) SendNullArray() error {
	_, err := fmt.Fprint(c.Conn, "*-1\r\n")
	return err
}

func (c Connection) SendArray(messages []string) error {
	if _, err := fmt.Fprintf(c.Conn, "*%d\r\n", len(messages)); err != nil {
		return err
	}
	for _, message := range messages {
		if err := c.SendBulk(message); err != nil {
			return err
		}
	}
	return nil
}

There are a few more of these (bulk strings, errors, array lengths), all following the same pattern.

Lists Were Pretty Easy

With the registry in place, adding commands got fast, pretty much a few minutes each. My rpush implementation actually cleared three lessons in one go! Creating a list and appending every value from the supplied arguments. lpush, lrange, llen, and lpop were all pretty straightforward after that.

The pattern was working. I was feeling good. And then came blpop.... T.T

blpop: My First Concurrency Problem

blpop is the blocking variant of lpop, if the list is empty, the request just sits there until an element becomes available to pop. Which means goroutines, shared state, and all the fun that comes with them.

Sure enough, I ran into a map read/write error. My first real race condition in Go! I knew channels existed, but I decided to explore mutexes first. I wanted to understand the basics before reaching for the abstraction.

So I paused the lessons and went to study. I read up on what mutexes actually are, talked with Copilot a bit about how they're used in Go, and in the process stumbled onto something I'd never heard of until today: sync.Cond. It's a condition variable, you can use it in blpop to wait for a broadcast, then continue when something signals it. I'm still a bit fuzzy on the details, but it's on my list to dig into properly.

Here's the whole thing, race condition protection included:

var lists = make(map[string][]string)
var listsMu sync.Mutex
var listCond = sync.Cond{L: &listsMu}

func BLPop(connection net.Conn, args []string) error {
	err := error(nil)
	key := args[0]
	timeout, _ := strconv.ParseFloat(args[1], 64)

	if timeout > 0 {
		timeout *= 1000
	}

	t := timer.NewTimer(time.Duration(timeout) * time.Millisecond)
	t.Start(func() {
		(&helpers.Connection{Conn: connection}).SendNullArray()
	})

	listsMu.Lock()

	for len(lists[key]) == 0 {
		listCond.Wait()
	}

	poppedValue := lists[key][0]
	lists[key] = lists[key][1:]

	listsMu.Unlock()

	err = (&helpers.Connection{Conn: connection}).SendArray([]string{key, poppedValue})
	return err
}

That for loop around listCond.Wait() is the shape of the whole pattern: hold the mutex, wait to be woken up, re-check the condition before proceeding. And on the push side, every write ends with a broadcast so no waiter sleeps forever:

listsMu.Lock()

lists[key] = append(lists[key], values...)
length := len(lists[key])

listCond.Broadcast()
listsMu.Unlock()

The Timeout

The last lesson of the section was implementing a timeout for blpop, and it took me slightly longer than I expected. I ended up using a timer package with a Start method that takes a func as a callback when the timer expires , so in that callback, I send back a null RESP array.

There are three cases to handle: the timer completes and the null array gets sent, the full timeout elapses, or the timeout is 0, which means wait forever. Getting all three to behave took some fiddling, but it works.

What's Next

Section 2 started as "clean up the code" and ended with me learning Go concurrency for real, mutexes, condition variables, timers, and my first race condition. That's a pretty good trade for one section.

I still want to get properly comfortable with sync.Cond, and the other concurrency patterns, but I have a feeling the coming sections are only going to lean harder into concurrency. Onward to section 3!!