task/queue/queue.go

169 lines
4.3 KiB
Go
Raw Normal View History

package queue
import (
"fmt"
"strings"
"sync/atomic"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/redis"
)
var delayedSequence uint64
const processingPlaceholder = "\x00apigo-task-processing\x00"
func pendingKey(name string) string { return "queue:" + name + ":pending" }
func processingKey(name, consumerID string) string {
return "queue:" + name + ":" + strings.TrimSpace(consumerID) + ":processing"
}
func client() (*redis.Redis, error) {
rd := redis.GetRedis("task", nil)
if rd == nil {
return nil, fmt.Errorf("task redis is unavailable")
}
if rd.Error != nil {
return nil, rd.Error
}
return rd, nil
}
func Add(queueName string, item any) error {
rd, err := client()
if err != nil {
return err
}
r := rd.Do("LPUSH", pendingKey(queueName), item)
return r.Error
}
// AddAfter stores an item in the queue's persistent delayed lane until delay elapses.
func AddAfter(queueName string, item any, delay time.Duration) error {
rd, err := client()
if err != nil {
return err
}
payload, err := cast.ToJSON(item)
if err != nil {
return err
}
id := fmt.Sprintf("%d:%d", time.Now().UnixNano(), atomic.AddUint64(&delayedSequence, 1))
member := id + "\x00" + payload
r := rd.Do("ZADD", delayedKey(queueName), float64(time.Now().Add(delay).UnixMilli()), member)
return r.Error
}
func delayedKey(name string) string { return "queue:" + name + ":delayed" }
// PromoteDelayed moves due items into pending. Queue workers call this before Fetch.
// The current task runtime has one consumer per queue. Pending is written
// before delayed is removed so a crash can only result in a duplicate, never
// a lost task; consumers already have retry/idempotency safeguards.
func PromoteDelayed(queueName string, limit int) error {
if limit < 1 {
limit = 100
}
rd, err := client()
if err != nil {
return err
}
items, err := rd.ZRangeByScore(delayedKey(queueName), "-inf", time.Now().UnixMilli(), 0, limit)
if err != nil {
return err
}
for _, result := range items {
member := result.String()
parts := strings.SplitN(member, "\x00", 2)
if len(parts) != 2 {
_ = rd.ZREM(delayedKey(queueName), member)
continue
}
_ = rd.Do("LPUSH", pendingKey(queueName), parts[1])
_ = rd.ZREM(delayedKey(queueName), member)
}
return rd.Error
}
func Fetch(queueName, consumerID string, n int) ([]redis.Result, error) {
if n < 1 {
return []redis.Result{}, nil
}
rd, err := client()
if err != nil {
return nil, err
}
if err := PromoteDelayed(queueName, n); err != nil {
return nil, err
}
processing := processingKey(queueName, consumerID)
if items := processingItems(rd, processing); len(items) > 0 {
return items, nil
}
for i := 0; i < n; i++ {
if rd.LLEN(pendingKey(queueName)) == 0 {
break
}
if rd.LLEN(processing) == 0 {
// SugarDB requires the LMOVE destination to exist, while standard Redis
// creates it. Pushing and immediately popping a marker initializes an
// empty SugarDB list without retaining application-visible data.
if r := rd.Do("LPUSH", processing, processingPlaceholder); r.Error != nil {
return nil, r.Error
}
if r := rd.Do("LPOP", processing); r.Error != nil {
return nil, r.Error
}
}
result := rd.Do("LMOVE", pendingKey(queueName), processing, "RIGHT", "LEFT")
if result.Error != nil {
return nil, result.Error
}
if strings.TrimSpace(result.String()) == "" {
break
}
}
return processingItems(rd, processing), nil
}
func processingItems(rd *redis.Redis, key string) []redis.Result {
items := rd.LRANGE(key, 0, -1)
filtered := items[:0]
for _, item := range items {
if item.String() != processingPlaceholder {
filtered = append(filtered, item)
}
}
return filtered
}
func Finish(queueName, consumerID string) error {
rd, err := client()
if err != nil {
return err
}
r := rd.Do("DEL", processingKey(queueName, consumerID))
return r.Error
}
func FetchCount(queueName, consumerID string) int {
_ = PromoteDelayed(queueName, 100)
rd, err := client()
if err != nil {
return 0
}
processing := processingKey(queueName, consumerID)
if count := len(processingItems(rd, processing)); count > 0 {
return count
}
return rd.LLEN(pendingKey(queueName))
}
func Decode[T any](result redis.Result) (T, error) {
var item T
if err := cast.UnmarshalJSON(result.Bytes(), &item); err != nil {
return item, fmt.Errorf("decode queue item: %w", err)
}
return item, nil
}