mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 14:29:36 +00:00
309 lines
8.1 KiB
Go
309 lines
8.1 KiB
Go
// Package streamqueue is a durable, at-least-once work queue backed by Redis Streams. Handlers MUST be idempotent.
|
|
package streamqueue
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/zerodha/logf"
|
|
)
|
|
|
|
const (
|
|
defaultWorkers = 4
|
|
defaultMaxAttempts = 50
|
|
defaultMaxLen = 100000
|
|
defaultClaimMinIdle = 30 * time.Second
|
|
defaultReclaimEvery = 15 * time.Second
|
|
defaultBlock = 5 * time.Second
|
|
defaultBatch = 16
|
|
defaultAckTimeout = 5 * time.Second
|
|
|
|
deadLetterWarnThresh = 1000
|
|
|
|
payloadField = "payload"
|
|
origIDField = "orig_id"
|
|
)
|
|
|
|
// Handler processes one entry. A non-nil error leaves the entry pending for retry, nil acknowledges it.
|
|
type Handler func(ctx context.Context, payload []byte) error
|
|
|
|
// Opts configures a Queue. Redis, Stream, Group, Handler and Logger are required.
|
|
type Opts struct {
|
|
Redis *redis.Client
|
|
Logger *logf.Logger
|
|
Handler Handler
|
|
Stream string
|
|
Group string
|
|
Consumer string
|
|
Workers int
|
|
MaxAttempts int
|
|
ClaimMinIdle time.Duration
|
|
}
|
|
|
|
// Queue is a single consumer group over one Redis stream, with a dead-letter stream for poison entries.
|
|
type Queue struct {
|
|
rd *redis.Client
|
|
lo *logf.Logger
|
|
handler Handler
|
|
stream string
|
|
deadStream string
|
|
group string
|
|
consumer string
|
|
workers int
|
|
maxAttempts int
|
|
claimMinIdle time.Duration
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
startMu sync.Mutex
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// New creates the consumer group (and stream) if absent and returns a ready Queue. Call Run to start consuming.
|
|
func New(opts Opts) (*Queue, error) {
|
|
if opts.Redis == nil {
|
|
return nil, errors.New("streamqueue: redis client is required")
|
|
}
|
|
if opts.Stream == "" || opts.Group == "" {
|
|
return nil, errors.New("streamqueue: stream and group are required")
|
|
}
|
|
if opts.Handler == nil {
|
|
return nil, errors.New("streamqueue: handler is required")
|
|
}
|
|
if opts.Logger == nil {
|
|
return nil, errors.New("streamqueue: logger is required")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
q := &Queue{
|
|
rd: opts.Redis,
|
|
lo: opts.Logger,
|
|
handler: opts.Handler,
|
|
stream: opts.Stream,
|
|
deadStream: opts.Stream + ":dead",
|
|
group: opts.Group,
|
|
consumer: cmp.Or(opts.Consumer, "consumer"),
|
|
workers: positiveOr(opts.Workers, defaultWorkers),
|
|
maxAttempts: positiveOr(opts.MaxAttempts, defaultMaxAttempts),
|
|
claimMinIdle: positiveOr(opts.ClaimMinIdle, defaultClaimMinIdle),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
if err := q.rd.XGroupCreateMkStream(ctx, q.stream, q.group, "0").Err(); err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
|
|
cancel()
|
|
return nil, fmt.Errorf("streamqueue: creating consumer group: %w", err)
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
// Enqueue appends a payload to the stream. A nil error means the entry is durably stored.
|
|
func (q *Queue) Enqueue(ctx context.Context, payload []byte) error {
|
|
return q.rd.XAdd(ctx, &redis.XAddArgs{
|
|
Stream: q.stream,
|
|
MaxLen: defaultMaxLen,
|
|
Approx: true,
|
|
Values: map[string]any{payloadField: payload},
|
|
}).Err()
|
|
}
|
|
|
|
// Run starts the workers and the reclaimer and blocks until Close is called.
|
|
func (q *Queue) Run() {
|
|
// Serialised with Close's cancel so its wg.Wait cannot see a zero counter while workers are still registering.
|
|
q.startMu.Lock()
|
|
if q.ctx.Err() == nil {
|
|
for i := range q.workers {
|
|
q.wg.Add(1)
|
|
go q.worker(fmt.Sprintf("%s:%d", q.consumer, i))
|
|
}
|
|
q.wg.Add(1)
|
|
go q.reclaimer()
|
|
}
|
|
q.startMu.Unlock()
|
|
q.wg.Wait()
|
|
}
|
|
|
|
// Close stops consuming and waits for in-flight handlers to finish. Un-acked entries stay durable for the next start.
|
|
func (q *Queue) Close() {
|
|
q.startMu.Lock()
|
|
q.cancel()
|
|
q.startMu.Unlock()
|
|
q.wg.Wait()
|
|
}
|
|
|
|
func (q *Queue) worker(consumer string) {
|
|
defer q.wg.Done()
|
|
for {
|
|
if q.ctx.Err() != nil {
|
|
return
|
|
}
|
|
res, err := q.rd.XReadGroup(q.ctx, &redis.XReadGroupArgs{
|
|
Group: q.group,
|
|
Consumer: consumer,
|
|
Streams: []string{q.stream, ">"},
|
|
Count: defaultBatch,
|
|
Block: defaultBlock,
|
|
}).Result()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) || q.ctx.Err() != nil {
|
|
continue
|
|
}
|
|
q.lo.Error("error reading from stream", "stream", q.stream, "error", err)
|
|
q.sleep(time.Second)
|
|
continue
|
|
}
|
|
for _, s := range res {
|
|
for _, msg := range s.Messages {
|
|
if q.ctx.Err() != nil {
|
|
return
|
|
}
|
|
q.process(consumer, msg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (q *Queue) reclaimer() {
|
|
defer q.wg.Done()
|
|
t := time.NewTicker(defaultReclaimEvery)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-q.ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
q.reclaimOnce()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (q *Queue) reclaimOnce() {
|
|
pending, err := q.rd.XPendingExt(q.ctx, &redis.XPendingExtArgs{
|
|
Stream: q.stream,
|
|
Group: q.group,
|
|
Idle: q.claimMinIdle,
|
|
Start: "-",
|
|
End: "+",
|
|
Count: defaultBatch,
|
|
}).Result()
|
|
if err != nil {
|
|
if !errors.Is(err, redis.Nil) && q.ctx.Err() == nil {
|
|
q.lo.Error("error scanning pending stream entries", "stream", q.stream, "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var retry, dead []string
|
|
for _, p := range pending {
|
|
if int(p.RetryCount) >= q.maxAttempts {
|
|
dead = append(dead, p.ID)
|
|
} else {
|
|
retry = append(retry, p.ID)
|
|
}
|
|
}
|
|
|
|
if len(dead) > 0 {
|
|
q.deadLetter(dead)
|
|
}
|
|
if len(retry) == 0 {
|
|
return
|
|
}
|
|
|
|
msgs, err := q.rd.XClaim(q.ctx, &redis.XClaimArgs{
|
|
Stream: q.stream,
|
|
Group: q.group,
|
|
Consumer: q.consumer + ":reclaim",
|
|
MinIdle: q.claimMinIdle,
|
|
Messages: retry,
|
|
}).Result()
|
|
if err != nil {
|
|
if q.ctx.Err() == nil {
|
|
q.lo.Error("error claiming pending stream entries", "stream", q.stream, "error", err)
|
|
}
|
|
return
|
|
}
|
|
for _, msg := range msgs {
|
|
if q.ctx.Err() != nil {
|
|
return
|
|
}
|
|
q.process(q.consumer+":reclaim", msg)
|
|
}
|
|
}
|
|
|
|
func (q *Queue) deadLetter(ids []string) {
|
|
msgs, err := q.rd.XClaim(q.ctx, &redis.XClaimArgs{
|
|
Stream: q.stream,
|
|
Group: q.group,
|
|
Consumer: q.consumer + ":dead",
|
|
MinIdle: q.claimMinIdle,
|
|
Messages: ids,
|
|
}).Result()
|
|
if err != nil {
|
|
if q.ctx.Err() == nil {
|
|
q.lo.Error("error claiming dead stream entries", "stream", q.stream, "error", err)
|
|
}
|
|
return
|
|
}
|
|
for _, msg := range msgs {
|
|
if err := q.rd.XAdd(q.ctx, &redis.XAddArgs{
|
|
Stream: q.deadStream,
|
|
Values: map[string]any{payloadField: msg.Values[payloadField], origIDField: msg.ID},
|
|
}).Err(); err != nil {
|
|
q.lo.Error("error moving entry to dead-letter stream", "stream", q.stream, "dead_stream", q.deadStream, "id", msg.ID, "error", err)
|
|
continue
|
|
}
|
|
if err := q.rd.XAck(q.ctx, q.stream, q.group, msg.ID).Err(); err == nil {
|
|
q.rd.XDel(q.ctx, q.stream, msg.ID)
|
|
}
|
|
q.lo.Warn("stream entry dead-lettered, check and fix the issue", "stream", q.stream, "dead_stream", q.deadStream, "id", msg.ID, "max_attempts", q.maxAttempts)
|
|
}
|
|
deadLetterLen := q.rd.XLen(q.ctx, q.deadStream).Val()
|
|
if deadLetterLen > deadLetterWarnThresh {
|
|
q.lo.Warn("DEAD-LETTER QUEUE OVERFLOW - ENTRIES CONSUMING REDIS MEMORY", "stream", q.stream, "dead_stream", q.deadStream, "dead_letter_count", deadLetterLen, "threshold", deadLetterWarnThresh)
|
|
}
|
|
}
|
|
|
|
func (q *Queue) process(consumer string, msg redis.XMessage) {
|
|
payload, _ := msg.Values[payloadField].(string)
|
|
if err := q.invoke([]byte(payload)); err != nil {
|
|
q.lo.Warn("stream handler failed, entry left pending for retry", "stream", q.stream, "consumer", consumer, "id", msg.ID, "error", err)
|
|
return
|
|
}
|
|
// Ack on a detached context so a handler that finished as shutdown cancelled q.ctx still releases the entry.
|
|
ackCtx, cancel := context.WithTimeout(context.Background(), defaultAckTimeout)
|
|
defer cancel()
|
|
if err := q.rd.XAck(ackCtx, q.stream, q.group, msg.ID).Err(); err != nil {
|
|
q.lo.Error("error acking stream entry", "stream", q.stream, "id", msg.ID, "error", err)
|
|
return
|
|
}
|
|
q.rd.XDel(ackCtx, q.stream, msg.ID)
|
|
}
|
|
|
|
func (q *Queue) invoke(payload []byte) (err error) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
err = fmt.Errorf("streamqueue: handler panic: %v", rec)
|
|
}
|
|
}()
|
|
return q.handler(q.ctx, payload)
|
|
}
|
|
|
|
func (q *Queue) sleep(d time.Duration) {
|
|
select {
|
|
case <-q.ctx.Done():
|
|
case <-time.After(d):
|
|
}
|
|
}
|
|
|
|
func positiveOr[T ~int | ~int64](v, fallback T) T {
|
|
if v <= 0 {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|