Konrad Reiche Photos About Talks

Avoiding Redis race conditions in tests

If you write your Go tests against real dependencies like Redis, you may have encountered a common problem. Tests running in parallel or across multiple packages can inadvertently use the same keys. This often leads to intermittent failures when tests simultaneously read from or write to these shared keys.

One way to avoid this is by ensuring each test uses a unique set of keys. However, tracking and enforcing unique keys can be cumbersome and impractical. In relational database testing, a solution is to have each test write to its own database. While Redis offers logical databases, this option isn’t available when using Redis Clusters.

Fortunately, there’s a simple solution: implement middleware that runs only during tests and transparently rewrites the keys to include a unique prefix derived from the name of the running test using t.Name(). This approach prevents key collisions without requiring significant changes to your tests. Here’s how you can do it with minimal code:

type prefixHook struct {
	prefix string
}

func NewPrefixHook(t *testing.T) *prefixHook {
	return &prefixHook{
		prefix: t.Name(),
	}
}

func (h *prefixHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
	return func(ctx context.Context, cmd redis.Cmder) error {
		h.prefixKey(cmd)
		if err := next(ctx, cmd); err != nil {
			return err
		}
		return nil
	}
}

func (h *prefixHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
	return func(ctx context.Context, cmds []redis.Cmder) error {
		for _, cmd := range cmds {
			h.prefixKey(cmd)
		}
		if err := next(ctx, cmds); err != nil {
			return err
		}
		return nil
	}
}

func (h *prefixHook) DialHook(next redis.DialHook) redis.DialHook {
	return next
}

func (h *prefixHook) prefixKey(cmd redis.Cmder) {
	args := cmd.Args()
	if len(cmd.Args()) >= 2 {
		key, ok := args[1].(string)
		if !ok {
			return
		}
		args[1] = h.prefix + ":" + key
	}
}

This prefix hook modifies the first key argument in each Redis command, effectively preventing any conflicts. You can then apply the hook like so, and you won’t even notice it’s there:

func TestRedis(t *testing.T) {
	client := redis.NewClusterClient(&redis.ClusterOptions{
		Addr: []string{
			"localhost:7000",
		},
	})
	client.AddHook(NewPrefixHook(t))

	// ...
}

However, this solution assumes you don’t use multi-key commands like MGET or other commands where keys can appear in different positions, such as ZUNIONSTORE. This limitation makes the simple hack suitable only for a narrow set of use cases. If you need to handle more complex scenarios, you could extend the approach by issuing the COMMAND or COMMAND GETKEYS commands to correctly identify the keys for any call.

If you prefer a little dependency over a little copying1, you can use the package where I’ve encapsulated this functionality: go-redis-prefix. Enjoy!


  1. Alternatively, as the Go proverb goes: A little copying is better than a little dependency ↩︎