blob: c322b8d5c03388ed5617e52594a116f1ae443a22 [file] [edit]
package redis
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type stateUpdateTest struct {
state connectionState
expected connectionState
commandName string
args []interface{}
info map[string]*commandAction
}
func buildStateUpdateTests(tests map[string]stateUpdateTest, root, infos map[string]*commandAction, args ...string) {
for cmd, ci := range infos {
if cmd != strings.ToLower(cmd) {
// Skip non lower case commands as we only need to test one case per action.
continue
}
cmdArgs := append(args, cmd)
if ci.Next != nil {
buildStateUpdateTests(tests, root, ci.Next, cmdArgs...)
continue
}
t := stateUpdateTest{
commandName: cmdArgs[0],
args: make([]interface{}, len(cmdArgs)-1),
info: root,
}
for i, arg := range cmdArgs[1:] {
t.args[i] = arg
}
var state connectionState
ci.Action(&state)
if state != 0 {
// Action is a Set.
t.expected = state
} else {
// Action is a Clear.
state = ^connectionState(0)
ci.Action(&state)
t.state = ^state
}
tests[strings.Join(cmdArgs, "-")] = t
}
}
func TestConnectionStateUpdate(t *testing.T) {
tests := make(map[string]stateUpdateTest)
buildStateUpdateTests(tests, connActions, connActions)
buildStateUpdateTests(tests, connActions, connActions)
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
tc.state.update(tc.info, true, tc.commandName, tc.args...)
require.Equal(t, tc.expected, tc.state)
})
}
}
func benchmarkStateUpdate(b *testing.B, names ...string) {
var state connectionState
for i := 0; i < b.N; i++ {
for _, cmd := range names {
state.update(connActions, true, cmd)
}
}
}
func BenchmarkConnectionStateUpdateCorrectCase(b *testing.B) {
benchmarkStateUpdate(b, "watch", "WATCH", "monitor", "MONITOR")
}
func BenchmarkConnectionStateUpdateMixedCase(b *testing.B) {
benchmarkStateUpdate(b, "wAtch", "WeTCH", "monItor", "MONiTOR")
}
func BenchmarkConnectionStateUpdateNoMatch(b *testing.B) {
benchmarkStateUpdate(b, "GET", "SET", "HMGET", "HMSET")
}
func TestClientReply(t *testing.T) {
p, err := NewPool(
PoolDial(func(options ...DialOption) (*Conn, error) {
return DialDefaultServer(options...)
}),
MaxIdle(1),
MaxActive(2),
)
require.NoError(t, err)
defer p.Close()
closeCheck := func(t *testing.T, c *PoolConn) {
t.Helper()
done := make(chan error)
go func() {
done <- c.Close()
}()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("timeout waiting for connection close")
}
}
var defaultState connectionState
tests := map[string]func(t *testing.T){
"off": func(t *testing.T) {
c := p.Get()
defer closeCheck(t, c)
err := c.Send("CLIENT", "REPLY", "OFF")
require.NoError(t, err)
require.Equal(t, stateClientReplyOff, c.pc.conn.state)
err = c.Send("ECHO", "first")
require.NoError(t, err)
require.Equal(t, stateClientReplyOff, c.pc.conn.state)
_, err = c.Do("CLIENT", "REPLY", "ON")
require.NoError(t, err)
require.Equal(t, defaultState, c.pc.conn.state)
reply, err := String(c.Do("ECHO", "second"))
require.NoError(t, err)
require.Equal(t, "second", reply)
},
"off-skip": func(t *testing.T) {
c := p.Get()
defer closeCheck(t, c)
err := c.Send("CLIENT", "REPLY", "OFF")
require.NoError(t, err)
require.Equal(t, stateClientReplyOff, c.pc.conn.state)
err = c.Send("ECHO", "first")
require.NoError(t, err)
require.Equal(t, stateClientReplyOff, c.pc.conn.state)
// Skip should be ignored as we are already in a off state.
err = c.Send("CLIENT", "REPLY", "SKIP")
require.NoError(t, err)
require.Equal(t, stateClientReplyOff, c.pc.conn.state)
_, err = c.Do("CLIENT", "REPLY", "ON")
require.NoError(t, err)
require.Equal(t, defaultState, c.pc.conn.state)
reply, err := String(c.Do("ECHO", "second"))
require.NoError(t, err)
require.Equal(t, "second", reply)
},
"skip": func(t *testing.T) {
c := p.Get()
defer closeCheck(t, c)
err := c.Send("CLIENT", "REPLY", "SKIP")
require.NoError(t, err)
require.Equal(t, stateClientReplySkipNext, c.pc.conn.state)
err = c.Send("ECHO", "first")
require.NoError(t, err)
require.Equal(t, stateClientReplySkip, c.pc.conn.state)
err = c.Send("ECHO", "second")
require.NoError(t, err)
require.Equal(t, defaultState, c.pc.conn.state)
err = c.Flush()
require.NoError(t, err)
reply, err := String(c.Receive())
require.NoError(t, err)
require.Equal(t, "second", reply)
reply, err = String(c.Do("ECHO", "third"))
require.NoError(t, err)
require.Equal(t, "third", reply)
},
}
for name, tc := range tests {
t.Run(name, tc)
}
}
// setAllFlags sets all flags in the state.
func setAllFlags(state *connectionState, infos map[string]*commandAction) {
for _, ci := range infos {
if ci.Next != nil {
setAllFlags(state, ci.Next)
}
if ci.Action != nil {
var check connectionState
ci.Action(&check)
if check != 0 {
*state |= check
}
}
}
}
// isUnsupported returns true if err is an unknown or disabled command error, false otherwise.
func isUnsupported(err error) bool {
if err != nil {
errStr := strings.ToLower(err.Error())
if strings.HasPrefix(errStr, "err unknown command") ||
strings.HasPrefix(errStr, "err unknown subcommand") ||
strings.HasPrefix(errStr, "err this instance has cluster support disabled") {
return true
}
}
return false
}
// checkSupported checks if a command is supported and if it is, it
// sends it on c otherwise it removes flags from the expected state.
func checkSupported(t *testing.T, p *Pool, c *PoolConn, expected *connectionState, flags connectionState, cmd string, args ...interface{}) {
t.Helper()
c2 := p.Get()
defer c2.Close() //nolint: errcheck
_, err := c2.Do(cmd, args...)
if isUnsupported(err) {
*expected &^= flags
c2.pc.conn.state &^= flags
return
}
require.NoError(t, err)
require.NoError(t, c.Send(cmd, args...))
}
type closeTest struct {
expected connectionState
commandName string
args []interface{}
}
// buildCloseTests builds a map of close tests for the given command infos.
func buildCloseTests(tests map[string]closeTest, root, infos map[string]*commandAction, args ...string) {
for cmd, ci := range infos {
if cmd != strings.ToLower(cmd) {
// Skip non lower case commands as we only need to test one case per action.
continue
}
if cmd == "monitor" {
// MONITOR is tested separately.
continue
}
cmdArgs := append(args, cmd)
if ci.Next != nil {
buildCloseTests(tests, root, ci.Next, cmdArgs...)
continue
}
var state connectionState
ci.Action(&state)
if state == 0 {
// Action is a Clear so not needed.
continue
}
// Action is a Set.
t := closeTest{
commandName: cmdArgs[0],
args: make([]interface{}, len(cmdArgs)-1),
expected: state,
}
for i, arg := range cmdArgs[1:] {
t.args[i] = arg
}
switch t.commandName {
case "watch", "psubscribe", "subscribe", "ssubscribe":
t.args = append(t.args, "key")
}
tests[strings.Join(cmdArgs, "-")] = t
}
}
func TestPoolCloseCleanup(t *testing.T) {
p, err := NewPool(
PoolDial(func(options ...DialOption) (*Conn, error) {
return DialDefaultServer(options...)
}),
)
require.NoError(t, err)
defer p.Close()
var expected connectionState
setAllFlags(&expected, connActions)
for _, val := range []string{"off", "skip"} {
t.Run("all-client-reply-"+val, func(t *testing.T) {
c := p.Get()
// Apply all state changing commands.
checkSupported(t, p, c, &expected, stateClientNoEvict, "CLIENT", "NO-EVICT", "ON")
checkSupported(t, p, c, &expected, stateClientNoTouch, "CLIENT", "NO-TOUCH", "ON")
// CLIENT REPLY OFF and CLIENT REPLY SKIP / SKIP NEXT are mutually exclusive.
switch val {
case "off":
checkSupported(t, p, c, &expected, stateClientReplyOff, "CLIENT", "REPLY", "OFF")
checkSupported(t, p, c, &expected, 0, "CLIENT", "REPLY", "SKIP") // Should be ignored.
// CLIENT REPLY SKIP / SKIP NEXT was never set.
expected &^= stateClientReplySkip | stateClientReplySkipNext
case "skip":
checkSupported(t, p, c, &expected, stateClientReplySkip|stateClientReplySkipNext, "CLIENT", "REPLY", "SKIP")
// CLIENT REPLY SKIP / SKIP NEXT should be unset by the next command and CLIENT REPLY OFF was never set.
expected &^= stateClientReplyOff | stateClientReplySkip | stateClientReplySkipNext
}
checkSupported(t, p, c, &expected, stateClientTracking, "CLIENT", "TRACKING", "ON")
require.Zero(t, c.pc.conn.state&(stateClientReplySkipNext)) // Skip next should have been cleared.
// MONITOR isn't tested as it can't be cleared during close without using RESET
// which isn't possible to use as we can't ensure that the dialled settings such
// as selected database and AUTH are restored.
expected &^= stateMonitor
checkSupported(t, p, c, &expected, stateWatch, "WATCH", "key") // Out of order as WATCH isn't allowed in side a subscriptions.
require.Zero(t, c.pc.conn.state&(stateClientReplySkip)) // Skip should should have cleared.
checkSupported(t, p, c, &expected, stateMulti, "MULTI") // Out of order as MULTI isn't allowed in side a subscriptions.
checkSupported(t, p, c, &expected, statePsubscribe, "PSUBSCRIBE", "x")
checkSupported(t, p, c, &expected, stateReadOnly, "READONLY")
checkSupported(t, p, c, &expected, stateSsubscribe, "SSUBSCRIBE", "x")
checkSupported(t, p, c, &expected, stateSubscribe, "SUBSCRIBE", "x")
require.Equal(t, expected, c.pc.conn.state)
pc := c.pc
require.NoError(t, c.Close())
require.Zero(t, pc.conn.state)
require.Zero(t, pc.conn.pending)
})
}
tests := make(map[string]closeTest)
buildCloseTests(tests, connActions, connActions)
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := p.Get()
_, err := c.Do(tc.commandName, tc.args...)
if isUnsupported(err) {
t.Skipf("Command %q not supported", tc.commandName)
}
require.NoError(t, err)
require.Equal(t, tc.expected, c.pc.conn.state)
pc := c.pc
require.NoError(t, c.Close())
require.Zero(t, pc.conn.state)
require.Zero(t, pc.conn.pending)
})
}
t.Run("monitor", func(t *testing.T) {
c := p.Get()
_, err := c.Do("MONITOR")
require.NoError(t, err)
active := p.active
require.ErrorIs(t, c.Close(), errMonitorEnabled)
require.Less(t, p.active, active)
})
}