227 lines
7.1 KiB
Go
227 lines
7.1 KiB
Go
package queue
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
|
|
"forgejo.riotpiao.com/rock/kmsvc-manage/internal/kafka"
|
|
kmsvcredis "forgejo.riotpiao.com/rock/kmsvc-manage/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())
|
|
}
|