441 lines
11 KiB
Go
441 lines
11 KiB
Go
//go:build integration
|
|
|
|
package redisqueue
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/centrifugal/centrifugo/v6/internal/redisshard"
|
|
|
|
"github.com/redis/rueidis"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var useLegacyReclaim = false
|
|
|
|
func testRedisShard(t *testing.T) *redisshard.RedisShard {
|
|
t.Helper()
|
|
shard, err := redisshard.NewRedisShard(redisshard.RedisShardConfig{
|
|
Address: "localhost:6379",
|
|
})
|
|
require.NoError(t, err)
|
|
return shard
|
|
}
|
|
|
|
func TestNewConsumer(t *testing.T) {
|
|
t.Run("creates a new consumer", func(tt *testing.T) {
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
})
|
|
require.NoError(tt, err)
|
|
assert.NotNil(tt, c)
|
|
})
|
|
}
|
|
|
|
func TestNewConsumerWithOptions(t *testing.T) {
|
|
t.Run("sets defaults for Name, GroupName, BlockingTimeout, and ReclaimTimeout", func(tt *testing.T) {
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
hostname, err := os.Hostname()
|
|
require.NoError(tt, err)
|
|
|
|
assert.Equal(tt, hostname, c.options.Name)
|
|
assert.Equal(tt, tt.Name(), c.options.GroupName)
|
|
assert.Equal(tt, 15*time.Second, c.options.BlockingTimeout)
|
|
assert.Equal(tt, 5*time.Second, c.options.ReclaimInterval)
|
|
})
|
|
|
|
t.Run("allows override of Name, GroupName, BlockingTimeout, ReclaimTimeout, and RedisClient", func(tt *testing.T) {
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: "test_group_name",
|
|
Name: "test_name",
|
|
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
|
|
BlockingTimeout: 10 * time.Second,
|
|
ReclaimInterval: 10 * time.Second,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
assert.Equal(tt, "test_name", c.options.Name)
|
|
assert.Equal(tt, "test_group_name", c.options.GroupName)
|
|
assert.Equal(tt, 10*time.Second, c.options.BlockingTimeout)
|
|
assert.Equal(tt, 10*time.Second, c.options.ReclaimInterval)
|
|
})
|
|
}
|
|
|
|
func TestRun(t *testing.T) {
|
|
t.Run("returns an error if no ConsumerFunc are registered", func(tt *testing.T) {
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
_, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
})
|
|
require.Error(tt, err)
|
|
})
|
|
|
|
t.Run("calls the ConsumerFunc on for a message", func(tt *testing.T) {
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
|
|
done := make(chan struct{})
|
|
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(m *Message) error {
|
|
require.Equal(tt, "value", m.Values["test"])
|
|
close(done)
|
|
return nil
|
|
},
|
|
|
|
VisibilityTimeout: 60 * time.Second,
|
|
BlockingTimeout: 10 * time.Millisecond,
|
|
Concurrency: 10,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create a producer
|
|
p, err := NewProducer(e, ProducerOptions{
|
|
Stream: tt.Name(),
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create consumer group
|
|
c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupDestroy().Key(tt.Name()).Group(c.options.GroupName).Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
|
|
res := c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupCreate().Key(tt.Name()).Group(c.options.GroupName).Id("$").Mkstream().Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
require.NoError(tt, res.Error())
|
|
|
|
// enqueue a message
|
|
err = p.Enqueue(&Message{
|
|
Values: map[string]string{"test": "value"},
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// watch for consumer errors
|
|
go func() {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case err := <-c.Errors:
|
|
require.NoError(tt, err)
|
|
}
|
|
}()
|
|
|
|
// Run the consumer.
|
|
go c.Run()
|
|
|
|
<-done
|
|
c.Shutdown()
|
|
})
|
|
|
|
t.Run("reclaims pending messages according to ReclaimInterval", func(tt *testing.T) {
|
|
// create a consumer
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
|
|
VisibilityTimeout: 5 * time.Millisecond,
|
|
BlockingTimeout: 10 * time.Millisecond,
|
|
ReclaimInterval: 1 * time.Millisecond,
|
|
Concurrency: 10,
|
|
|
|
UseLegacyReclaim: useLegacyReclaim,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create a producer
|
|
p, err := NewProducer(e, ProducerOptions{
|
|
Stream: tt.Name(),
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create consumer group
|
|
c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupDestroy().Key(tt.Name()).Group(c.options.GroupName).Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
|
|
res := c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupCreate().Key(tt.Name()).Group(c.options.GroupName).Id("$").Mkstream().Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
require.NoError(tt, res.Error())
|
|
|
|
// enqueue a message
|
|
msg := &Message{
|
|
Values: map[string]string{"test": "value"},
|
|
}
|
|
err = p.Enqueue(msg)
|
|
require.NoError(tt, err)
|
|
|
|
// register a handler that will assert the message and then shut down
|
|
// the consumer.
|
|
c.options.ConsumerFunc = func(m *Message) error {
|
|
assert.Equal(tt, msg.ID, m.ID)
|
|
c.Shutdown()
|
|
return nil
|
|
}
|
|
|
|
res = c.xReadGroup(tt.Name(), c.options.GroupName, "failed_consumer", 1, 1000)
|
|
err = res.Error()
|
|
require.NoError(tt, err)
|
|
|
|
xRead, err := res.AsXRead()
|
|
require.NoError(tt, err)
|
|
|
|
require.Len(tt, xRead, 1)
|
|
for s, messages := range xRead {
|
|
require.Equal(tt, tt.Name(), s)
|
|
require.Len(tt, messages, 1)
|
|
require.Equal(tt, msg.ID, messages[0].ID)
|
|
}
|
|
|
|
// wait for more than VisibilityTimeout + ReclaimInterval to ensure that
|
|
// the pending message is reclaimed
|
|
time.Sleep(6 * time.Millisecond)
|
|
|
|
// watch for consumer errors
|
|
go func() {
|
|
err := <-c.Errors
|
|
require.NoError(tt, err)
|
|
}()
|
|
|
|
// run the consumer
|
|
c.Run()
|
|
})
|
|
|
|
t.Run("doesn't reclaim if there is no VisibilityTimeout set", func(tt *testing.T) {
|
|
// create a consumer
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
|
|
BlockingTimeout: 10 * time.Millisecond,
|
|
ReclaimInterval: 1 * time.Millisecond,
|
|
Concurrency: 10,
|
|
|
|
UseLegacyReclaim: useLegacyReclaim,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create a producer
|
|
p, err := NewProducer(e, ProducerOptions{
|
|
Stream: tt.Name(),
|
|
StreamMaxLength: 2,
|
|
ApproximateMaxLength: false,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create consumer group
|
|
c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupDestroy().Key(tt.Name()).Group(c.options.GroupName).Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
|
|
res := c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupCreate().Key(tt.Name()).Group(c.options.GroupName).Id("$").Mkstream().Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
require.NoError(tt, res.Error())
|
|
|
|
// enqueue a message
|
|
msg1 := &Message{
|
|
Values: map[string]string{"test": "value"},
|
|
}
|
|
msg2 := &Message{
|
|
Values: map[string]string{"test": "value2"},
|
|
}
|
|
err = p.Enqueue(msg1)
|
|
require.NoError(tt, err)
|
|
|
|
// register a handler that will assert the message and then shut down
|
|
// the consumer
|
|
c.options.ConsumerFunc = func(m *Message) error {
|
|
assert.Equal(tt, msg2.ID, m.ID)
|
|
c.Shutdown()
|
|
return nil
|
|
}
|
|
|
|
res = c.xReadGroup(tt.Name(), c.options.GroupName, "failed_consumer", 1, 1000)
|
|
err = res.Error()
|
|
require.NoError(tt, err)
|
|
|
|
xRead, err := res.AsXRead()
|
|
require.NoError(tt, err)
|
|
|
|
require.Len(tt, xRead, 1)
|
|
for s, messages := range xRead {
|
|
require.Equal(tt, tt.Name(), s)
|
|
require.Len(tt, messages, 1)
|
|
require.Equal(tt, msg1.ID, messages[0].ID)
|
|
}
|
|
|
|
// add another message to the stream to let the consumer consume it
|
|
err = p.Enqueue(msg2)
|
|
require.NoError(tt, err)
|
|
|
|
// watch for consumer errors
|
|
go func() {
|
|
err := <-c.Errors
|
|
require.NoError(tt, err)
|
|
}()
|
|
|
|
// run the consumer
|
|
c.Run()
|
|
|
|
res = xPendingExt(c.shard, tt.Name(), c.options.GroupName, "-", "+", 1)
|
|
err = res.Error()
|
|
require.NoError(tt, err)
|
|
|
|
xPendingCmd := NewXPendingExtCmd(res)
|
|
require.NoError(tt, xPendingCmd.Err)
|
|
require.Len(tt, xPendingCmd.Val, 1)
|
|
require.Equal(tt, msg1.ID, xPendingCmd.Val[0].ID)
|
|
})
|
|
|
|
t.Run("acknowledges pending messages that have already been deleted", func(tt *testing.T) {
|
|
// create a consumer
|
|
e := testRedisShard(t)
|
|
defer e.Close()
|
|
|
|
c, err := NewConsumer(e, ConsumerOptions{
|
|
Stream: tt.Name(),
|
|
GroupName: tt.Name(),
|
|
ConsumerFunc: func(message *Message) error {
|
|
return nil
|
|
},
|
|
|
|
VisibilityTimeout: 5 * time.Millisecond,
|
|
BlockingTimeout: 10 * time.Millisecond,
|
|
ReclaimInterval: 1 * time.Millisecond,
|
|
Concurrency: 10,
|
|
|
|
UseLegacyReclaim: useLegacyReclaim,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create a producer
|
|
p, err := NewProducer(e, ProducerOptions{
|
|
Stream: tt.Name(),
|
|
StreamMaxLength: 1,
|
|
ApproximateMaxLength: false,
|
|
})
|
|
require.NoError(tt, err)
|
|
|
|
// create consumer group
|
|
c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupDestroy().Key(tt.Name()).Group(c.options.GroupName).Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
|
|
res := c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().XgroupCreate().Key(tt.Name()).Group(c.options.GroupName).Id("$").Mkstream().Build()
|
|
return client.Do(context.Background(), cmd)
|
|
})
|
|
require.NoError(tt, res.Error())
|
|
|
|
// enqueue a message
|
|
msg := &Message{
|
|
Values: map[string]string{"test": "value"},
|
|
}
|
|
err = p.Enqueue(msg)
|
|
require.NoError(tt, err)
|
|
|
|
// register a noop handler that should never be called
|
|
c.options.ConsumerFunc = func(m *Message) error {
|
|
t.Fail()
|
|
return nil
|
|
}
|
|
|
|
// read the message but don't acknowledge it
|
|
res = c.xReadGroup(tt.Name(), c.options.GroupName, "failed_consumer", 1, 1000)
|
|
err = res.Error()
|
|
require.NoError(tt, err)
|
|
|
|
xRead, err := res.AsXRead()
|
|
require.NoError(tt, err)
|
|
|
|
require.Len(tt, xRead, 1)
|
|
for s, messages := range xRead {
|
|
require.Equal(tt, tt.Name(), s)
|
|
require.Len(tt, messages, 1)
|
|
require.Equal(tt, msg.ID, messages[0].ID)
|
|
}
|
|
|
|
// delete the message
|
|
err = c.shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
cmd := client.B().Xdel().Key(tt.Name()).Id(msg.ID).Build()
|
|
return client.Do(context.Background(), cmd)
|
|
}).Error()
|
|
require.NoError(tt, err)
|
|
|
|
// watch for consumer errors
|
|
go func() {
|
|
err := <-c.Errors
|
|
require.NoError(tt, err)
|
|
}()
|
|
|
|
// in 10ms, shut down the consumer
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
c.Shutdown()
|
|
}()
|
|
|
|
// run the consumer
|
|
c.Run()
|
|
|
|
res = xPendingExt(c.shard, tt.Name(), c.options.GroupName, "-", "+", 1)
|
|
err = res.Error()
|
|
require.NoError(tt, err)
|
|
|
|
xPendingCmd := NewXPendingExtCmd(res)
|
|
require.NoError(tt, xPendingCmd.Err)
|
|
require.Len(tt, xPendingCmd.Val, 0)
|
|
})
|
|
}
|