211 lines
5.9 KiB
Go
211 lines
5.9 KiB
Go
//go:build integration
|
||
|
||
package consuming
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/nats-io/nats.go"
|
||
"github.com/prometheus/client_golang/prometheus"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestNatsJetStreamConsumer(t *testing.T) {
|
||
t.Parallel()
|
||
testCases := []struct {
|
||
name string
|
||
useExistingConsumer bool
|
||
}{
|
||
{name: "basic", useExistingConsumer: false},
|
||
{name: "use_existing_consumer", useExistingConsumer: true},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
testNatsJetStreamConsumer(t, tc.useExistingConsumer)
|
||
})
|
||
}
|
||
}
|
||
|
||
func testNatsJetStreamConsumer(t *testing.T, useExistingConsumer bool) {
|
||
url := "nats://localhost:4222"
|
||
subject := "test.subject." + uuid.NewString()
|
||
durableConsumerName := "test-durable-" + uuid.NewString()
|
||
|
||
nc, err := nats.Connect(url)
|
||
require.NoError(t, err)
|
||
defer nc.Close()
|
||
|
||
js, err := nc.JetStream()
|
||
require.NoError(t, err)
|
||
|
||
streamName := "TEST_STREAM_" + uuid.NewString()
|
||
_, err = js.AddStream(&nats.StreamConfig{
|
||
Name: streamName,
|
||
Subjects: []string{subject},
|
||
Storage: nats.MemoryStorage,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// If useExistingConsumer is true, create the consumer manually first
|
||
if useExistingConsumer {
|
||
_, err = js.AddConsumer(streamName, &nats.ConsumerConfig{
|
||
Durable: durableConsumerName,
|
||
Name: durableConsumerName,
|
||
})
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
var receivedNum atomic.Int64
|
||
|
||
createConsumer := func(name string, done chan struct{}) *NatsJetStreamConsumer {
|
||
dispatcher := &MockDispatcher{
|
||
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
|
||
receivedNum.Add(int64(1))
|
||
close(done)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
cfg := NatsJetStreamConsumerConfig{
|
||
URL: url,
|
||
StreamName: streamName,
|
||
Subjects: []string{subject},
|
||
DurableConsumerName: durableConsumerName, // shared durable name
|
||
DeliverPolicy: "new",
|
||
MethodHeader: "test-method",
|
||
UseExistingConsumer: useExistingConsumer,
|
||
}
|
||
|
||
consumer, err := NewNatsJetStreamConsumer(cfg, dispatcher, testCommon(prometheus.NewRegistry()))
|
||
require.NoError(t, err)
|
||
return consumer
|
||
}
|
||
|
||
// Create done channels
|
||
done1 := make(chan struct{})
|
||
done2 := make(chan struct{})
|
||
|
||
// Start both consumers
|
||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||
defer cancel1()
|
||
consumer1 := createConsumer("consumer1", done1)
|
||
go func() {
|
||
if err := consumer1.Run(ctx1); err != nil && !errors.Is(err, context.Canceled) {
|
||
t.Errorf("consumer1 error: %v", err)
|
||
}
|
||
}()
|
||
|
||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||
defer cancel2()
|
||
consumer2 := createConsumer("consumer2", done2)
|
||
go func() {
|
||
if err := consumer2.Run(ctx2); err != nil && !errors.Is(err, context.Canceled) {
|
||
t.Errorf("consumer2 error: %v", err)
|
||
}
|
||
}()
|
||
|
||
// Publish a message
|
||
msg := nats.NewMsg(subject)
|
||
msg.Data = []byte("Hello from dual consumers!")
|
||
msg.Header.Set("test-method", "publish")
|
||
_, err = js.PublishMsg(msg)
|
||
require.NoError(t, err)
|
||
|
||
waitAnyCh(t, []chan struct{}{done1, done2}, 5*time.Second, "timeout waiting for message processing")
|
||
time.Sleep(500 * time.Millisecond) // Give a short delay in case both try to ack
|
||
require.Equal(t, int64(1), receivedNum.Load(), "only one consumer should have received the message")
|
||
}
|
||
|
||
// TestNatsJetStreamConsumer_RecreateOnConsumerDeleted verifies that the consumer
|
||
// recovers without a process restart when its JetStream consumer is lost on the
|
||
// server side – ex. the stream is deleted and re-created by an operator, or an
|
||
// in-memory stream is lost after NATS restart.
|
||
func TestNatsJetStreamConsumer_RecreateOnConsumerDeleted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
url := "nats://localhost:4222"
|
||
subject := "test.recreate." + uuid.NewString()
|
||
durableConsumerName := "test-durable-" + uuid.NewString()
|
||
streamName := "TEST_STREAM_" + uuid.NewString()
|
||
|
||
nc, err := nats.Connect(url)
|
||
require.NoError(t, err)
|
||
defer nc.Close()
|
||
|
||
js, err := nc.JetStream()
|
||
require.NoError(t, err)
|
||
|
||
addStream := func() {
|
||
_, err := js.AddStream(&nats.StreamConfig{
|
||
Name: streamName,
|
||
Subjects: []string{subject},
|
||
Storage: nats.MemoryStorage,
|
||
})
|
||
require.NoError(t, err)
|
||
}
|
||
addStream()
|
||
|
||
var receivedNum atomic.Int64
|
||
dispatcher := &MockDispatcher{
|
||
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
|
||
receivedNum.Add(1)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
cfg := NatsJetStreamConsumerConfig{
|
||
URL: url,
|
||
StreamName: streamName,
|
||
Subjects: []string{subject},
|
||
DurableConsumerName: durableConsumerName,
|
||
DeliverPolicy: "new",
|
||
MethodHeader: "test-method",
|
||
}
|
||
|
||
consumer, err := NewNatsJetStreamConsumer(cfg, dispatcher, testCommon(prometheus.NewRegistry()))
|
||
require.NoError(t, err)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
go func() {
|
||
if err := consumer.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||
t.Errorf("consumer error: %v", err)
|
||
}
|
||
}()
|
||
|
||
publishAndWaitReceived := func(stage string) {
|
||
deadline := time.Now().Add(30 * time.Second)
|
||
before := receivedNum.Load()
|
||
for time.Now().Before(deadline) {
|
||
msg := nats.NewMsg(subject)
|
||
msg.Data = []byte("hello")
|
||
msg.Header.Set("test-method", "publish")
|
||
_, _ = js.PublishMsg(msg)
|
||
time.Sleep(300 * time.Millisecond)
|
||
if receivedNum.Load() > before {
|
||
return
|
||
}
|
||
}
|
||
t.Fatalf("timeout waiting for message processing: %s", stage)
|
||
}
|
||
|
||
// Sanity check: consumer works initially.
|
||
publishAndWaitReceived("initial consume")
|
||
|
||
// Delete the stream: the durable consumer is destroyed together with it.
|
||
require.NoError(t, js.DeleteStream(streamName))
|
||
// Give the consumer some time to observe the loss.
|
||
time.Sleep(2 * time.Second)
|
||
|
||
// Re-create the stream (as an operator like NACK would do). The consumer
|
||
// must re-create its durable and resume consuming without a restart.
|
||
addStream()
|
||
publishAndWaitReceived("consume after stream re-creation")
|
||
}
|