⬆️ bump simpleredis to the pooled build, teach the e2e mock RESP

The pooled simpleredis sends RESP arrays instead of inline commands, so
the e2e redis stand-in answered +OK to the "*2" header and the plugin
read "OK" as a cached verdict, blocking a request that should pass.
The mock now parses RESP arrays, still accepts inline, and handles MGET.

Points at pool-redis-connections until maxlerebourg/simpleredis#8 is
tagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
mhx
2026-09-04 11:07:18 +02:00
co-authored by Claude Opus 5
parent 3ceb9617f2
commit 4e2bfeb142
6 changed files with 353 additions and 138 deletions
+7 -2
View File
@@ -1,8 +1,13 @@
# simpleredis
Minimal go redis with only `get`, `set` and `delete` operation.
Minimal go redis with only `get`, `mget`, `set` and `delete` operation.
It supports password authentication with redis.
With **NO** external dependencies.
Connections are pooled: a command reuses an already authenticated connection when
one is idle, so `AUTH` and `SELECT` are paid once per connection instead of once
per command. A `SimpleRedis` is safe for concurrent use and must not be copied
once initialized.
## Example
```go
import simpleredis "github.com/maxlerebourg/simpleredis"
@@ -11,7 +16,7 @@ var redis simpleredis.SimpleRedis
redis.Init("redis:6379", "", "") // redisHost, redisPass, redisDatabase
err := redis.Set("test", []bytes("whatever"), 60), // Set key "test" with "whatever" for 60 seconds
err := redis.Set("test", []byte("whatever"), 60) // Set key "test" with "whatever" for 60 seconds
if err != nil {
...
}
+277 -121
View File
@@ -1,14 +1,16 @@
// Package simpleredis implements utility routines for interacting.
// It supports currently the following operations: GET, SET, DELETE,
// It supports currently the following operations: GET, MGET, SET, DELETE,
// and support timetoleave for keys.
package simpleredis
import (
"bufio"
"fmt"
"errors"
"io"
"net"
"net/textproto"
"strconv"
"strings"
"sync"
"time"
)
@@ -21,13 +23,30 @@ const (
RedisIssue = "redis:issue?"
)
// A redisCmd is used to communicate with redis at low level using commands.
type redisCmd struct {
Command string
Name string
Data []byte
Duration int64
Error error
const (
maxIdleConns = 8
idleTimeout = 30 * time.Second
dialTimeout = 2 * time.Second
ioTimeout = 1 * time.Second
)
var (
errUnreachable = errors.New(RedisUnreachable)
errMiss = errors.New(RedisMiss)
errTimeout = errors.New(RedisTimeout)
errNoAuth = errors.New(RedisNoAuth)
errIssue = errors.New(RedisIssue)
)
type pooledConn struct {
netConn net.Conn
reader *bufio.Reader
writer *bufio.Writer
lastUsed time.Time
}
func (c *pooledConn) close() {
_ = c.netConn.Close()
}
// A SimpleRedis is used to communicate with redis.
@@ -35,97 +54,9 @@ type SimpleRedis struct {
host string
pass string
database string
}
func genRedisArray(params ...[]byte) []byte {
MSG := ""
for cntr := 0; cntr < len(params); cntr++ {
MSG = strings.Join([]string{MSG, string(params[cntr])}, " ")
}
MSG = strings.Trim(MSG, " ")
MSG = strings.Join([]string{MSG, "\r\n"}, "")
return []byte(MSG)
}
func send(wr *textproto.Writer, method string, data []byte) {
if err := wr.PrintfLine(string(data)); err != nil {
fmt.Printf("redis:%s %s", method, err.Error())
}
}
func (sr *SimpleRedis) waitRedis(reader *textproto.Reader, channel chan redisCmd) {
for {
select {
case <-time.After(time.Second * 1):
channel <- redisCmd{Error: fmt.Errorf(RedisTimeout)}
return
default:
read, _ := reader.ReadLineBytes()
if string(read) != "+OK" {
channel <- redisCmd{Error: fmt.Errorf(RedisNoAuth)}
return
}
}
// breaks out of for
break
}
}
func (sr *SimpleRedis) askRedis(cmd redisCmd, channel chan redisCmd) redisCmd {
dialer := net.Dialer{Timeout: 2 * time.Second}
conn, err := dialer.Dial("tcp", sr.host)
if err != nil {
return redisCmd{Error: fmt.Errorf(RedisUnreachable)}
}
defer func() {
if err := conn.Close(); err != nil {
fmt.Printf("redis:connClose %s", err.Error())
}
}()
writer := textproto.NewWriter(bufio.NewWriter(conn))
reader := textproto.NewReader(bufio.NewReader(conn))
if sr.pass != "" {
data := genRedisArray([]byte("AUTH"), []byte(sr.pass))
send(writer, "auth", data)
sr.waitRedis(reader, channel)
}
if sr.database != "" {
data := genRedisArray([]byte("SELECT"), []byte(sr.database))
send(writer, "select", data)
sr.waitRedis(reader, channel)
}
switch cmd.Command {
case "SET":
data := genRedisArray([]byte("SET"), []byte(cmd.Name), cmd.Data, []byte("EX"), []byte(fmt.Sprintf("%d", cmd.Duration)))
send(writer, "set", data)
case "DEL":
data := genRedisArray([]byte("DEL"), []byte(cmd.Name))
send(writer, "del", data)
case "GET":
data := genRedisArray([]byte("GET"), []byte(cmd.Name))
send(writer, "get", data)
for {
select {
case <-time.After(time.Second * 1):
return redisCmd{Error: fmt.Errorf(RedisTimeout)}
default:
read, _ := reader.ReadLineBytes()
str := string(read)
if strings.Contains(str, "-NOAUTH") {
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
} else if str == "$-1" {
return redisCmd{Error: fmt.Errorf(RedisMiss)}
}
read, _ = reader.ReadLineBytes()
return redisCmd{Data: read}
}
}
}
return redisCmd{Error: fmt.Errorf(RedisIssue)}
mu sync.Mutex
idle []*pooledConn
}
// Init sets the redisHost used to connect to redis.
@@ -137,36 +68,261 @@ func (sr *SimpleRedis) Init(host, pass, database string) {
// Get fetches the value for key name in redis.
func (sr *SimpleRedis) Get(name string) ([]byte, error) {
cmd := redisCmd{
Command: "GET",
Name: name,
values, err := sr.exec([]byte("GET"), []byte(name))
if err != nil {
return nil, err
}
channel := make(chan redisCmd)
resp := sr.askRedis(cmd, channel)
if resp.Error != nil {
return nil, resp.Error
if len(values) != 1 {
return nil, errIssue
}
return resp.Data, nil
return values[0], nil
}
// MGet fetches the values for keys names in redis, nil where a key is missing.
func (sr *SimpleRedis) MGet(names []string) ([][]byte, error) {
if len(names) == 0 {
return nil, nil
}
args := make([][]byte, 0, len(names)+1)
args = append(args, []byte("MGET"))
for _, name := range names {
args = append(args, []byte(name))
}
values, err := sr.exec(args...)
if err != nil {
return nil, err
}
if len(values) != len(names) {
return nil, errIssue
}
return values, nil
}
// Set updates the value for key name in redis with value data for duration.
func (sr *SimpleRedis) Set(name string, data []byte, duration int64) error {
cmd := redisCmd{
Command: "SET",
Name: name,
Data: data,
Duration: duration,
}
sr.askRedis(cmd, nil)
return nil
_, err := sr.exec([]byte("SET"), []byte(name), data, []byte("EX"), []byte(strconv.FormatInt(duration, 10)))
return err
}
// Del removes the key name in redis.
func (sr *SimpleRedis) Del(name string) error {
cmd := redisCmd{
Command: "DEL",
Name: name,
_, err := sr.exec([]byte("DEL"), []byte(name))
return err
}
func (sr *SimpleRedis) exec(args ...[]byte) ([][]byte, error) {
conn, reused, err := sr.borrow()
if err != nil {
return nil, err
}
sr.askRedis(cmd, nil)
return nil
values, reusable, err := sr.do(conn, args)
sr.release(conn, reusable)
if err == nil || reusable || !reused {
return values, err
}
conn, err = sr.dial()
if err != nil {
return nil, err
}
values, reusable, err = sr.do(conn, args)
sr.release(conn, reusable)
return values, err
}
func (sr *SimpleRedis) borrow() (*pooledConn, bool, error) {
var reused *pooledConn
var stale []*pooledConn
now := time.Now()
sr.mu.Lock()
for len(sr.idle) > 0 {
conn := sr.idle[len(sr.idle)-1]
sr.idle = sr.idle[:len(sr.idle)-1]
if now.Sub(conn.lastUsed) < idleTimeout {
reused = conn
break
}
stale = append(stale, conn)
}
sr.mu.Unlock()
for _, conn := range stale {
conn.close()
}
if reused != nil {
return reused, true, nil
}
conn, err := sr.dial()
return conn, false, err
}
func (sr *SimpleRedis) release(conn *pooledConn, reusable bool) {
if !reusable {
conn.close()
return
}
conn.lastUsed = time.Now()
sr.mu.Lock()
if len(sr.idle) >= maxIdleConns {
sr.mu.Unlock()
conn.close()
return
}
sr.idle = append(sr.idle, conn)
sr.mu.Unlock()
}
func (sr *SimpleRedis) dial() (*pooledConn, error) {
dialer := net.Dialer{Timeout: dialTimeout}
netConn, err := dialer.Dial("tcp", sr.host)
if err != nil {
return nil, errUnreachable
}
conn := &pooledConn{
netConn: netConn,
reader: bufio.NewReader(netConn),
writer: bufio.NewWriter(netConn),
}
if sr.pass != "" {
if _, _, err = sr.do(conn, [][]byte{[]byte("AUTH"), []byte(sr.pass)}); err != nil {
conn.close()
return nil, err
}
}
if sr.database != "" {
if _, _, err = sr.do(conn, [][]byte{[]byte("SELECT"), []byte(sr.database)}); err != nil {
conn.close()
return nil, err
}
}
return conn, nil
}
func (sr *SimpleRedis) do(conn *pooledConn, args [][]byte) ([][]byte, bool, error) {
if err := conn.netConn.SetDeadline(time.Now().Add(ioTimeout)); err != nil {
return nil, false, errUnreachable
}
if err := writeCommand(conn.writer, args); err != nil {
return nil, false, ioError(err)
}
values, clean, err := readReply(conn.reader)
if err != nil && !clean {
return nil, false, ioError(err)
}
return values, true, err
}
func writeCommand(writer *bufio.Writer, args [][]byte) error {
if _, err := writer.WriteString("*" + strconv.Itoa(len(args)) + "\r\n"); err != nil {
return err
}
for _, arg := range args {
if _, err := writer.WriteString("$" + strconv.Itoa(len(arg)) + "\r\n"); err != nil {
return err
}
if _, err := writer.Write(arg); err != nil {
return err
}
if _, err := writer.WriteString("\r\n"); err != nil {
return err
}
}
return writer.Flush()
}
func readReply(reader *bufio.Reader) ([][]byte, bool, error) {
line, err := readLine(reader)
if err != nil {
return nil, false, err
}
if len(line) == 0 {
return nil, false, errIssue
}
switch line[0] {
case '+', ':':
return [][]byte{line[1:]}, true, nil
case '-':
return nil, true, replyError(line[1:])
case '$':
data, bulkErr := readBulk(reader, line)
if bulkErr == errMiss {
return nil, true, errMiss
}
if bulkErr != nil {
return nil, false, bulkErr
}
return [][]byte{data}, true, nil
case '*':
count, convErr := strconv.Atoi(string(line[1:]))
if convErr != nil || count < 0 {
return nil, false, errIssue
}
values := make([][]byte, count)
for i := 0; i < count; i++ {
head, headErr := readLine(reader)
if headErr != nil {
return nil, false, headErr
}
data, bulkErr := readBulk(reader, head)
if bulkErr == errMiss {
continue
}
if bulkErr != nil {
return nil, false, bulkErr
}
values[i] = data
}
return values, true, nil
default:
return nil, false, errIssue
}
}
func readBulk(reader *bufio.Reader, head []byte) ([]byte, error) {
if len(head) == 0 || head[0] != '$' {
return nil, errIssue
}
length, err := strconv.Atoi(string(head[1:]))
if err != nil {
return nil, errIssue
}
if length < 0 {
return nil, errMiss
}
data := make([]byte, length+2)
if _, err = io.ReadFull(reader, data); err != nil {
return nil, err
}
return data[:length], nil
}
func readLine(reader *bufio.Reader) ([]byte, error) {
line, err := reader.ReadBytes('\n')
if err != nil {
return nil, err
}
if len(line) < 2 || line[len(line)-2] != '\r' {
return nil, errIssue
}
return line[:len(line)-2], nil
}
func replyError(message []byte) error {
text := string(message)
for _, prefix := range []string{"NOAUTH", "WRONGPASS", "NOPERM", "ERR Client sent AUTH"} {
if strings.HasPrefix(text, prefix) {
return errNoAuth
}
}
return errors.New(text)
}
func ioError(err error) error {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return errTimeout
}
return errUnreachable
}