package queue import ( "context" "testing" "time" "github.com/alicebob/miniredis/v2" goredis "github.com/redis/go-redis/v9" "github.com/twmb/franz-go/pkg/kfake" "forgejo.riotpiao.com/rock/kmsvc-manage/internal/kafka" kmsvcredis "forgejo.riotpiao.com/rock/kmsvc-manage/internal/redis" ) // newTestKafka starts an in-memory, wire-protocol-compatible fake Kafka // cluster (kfake) so these tests exercise the real franz-go producer/ // consumer/admin code paths without needing Docker/testcontainers — not // available in this environment, same documented tradeoff as the // queue-operator's envtest substitution (internal/operator). func newTestKafka(t *testing.T) []string { t.Helper() cluster, err := kfake.NewCluster(kfake.NumBrokers(1)) if err != nil { t.Fatalf("starting kfake cluster: %v", err) } t.Cleanup(cluster.Close) return cluster.ListenAddrs() } func newTestRedisClient(t *testing.T) *goredis.Client { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatalf("starting miniredis: %v", err) } t.Cleanup(mr.Close) return goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) } // seedQueue creates the queue's shard-0 topic, queue meta, and shard map // directly — standing in for the queue-operator (internal/operator), which // is exercised separately in its own test suite. func seedQueue(t *testing.T, ctx context.Context, brokers []string, rdb *goredis.Client, name string, fifo bool, partitionsPerShard int32) kafka.Shard { t.Helper() admin, err := kafka.NewAdmin(brokers) if err != nil { t.Fatalf("new admin: %v", err) } t.Cleanup(admin.Close) shard := kafka.Shard{ID: "0", Topic: kafka.ShardTopicName(name, fifo, "0"), HashRangeStart: 0, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"} if err := admin.CreateTopic(ctx, shard.Topic, kafka.TopicConfig{PartitionCount: partitionsPerShard, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil { t.Fatalf("create shard topic: %v", err) } if err := kmsvcredis.PutQueueMeta(ctx, rdb, name, kmsvcredis.QueueMeta{ FIFO: fifo, VisibilityTimeoutSeconds: 1, MaxReceiveCount: 3, PartitionsPerShard: partitionsPerShard, RetentionSeconds: 345600, }); err != nil { t.Fatalf("put queue meta: %v", err) } if err := kmsvcredis.PutShardMap(ctx, rdb, name, []kafka.Shard{shard}); err != nil { t.Fatalf("put shard map: %v", err) } return shard } func newTestServices(t *testing.T, brokers []string, rdb *goredis.Client, group string, topics ...string) (*SendMessageService, *ReceiveMessageService, *DeleteMessageService) { t.Helper() producer, err := kafka.NewProducer(brokers) if err != nil { t.Fatalf("new producer: %v", err) } t.Cleanup(producer.Close) consumer, err := kafka.NewConsumer(brokers, group, topics...) if err != nil { t.Fatalf("new consumer: %v", err) } t.Cleanup(consumer.Close) router := &ShardRouter{Redis: rdb} send := &SendMessageService{Redis: rdb, Producer: producer, Router: router} receive := &ReceiveMessageService{Redis: rdb, Fetcher: consumer, Router: router, PollInterval: 20 * time.Millisecond} del := &DeleteMessageService{Redis: rdb} return send, receive, del } func receiveUntil(t *testing.T, ctx context.Context, receive *ReceiveMessageService, queueName string, want int, waitTime time.Duration) []Message { t.Helper() out, err := receive.ReceiveMessage(ctx, ReceiveMessageInput{QueueName: queueName, MaxNumberOfMessages: int32(want), WaitTime: waitTime}) if err != nil { t.Fatalf("receive message: %v", err) } return out } func TestSendReceiveDeleteStandardQueue(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1) send, receive, del := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic) if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: "hello"}); err != nil { t.Fatalf("send message: %v", err) } msgs := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second) if len(msgs) != 1 || msgs[0].Body != "hello" { t.Fatalf("messages = %+v, want one %q", msgs, "hello") } if err := del.DeleteMessage(ctx, "orders", msgs[0].ReceiptHandle); err != nil { t.Fatalf("delete message: %v", err) } // Nothing left to redeliver: a short poll should come back empty. again := receiveUntil(t, ctx, receive, "orders", 1, 100*time.Millisecond) if len(again) != 0 { t.Fatalf("messages after delete = %+v, want none", again) } } func TestReceiveMessageRedeliversAfterVisibilityTimeoutExpires(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1) send, receive, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic) if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: "redeliver-me"}); err != nil { t.Fatalf("send message: %v", err) } first := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second) if len(first) != 1 { t.Fatalf("first receive = %+v, want one message", first) } // Run the reaper directly (its own service is covered in internal/redis // and a separate Task 7 reaper loop — here we just need its effect: an // expired in-flight message becomes redeliverable). if _, err := kmsvcredis.Reap(ctx, rdb, "orders", first[0].ReceiptHandle, 3); err != nil { t.Fatalf("reap: %v", err) } second := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second) if len(second) != 1 || second[0].ReceiveCount != 2 { t.Fatalf("second receive = %+v, want one message with receiveCount=2", second) } if second[0].Body != "redeliver-me" { t.Fatalf("redelivered body = %q, want %q", second[0].Body, "redeliver-me") } } func TestFIFOSecondMessageInGroupNotReceivableUntilFirstAcked(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) shard := seedQueue(t, ctx, brokers, rdb, "orders.fifo", true, 1) send, receive, del := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders.fifo"), shard.Topic) if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "first", MessageGroupID: "g1"}); err != nil { t.Fatalf("send first: %v", err) } if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "second", MessageGroupID: "g1"}); err != nil { t.Fatalf("send second: %v", err) } first := receiveUntil(t, ctx, receive, "orders.fifo", 2, 2*time.Second) if len(first) != 1 || first[0].Body != "first" { t.Fatalf("first receive = %+v, want only %q", first, "first") } // The second message is consumed off the partition but parked (FIFO gate // held) — it must not be receivable yet. blocked := receiveUntil(t, ctx, receive, "orders.fifo", 1, 200*time.Millisecond) if len(blocked) != 0 { t.Fatalf("messages while group locked = %+v, want none", blocked) } if err := del.DeleteMessage(ctx, "orders.fifo", first[0].ReceiptHandle); err != nil { t.Fatalf("delete first: %v", err) } second := receiveUntil(t, ctx, receive, "orders.fifo", 1, 2*time.Second) if len(second) != 1 || second[0].Body != "second" { t.Fatalf("second receive after ack = %+v, want %q", second, "second") } } func TestSendMessageDedupRejectsDuplicateWithinWindow(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) shard := seedQueue(t, ctx, brokers, rdb, "orders.fifo", true, 1) send, _, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders.fifo"), shard.Topic) first, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "v1", MessageGroupID: "g1", MessageDeduplicationID: "d1"}) if err != nil { t.Fatalf("send first: %v", err) } dup, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "v2 (should be ignored)", MessageGroupID: "g1", MessageDeduplicationID: "d1"}) if err != nil { t.Fatalf("send dup: %v", err) } if dup.MessageID != "d1" { t.Errorf("dup message id = %q, want the dedup id %q", dup.MessageID, "d1") } if first.SequenceNumber == dup.SequenceNumber && first.MessageID == dup.MessageID { t.Errorf("expected dup response to be distinguishable from the original send") } } func TestSendMessageRejectsBodyOverSizeCap(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1) send, _, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic) oversized := make([]byte, MaxMessageBodyBytes+1) _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: string(oversized)}) if err == nil { t.Fatal("expected oversized message body to be rejected") } } func TestFIFOGroupStableAcrossShardSplit(t *testing.T) { ctx := context.Background() brokers := newTestKafka(t) rdb := newTestRedisClient(t) admin, err := kafka.NewAdmin(brokers) if err != nil { t.Fatalf("new admin: %v", err) } t.Cleanup(admin.Close) queueName := "orders.fifo" parent := kafka.Shard{ID: "0", Topic: kafka.ShardTopicName(queueName, true, "0"), HashRangeStart: 0, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"} for _, topic := range []string{parent.Topic} { if err := admin.CreateTopic(ctx, topic, kafka.TopicConfig{PartitionCount: 1, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil { t.Fatalf("create topic %s: %v", topic, err) } } if err := kmsvcredis.PutQueueMeta(ctx, rdb, queueName, kmsvcredis.QueueMeta{FIFO: true, VisibilityTimeoutSeconds: 30, MaxReceiveCount: 3, PartitionsPerShard: 1}); err != nil { t.Fatalf("put queue meta: %v", err) } if err := kmsvcredis.PutShardMap(ctx, rdb, queueName, []kafka.Shard{parent}); err != nil { t.Fatalf("put shard map: %v", err) } router := &ShardRouter{Redis: rdb} groupID := "group-A" beforeShard, err := router.RouteForSend(ctx, queueName, groupID) if err != nil { t.Fatalf("route before split: %v", err) } if beforeShard.ID != "0" { t.Fatalf("beforeShard = %+v, want shard-0", beforeShard) } // Simulate the operator splitting shard-0 (internal/operator/shard_split.go). mid := kafka.SplitHashRange(parent.HashRangeStart, parent.HashRangeEnd) childA := kafka.Shard{ID: "1", Topic: kafka.ShardTopicName(queueName, true, "1"), HashRangeStart: 0, HashRangeEnd: mid, Phase: "Active"} childB := kafka.Shard{ID: "2", Topic: kafka.ShardTopicName(queueName, true, "2"), HashRangeStart: mid, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"} parent.Phase = "Closing" for _, topic := range []string{childA.Topic, childB.Topic} { if err := admin.CreateTopic(ctx, topic, kafka.TopicConfig{PartitionCount: 1, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil { t.Fatalf("create topic %s: %v", topic, err) } } if err := kmsvcredis.PutShardMap(ctx, rdb, queueName, []kafka.Shard{parent, childA, childB}); err != nil { t.Fatalf("put post-split shard map: %v", err) } afterShard, err := router.RouteForSend(ctx, queueName, groupID) if err != nil { t.Fatalf("route after split: %v", err) } h := kafka.HashKey(groupID) wantID := "1" if h >= mid { wantID = "2" } if afterShard.ID != wantID { t.Fatalf("afterShard = %+v, want shard %s (hash=%d, mid=%d)", afterShard, wantID, h, mid) } // ReceiveMessage consuming across both the still-draining parent and the // new child must still see every message, regardless of which shard it // landed on. send, receive, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup(queueName), parent.Topic, childA.Topic, childB.Topic) if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: queueName, Body: "post-split", MessageGroupID: groupID}); err != nil { t.Fatalf("send post-split: %v", err) } msgs := receiveUntil(t, ctx, receive, queueName, 1, 2*time.Second) if len(msgs) != 1 || msgs[0].Body != "post-split" { t.Fatalf("messages = %+v, want one %q", msgs, "post-split") } }