142 lines
4.5 KiB
Go
142 lines
4.5 KiB
Go
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
|
||
|
|
}
|