feat(queue): add message-plane core logic for send/receive/delete/visibility

Implements SendMessage (size cap, FIFO dedup, shard routing), ReceiveMessage
(long-poll loop across active+closing shards, FIFO per-group exclusivity
gating, redelivery hand-out), DeleteMessage (ack + low-watermark offset
advancement), and ChangeMessageVisibility against the Redis state layer
and a real Kafka producer/consumer.
This commit is contained in:
riotpiaole
2026-06-21 17:08:24 -07:00
parent 3ac4083a68
commit b1f039ae25
7 changed files with 842 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
package queue
import (
"context"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
type ChangeVisibilityService struct {
Redis *goredis.Client
}
// ChangeMessageVisibility implements ChangeMessageVisibility (design.md
// §2b): extends (or shortens) how long a received message stays invisible
// to other consumers, without affecting its receive count.
func (s *ChangeVisibilityService) ChangeMessageVisibility(ctx context.Context, queueName, receiptHandle string, newTimeout time.Duration) error {
ok, err := kmsvcredis.ExtendVisibility(ctx, s.Redis, queueName, receiptHandle, newTimeout)
if err != nil {
return fmt.Errorf("change visibility %s: %w", queueName, err)
}
if !ok {
return fmt.Errorf("receipt handle not found or already expired")
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package queue
import (
"context"
"fmt"
goredis "github.com/redis/go-redis/v9"
"github.com/rockliang/kafka-management-service/internal/kafka"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
// OffsetCommitter is the subset of kafka.Admin DeleteMessage needs to
// advance a (shard, partition)'s committed consumer-group offset, per
// design.md §3's low-watermark strategy.
type OffsetCommitter interface {
CommitOffset(ctx context.Context, group, topic string, partition int32, offset int64) error
}
type DeleteMessageService struct {
Redis *goredis.Client
Committer OffsetCommitter
}
// DeleteMessage acks a received message (design.md §3): the atomic ack.lua
// script removes its in-flight record + vis_index entry, removes its offset
// from the pending set, and releases any FIFO lock it held; this then
// advances the (shard, partition)'s committed watermark if doing so is safe.
// Calling DeleteMessage on an already-acked/DLQ-routed/redelivered receipt
// handle is a no-op, matching SQS's idempotent DeleteMessage semantics.
func (s *DeleteMessageService) DeleteMessage(ctx context.Context, queueName, receiptHandle string) error {
rec, ok, err := kmsvcredis.GetInFlight(ctx, s.Redis, queueName, receiptHandle)
if err != nil {
return fmt.Errorf("delete message %s: %w", queueName, err)
}
if !ok {
return nil
}
outcome, err := kmsvcredis.Ack(ctx, s.Redis, queueName, receiptHandle)
if err != nil {
return fmt.Errorf("delete message %s: %w", queueName, err)
}
if outcome != kmsvcredis.AckOutcomeAcked {
return nil
}
return s.advanceWatermark(ctx, queueName, rec)
}
// advanceWatermark commits the new low-watermark for the acked message's
// (shard, partition) once the pending set has a new minimum. If nothing is
// pending (everything ever consumed on this partition has been acked), the
// last commit is left as-is rather than invented from nothing — at worst
// this means a restart replays slightly further back than strictly
// necessary, which design.md §3 accepts as the at-least-once tradeoff.
func (s *DeleteMessageService) advanceWatermark(ctx context.Context, queueName string, rec kmsvcredis.InFlightRecord) error {
minOffset, ok, err := kmsvcredis.MinPending(ctx, s.Redis, queueName, rec.ShardID, rec.Partition)
if err != nil {
return fmt.Errorf("advance watermark %s: %w", queueName, err)
}
if !ok {
return nil
}
if err := kmsvcredis.SetWatermark(ctx, s.Redis, queueName, rec.ShardID, rec.Partition, minOffset-1); err != nil {
return fmt.Errorf("advance watermark %s: %w", queueName, err)
}
if s.Committer == nil {
return nil
}
if err := s.Committer.CommitOffset(ctx, kafka.ConsumerGroup(queueName), rec.Topic, rec.Partition, minOffset); err != nil {
return fmt.Errorf("advance watermark %s: %w", queueName, err)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package queue
import (
"context"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
// acquireFIFOSlot claims the per-group exclusivity gate (design.md §3) so
// ReceiveMessage never hands out two in-flight messages for the same
// MessageGroupId at once. The lock's TTL matches the message's own
// visibility timeout, so a lock left by a crashed/timed-out consumer
// self-heals on the same schedule the reaper uses (design.md §5) — it is
// also released early, by ack.lua/reap.lua, on ack/redeliver/DLQ-route.
func acquireFIFOSlot(ctx context.Context, rdb *goredis.Client, queueName, groupID, receiptHandle string, visibilityTimeout time.Duration) (bool, error) {
ok, err := kmsvcredis.AcquireFIFOLock(ctx, rdb, queueName, groupID, receiptHandle, visibilityTimeout)
if err != nil {
return false, fmt.Errorf("acquire fifo slot %s/%s: %w", queueName, groupID, err)
}
return ok, nil
}
+306
View File
@@ -0,0 +1,306 @@
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"
"github.com/rockliang/kafka-management-service/internal/kafka"
kmsvcredis "github.com/rockliang/kafka-management-service/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")
}
}
+226
View File
@@ -0,0 +1,226 @@
package queue
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
"github.com/rockliang/kafka-management-service/internal/kafka"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
const defaultPollInterval = 200 * time.Millisecond // design.md §2b
// Fetcher is the subset of kafka.Consumer ReceiveMessage needs: one
// non-blocking poll iteration across whatever shard topics it's currently
// subscribed to (design.md §6 — every Active+Closing shard).
type Fetcher interface {
Poll(ctx context.Context) ([]kafka.Record, error)
}
// Message is one message handed back to a ReceiveMessage caller.
type Message struct {
ReceiptHandle string
Body string
ReceiveCount int32
}
type ReceiveMessageInput struct {
QueueName string
MaxNumberOfMessages int32
WaitTime time.Duration
VisibilityTimeoutOverride time.Duration // 0 = use the queue's configured default
}
type ReceiveMessageService struct {
Redis *goredis.Client
Fetcher Fetcher
Router *ShardRouter
// PollInterval is the short sleep between Kafka+Redis poll iterations
// during a long-poll wait (design.md §2b); defaults to 200ms.
PollInterval time.Duration
}
// ReceiveMessage implements ReceiveMessage's SQS-style long polling
// (design.md §2b): pops anything already queued for redelivery first
// (design.md §4, avoids re-fetching by offset), then polls Kafka for new
// records, looping until max_number_of_messages is satisfied or wait_time
// elapses.
func (s *ReceiveMessageService) ReceiveMessage(ctx context.Context, in ReceiveMessageInput) ([]Message, error) {
meta, ok, err := kmsvcredis.GetQueueMeta(ctx, s.Redis, in.QueueName)
if err != nil {
return nil, fmt.Errorf("receive message %s: %w", in.QueueName, err)
}
if !ok {
return nil, fmt.Errorf("queue %s not found", in.QueueName)
}
visTimeout := in.VisibilityTimeoutOverride
if visTimeout <= 0 {
visTimeout = time.Duration(meta.VisibilityTimeoutSeconds) * time.Second
}
max := in.MaxNumberOfMessages
if max <= 0 || max > 10 {
max = 10
}
interval := s.PollInterval
if interval <= 0 {
interval = defaultPollInterval
}
shards, err := s.Router.ConsumableShards(ctx, in.QueueName)
if err != nil {
return nil, err
}
topicToShardID := make(map[string]string, len(shards))
for _, sh := range shards {
topicToShardID[sh.Topic] = sh.ID
}
deadline := time.Now().Add(in.WaitTime)
out := make([]Message, 0, max)
for {
for int32(len(out)) < max {
msg, handed, err := s.handOutRedeliverable(ctx, meta, in.QueueName, visTimeout)
if err != nil {
return nil, err
}
if !handed {
break
}
out = append(out, msg)
}
if int32(len(out)) < max {
// kgo's PollFetches blocks until either records are available or
// its context is done, so each iteration gets its own
// short-lived context bounded by the poll interval — otherwise a
// quiet topic would block the very first iteration for the
// entire wait_time instead of retrying every interval.
pollCtx, cancel := context.WithTimeout(ctx, interval)
records, err := s.Fetcher.Poll(pollCtx)
cancel()
if err != nil && ctx.Err() != nil {
return nil, ctx.Err()
}
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("receive message %s: %w", in.QueueName, err)
}
for _, rec := range records {
if int32(len(out)) >= max {
break
}
shardID, known := topicToShardID[rec.Topic]
if !known {
continue // topic belongs to a shard that's since closed
}
msg, handed, err := s.handOutFresh(ctx, meta, in.QueueName, shardID, rec, visTimeout)
if err != nil {
return nil, err
}
if handed {
out = append(out, msg)
}
}
}
if len(out) > 0 || time.Now().After(deadline) {
return out, nil
}
select {
case <-ctx.Done():
return out, ctx.Err()
case <-time.After(interval):
}
}
}
func (s *ReceiveMessageService) handOutRedeliverable(ctx context.Context, meta kmsvcredis.QueueMeta, queueName string, visTimeout time.Duration) (Message, bool, error) {
handle, ok, err := kmsvcredis.PopRedeliverable(ctx, s.Redis, queueName)
if err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
if !ok {
return Message{}, false, nil
}
rec, ok, err := kmsvcredis.GetInFlight(ctx, s.Redis, queueName, handle)
if err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
if !ok {
// Acked/DLQ-routed between the push onto the redeliver list and this
// pop — nothing to hand out, caller should keep draining the list.
return Message{}, false, nil
}
if rec.GroupID != "" {
acquired, err := acquireFIFOSlot(ctx, s.Redis, queueName, rec.GroupID, handle, visTimeout)
if err != nil {
return Message{}, false, err
}
if !acquired {
if err := kmsvcredis.PushRedeliverable(ctx, s.Redis, queueName, handle); err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
return Message{}, false, nil
}
}
if _, err := kmsvcredis.ExtendVisibility(ctx, s.Redis, queueName, handle, visTimeout); err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
return Message{ReceiptHandle: handle, Body: rec.Body, ReceiveCount: rec.ReceiveCount}, true, nil
}
func (s *ReceiveMessageService) handOutFresh(ctx context.Context, meta kmsvcredis.QueueMeta, queueName, shardID string, rec kafka.Record, visTimeout time.Duration) (Message, bool, error) {
if err := kmsvcredis.AddPending(ctx, s.Redis, queueName, shardID, rec.Partition, rec.Offset); err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
receiptHandle := newReceiptHandle(shardID, rec.Partition, rec.Offset)
groupID := ""
if meta.FIFO {
groupID = string(rec.Key)
}
if err := kmsvcredis.PutInFlight(ctx, s.Redis, queueName, receiptHandle, kmsvcredis.InFlightRecord{
ShardID: shardID,
Topic: rec.Topic,
Partition: rec.Partition,
Offset: rec.Offset,
GroupID: groupID,
Body: string(rec.Value),
ReceiveCount: 1,
}, visTimeout); err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
if groupID != "" {
acquired, err := acquireFIFOSlot(ctx, s.Redis, queueName, groupID, receiptHandle, visTimeout)
if err != nil {
return Message{}, false, err
}
if !acquired {
// Another message for this group is already checked out: this
// one was already consumed off the partition (so it must be
// tracked, not dropped) but can't be delivered yet — park it on
// the redeliver list instead of handing it out now.
if err := kmsvcredis.PushRedeliverable(ctx, s.Redis, queueName, receiptHandle); err != nil {
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
}
return Message{}, false, nil
}
}
return Message{ReceiptHandle: receiptHandle, Body: string(rec.Value), ReceiveCount: 1}, true, nil
}
func newReceiptHandle(shardID string, partition int32, offset int64) string {
return fmt.Sprintf("%s:%d:%d:%s", shardID, partition, offset, uuid.NewString())
}
+110
View File
@@ -0,0 +1,110 @@
package queue
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
"github.com/rockliang/kafka-management-service/internal/kafka"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
// MaxMessageBodyBytes is the SQS-compatible size cap enforced at
// SendMessage, design.md §6.
const MaxMessageBodyBytes = 256 * 1024
const defaultDedupWindow = 5 * time.Minute
// Producer is the subset of kafka.Producer SendMessage needs, abstracted so
// tests can substitute a fake without a real Kafka broker.
type Producer interface {
Produce(ctx context.Context, topic string, partition int32, key, value []byte) (offset int64, err error)
}
type SendMessageInput struct {
QueueName string
Body string
MessageGroupID string // FIFO only
MessageDeduplicationID string // FIFO only
}
type SendMessageOutput struct {
MessageID string
SequenceNumber int64 // the Kafka offset it landed at
}
type SendMessageService struct {
Redis *goredis.Client
Producer Producer
Router *ShardRouter
DedupWindow time.Duration
}
// SendMessage implements SendMessage (design.md §2b/§2c/§6): enforces the
// size cap, runs the FIFO dedup check, resolves the destination shard via
// the routing key, and produces to that shard's topic at the
// within-shard partition the key hashes to.
func (s *SendMessageService) SendMessage(ctx context.Context, in SendMessageInput) (SendMessageOutput, error) {
if len(in.Body) > MaxMessageBodyBytes {
return SendMessageOutput{}, fmt.Errorf("message body exceeds %d bytes", MaxMessageBodyBytes)
}
meta, ok, err := kmsvcredis.GetQueueMeta(ctx, s.Redis, in.QueueName)
if err != nil {
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
}
if !ok {
return SendMessageOutput{}, fmt.Errorf("queue %s not found", in.QueueName)
}
if meta.FIFO {
if in.MessageGroupID == "" {
return SendMessageOutput{}, fmt.Errorf("messageGroupId is required for FIFO queue %s", in.QueueName)
}
if in.MessageDeduplicationID != "" {
window := s.DedupWindow
if window <= 0 {
window = defaultDedupWindow
}
fresh, err := kmsvcredis.TryDedup(ctx, s.Redis, in.QueueName, in.MessageGroupID, in.MessageDeduplicationID, window)
if err != nil {
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
}
if !fresh {
// SQS returns the original send's message ID for a deduped
// retry; v1 doesn't track that mapping, so the dedup ID
// itself is returned as a stable, idempotent identifier.
return SendMessageOutput{MessageID: in.MessageDeduplicationID}, nil
}
}
}
routingKey := in.MessageGroupID
if routingKey == "" {
routingKey = uuid.NewString()
}
shard, err := s.Router.RouteForSend(ctx, in.QueueName, routingKey)
if err != nil {
return SendMessageOutput{}, err
}
partition := kafka.PartitionWithinShard(routingKey, meta.PartitionsPerShard)
var key []byte
if meta.FIFO {
key = []byte(in.MessageGroupID)
}
offset, err := s.Producer.Produce(ctx, shard.Topic, partition, key, []byte(in.Body))
if err != nil {
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
}
return SendMessageOutput{
MessageID: uuid.NewString(),
SequenceNumber: offset,
}, nil
}
+71
View File
@@ -0,0 +1,71 @@
// Package queue implements the message-plane business logic from
// design.md §2b/§3: SendMessage/ReceiveMessage/DeleteMessage/
// ChangeMessageVisibility, FIFO gating, and shard-aware routing.
package queue
import (
"context"
"fmt"
goredis "github.com/redis/go-redis/v9"
"github.com/rockliang/kafka-management-service/internal/kafka"
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
)
// ShardRouter resolves a routing key to a shard by reading the cached shard
// map the queue-operator writes (design.md §4 `kmsvc:shardmap:`). It is the
// only piece of core logic that needs to know shards exist — everything
// downstream operates against a resolved (shard, topic) pair exactly as it
// would against a single topic.
//
// In-process caching of the shard map (design.md §4's stated optimization)
// is intentionally not implemented in v1: a direct Redis read per call is
// simpler and still cheap relative to a Kafka round trip, and can be added
// later without changing this type's interface.
type ShardRouter struct {
Redis *goredis.Client
}
// RouteForSend returns the Active shard a new message with the given
// routing key (MessageGroupId for FIFO, a random UUID for standard queues)
// should be produced to.
func (r *ShardRouter) RouteForSend(ctx context.Context, queueName, routingKey string) (kafka.Shard, error) {
shards, err := r.shards(ctx, queueName)
if err != nil {
return kafka.Shard{}, err
}
shard, found := kafka.SelectShard(kafka.ActiveShards(shards), routingKey)
if !found {
return kafka.Shard{}, fmt.Errorf("no active shard covers the routing key for queue %s", queueName)
}
return shard, nil
}
// ConsumableShards returns every shard a consumer should be subscribed to:
// Active (write+read) and Closing (read-only, draining) — everything except
// Closed, whose topic has already been deleted.
func (r *ShardRouter) ConsumableShards(ctx context.Context, queueName string) ([]kafka.Shard, error) {
shards, err := r.shards(ctx, queueName)
if err != nil {
return nil, err
}
out := make([]kafka.Shard, 0, len(shards))
for _, s := range shards {
if s.Phase != "Closed" {
out = append(out, s)
}
}
return out, nil
}
func (r *ShardRouter) shards(ctx context.Context, queueName string) ([]kafka.Shard, error) {
shards, ok, err := kmsvcredis.GetShardMap(ctx, r.Redis, queueName)
if err != nil {
return nil, fmt.Errorf("read shard map %s: %w", queueName, err)
}
if !ok || len(shards) == 0 {
return nil, fmt.Errorf("queue %s has no shard map yet (not reconciled)", queueName)
}
return shards, nil
}