Files
kmsvc-manage/internal/redis/reaper.go
T

142 lines
4.5 KiB
Go
Raw Normal View History

package redis
import (
"context"
_ "embed"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
//go:embed lua/reap.lua
var reapScript string
//go:embed lua/ack.lua
var ackScript string
var (
reapSHA = redis.NewScript(reapScript)
ackSHA = redis.NewScript(ackScript)
)
// ReapOutcome is the action taken by reap.lua for one expired in-flight entry.
type ReapOutcome string
const (
ReapOutcomeGone ReapOutcome = "gone"
ReapOutcomeDLQ ReapOutcome = "dlq"
ReapOutcomeRedeliver ReapOutcome = "redeliver"
)
// ReapResult carries enough of the in-flight record back to the caller to
// act on it (produce to DLQ topic, or nothing further for a redeliver).
type ReapResult struct {
Outcome ReapOutcome
Topic string
ShardID string
Partition int32
Offset int64
Body string
GroupID string
DedupID string
ReceiveCount int32
}
// Reap runs the atomic check-and-act script for one expired vis_index entry
// (design.md §5). Safe to call concurrently from multiple reaper replicas —
// only the first caller for a given receiptHandle gets a non-"gone" outcome.
func Reap(ctx context.Context, rdb *redis.Client, queue, receiptHandle string, maxReceiveCount int32) (ReapResult, error) {
keys := []string{InFlightKey(queue, receiptHandle), VisIndexKey(queue)}
res, err := reapSHA.Run(ctx, rdb, keys, receiptHandle, maxReceiveCount, queue).StringSlice()
if err != nil {
return ReapResult{}, fmt.Errorf("reap %s: %w", receiptHandle, err)
}
if len(res) == 0 || res[0] == string(ReapOutcomeGone) {
return ReapResult{Outcome: ReapOutcomeGone}, nil
}
switch ReapOutcome(res[0]) {
case ReapOutcomeDLQ:
partition, _ := strconv.ParseInt(res[3], 10, 32)
offset, _ := strconv.ParseInt(res[4], 10, 64)
return ReapResult{
Outcome: ReapOutcomeDLQ,
Topic: res[1],
ShardID: res[2],
Partition: int32(partition),
Offset: offset,
Body: res[5],
GroupID: res[6],
DedupID: res[7],
}, nil
case ReapOutcomeRedeliver:
receiveCount, _ := strconv.ParseInt(res[2], 10, 32)
return ReapResult{Outcome: ReapOutcomeRedeliver, ReceiveCount: int32(receiveCount)}, nil
default:
return ReapResult{}, fmt.Errorf("reap %s: unexpected outcome %q", receiptHandle, res[0])
}
}
// AckOutcome is the result of running ack.lua.
type AckOutcome string
const (
AckOutcomeAcked AckOutcome = "acked"
AckOutcomeNotFound AckOutcome = "not_found"
)
// Ack runs the atomic ack script for DeleteMessage (design.md §3).
func Ack(ctx context.Context, rdb *redis.Client, queue, receiptHandle string) (AckOutcome, error) {
keys := []string{InFlightKey(queue, receiptHandle), VisIndexKey(queue)}
res, err := ackSHA.Run(ctx, rdb, keys, receiptHandle, queue).StringSlice()
if err != nil {
return "", fmt.Errorf("ack %s: %w", receiptHandle, err)
}
if len(res) == 0 {
return AckOutcomeNotFound, nil
}
return AckOutcome(res[0]), nil
}
// ExpiredVisIndexEntries returns up to limit receiptHandles whose visibility
// has already passed (design.md §5's sweep: ZRANGEBYSCORE ... 0 now LIMIT 0
// limit), oldest-expiry first.
func ExpiredVisIndexEntries(ctx context.Context, rdb *redis.Client, queue string, limit int64) ([]string, error) {
handles, err := rdb.ZRangeByScore(ctx, VisIndexKey(queue), &redis.ZRangeBy{
Min: "0",
Max: strconv.FormatInt(time.Now().UnixMilli(), 10),
Count: limit,
}).Result()
if err != nil {
return nil, fmt.Errorf("expired vis index %s: %w", queue, err)
}
return handles, nil
}
// PopRedeliverable pops the next receiptHandle queued for redelivery
// (design.md §4's `kmsvc:redeliver:` row), or ok=false if none is pending.
func PopRedeliverable(ctx context.Context, rdb *redis.Client, queue string) (receiptHandle string, ok bool, err error) {
v, err := rdb.LPop(ctx, RedeliverKey(queue)).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("pop redeliverable %s: %w", queue, err)
}
return v, true, nil
}
// PushRedeliverable re-queues a receiptHandle for redelivery. Used by
// ReceiveMessage when a popped (or freshly fetched) message can't be handed
// out yet because its FIFO group still has another message in flight — it
// goes back on the list instead of into vis_index, so a later poll retries
// it without re-fetching from Kafka.
func PushRedeliverable(ctx context.Context, rdb *redis.Client, queue, receiptHandle string) error {
if err := rdb.RPush(ctx, RedeliverKey(queue), receiptHandle).Err(); err != nil {
return fmt.Errorf("push redeliverable %s: %w", queue, err)
}
return nil
}