169 lines
4.3 KiB
Go
169 lines
4.3 KiB
Go
|
|
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
|
||
|
|
}
|