Deployment
Automated deployment update
4b1daed
Raw
History Blame Contribute Delete
9.84 kB
// Package queue provides queue management for the notification service.
package queue
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
notifications "github.com/AmaniQuery/amaniquery/services/notifications"
)
// QueueManager handles notification queue operations
type QueueManager struct {
redis *redis.Client
logger *zap.Logger
}
// NewQueueManager creates a new queue manager
func NewQueueManager(redisClient *redis.Client, logger *zap.Logger) *QueueManager {
return &QueueManager{
redis: redisClient,
logger: logger,
}
}
// Enqueue adds a notification to the appropriate queue based on priority
func (m *QueueManager) Enqueue(ctx context.Context, msg notifications.QueueMessage, channel notifications.NotificationChannel) error {
queueName := m.getQueueName(msg.Request.Priority)
msgJson, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal queue message: %w", err)
}
return m.redis.XAdd(ctx, &redis.XAddArgs{
Stream: queueName,
Values: map[string]interface{}{
"data": string(msgJson),
"channel": string(channel),
},
}).Err()
}
// EnqueueScheduled adds a notification to the scheduled queue for later delivery
func (m *QueueManager) EnqueueScheduled(ctx context.Context, msg notifications.QueueMessage, channel notifications.NotificationChannel, deliverAt time.Time) error {
payload := struct {
Message notifications.QueueMessage `json:"message"`
Channel string `json:"channel"`
}{
Message: msg,
Channel: string(channel),
}
msgJson, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal scheduled message: %w", err)
}
score := float64(deliverAt.Unix())
return m.redis.ZAdd(ctx, notifications.QueueScheduled, redis.Z{
Score: score,
Member: string(msgJson),
}).Err()
}
// ProcessScheduledQueue moves scheduled notifications to active queues when due
func (m *QueueManager) ProcessScheduledQueue(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.processScheduledBatch(ctx)
}
}
}
// processScheduledBatch processes a batch of scheduled notifications
func (m *QueueManager) processScheduledBatch(ctx context.Context) {
now := time.Now().Unix()
// Get all scheduled notifications that are ready
msgs, err := m.redis.ZRangeByScore(ctx, notifications.QueueScheduled, &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now, 10),
Count: 100,
}).Result()
if err != nil {
m.logger.Error("Failed to get scheduled notifications", zap.Error(err))
return
}
if len(msgs) == 0 {
return
}
m.logger.Info("Processing scheduled notifications", zap.Int("count", len(msgs)))
for _, msgStr := range msgs {
var payload struct {
Message notifications.QueueMessage `json:"message"`
Channel string `json:"channel"`
}
if err := json.Unmarshal([]byte(msgStr), &payload); err != nil {
m.logger.Error("Failed to unmarshal scheduled message", zap.Error(err))
m.redis.ZRem(ctx, notifications.QueueScheduled, msgStr)
continue
}
// Move to appropriate priority queue
queueName := m.getQueueName(payload.Message.Request.Priority)
msgJson, _ := json.Marshal(payload.Message)
err = m.redis.XAdd(ctx, &redis.XAddArgs{
Stream: queueName,
Values: map[string]interface{}{
"data": string(msgJson),
"channel": payload.Channel,
},
}).Err()
if err != nil {
m.logger.Error("Failed to enqueue scheduled notification", zap.Error(err))
continue
}
// Remove from scheduled queue
m.redis.ZRem(ctx, notifications.QueueScheduled, msgStr)
m.logger.Debug("Scheduled notification moved to active queue",
zap.String("requestId", payload.Message.Request.ID),
zap.String("queue", queueName))
}
}
// GetQueueStats returns statistics for all queues
func (m *QueueManager) GetQueueStats(ctx context.Context) (map[string]QueueStats, error) {
queues := []string{
notifications.QueueHighPriority,
notifications.QueueNormalPriority,
notifications.QueueLowPriority,
}
stats := make(map[string]QueueStats)
for _, queue := range queues {
info, err := m.redis.XInfoStream(ctx, queue).Result()
if err != nil {
if err.Error() == "ERR no such key" {
stats[queue] = QueueStats{Length: 0}
continue
}
return nil, fmt.Errorf("failed to get info for queue %s: %w", queue, err)
}
stats[queue] = QueueStats{
Length: info.Length,
FirstEntry: extractTimestamp(info.FirstEntry),
LastEntry: extractTimestamp(info.LastEntry),
ConsumerGroups: info.Groups,
}
}
// Get scheduled queue stats
scheduledCount, err := m.redis.ZCard(ctx, notifications.QueueScheduled).Result()
if err == nil {
stats[notifications.QueueScheduled] = QueueStats{
Length: scheduledCount,
}
}
return stats, nil
}
// QueueStats holds queue statistics
type QueueStats struct {
Length int64 `json:"length"`
FirstEntry time.Time `json:"firstEntry,omitempty"`
LastEntry time.Time `json:"lastEntry,omitempty"`
ConsumerGroups int64 `json:"consumerGroups"`
}
// CleanupOldMessages removes processed messages older than the specified duration
func (m *QueueManager) CleanupOldMessages(ctx context.Context, maxAge time.Duration) (int64, error) {
queues := []string{
notifications.QueueHighPriority,
notifications.QueueNormalPriority,
notifications.QueueLowPriority,
}
var totalTrimmed int64
threshold := time.Now().Add(-maxAge)
thresholdID := fmt.Sprintf("%d-0", threshold.UnixMilli())
for _, queue := range queues {
trimmed, err := m.redis.XTrimMinID(ctx, queue, thresholdID).Result()
if err != nil {
m.logger.Error("Failed to trim queue", zap.String("queue", queue), zap.Error(err))
continue
}
totalTrimmed += trimmed
}
return totalTrimmed, nil
}
// Requeue moves a failed message back to the queue with backoff
func (m *QueueManager) Requeue(ctx context.Context, msg notifications.QueueMessage, channel notifications.NotificationChannel) error {
// Calculate backoff
backoff := time.Duration(msg.BackoffMs*(1<<msg.Attempt)) * time.Millisecond
deliverAt := time.Now().Add(backoff)
// Update attempt count
msg.Attempt++
if msg.Attempt >= msg.MaxRetries {
// Move to dead letter queue
return m.moveToDeadLetter(ctx, msg, channel, "max_retries_exceeded")
}
m.logger.Info("Requeuing notification with backoff",
zap.String("requestId", msg.Request.ID),
zap.Int("attempt", msg.Attempt),
zap.Duration("backoff", backoff))
return m.EnqueueScheduled(ctx, msg, channel, deliverAt)
}
// moveToDeadLetter moves a message to the dead letter queue
func (m *QueueManager) moveToDeadLetter(ctx context.Context, msg notifications.QueueMessage, channel notifications.NotificationChannel, reason string) error {
dlqKey := "notifications:dlq"
payload := map[string]interface{}{
"message": msg,
"channel": string(channel),
"reason": reason,
"failed_at": time.Now().Format(time.RFC3339),
"total_attempts": msg.Attempt,
}
msgJson, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal DLQ message: %w", err)
}
return m.redis.LPush(ctx, dlqKey, string(msgJson)).Err()
}
// GetDeadLetterMessages retrieves messages from the dead letter queue
func (m *QueueManager) GetDeadLetterMessages(ctx context.Context, start, stop int64) ([]map[string]interface{}, error) {
dlqKey := "notifications:dlq"
msgs, err := m.redis.LRange(ctx, dlqKey, start, stop).Result()
if err != nil {
return nil, fmt.Errorf("failed to get DLQ messages: %w", err)
}
var result []map[string]interface{}
for _, msgStr := range msgs {
var msg map[string]interface{}
if err := json.Unmarshal([]byte(msgStr), &msg); err != nil {
continue
}
result = append(result, msg)
}
return result, nil
}
// RetryDeadLetterMessage moves a message from DLQ back to the active queue
func (m *QueueManager) RetryDeadLetterMessage(ctx context.Context, index int64) error {
dlqKey := "notifications:dlq"
// Get the message
msgs, err := m.redis.LRange(ctx, dlqKey, index, index).Result()
if err != nil || len(msgs) == 0 {
return fmt.Errorf("message not found at index %d", index)
}
var payload struct {
Message notifications.QueueMessage `json:"message"`
Channel string `json:"channel"`
}
if err := json.Unmarshal([]byte(msgs[0]), &payload); err != nil {
return fmt.Errorf("failed to unmarshal DLQ message: %w", err)
}
// Reset attempt counter
payload.Message.Attempt = 0
payload.Message.EnqueuedAt = time.Now()
// Enqueue to normal priority queue
if err := m.Enqueue(ctx, payload.Message, notifications.NotificationChannel(payload.Channel)); err != nil {
return fmt.Errorf("failed to re-enqueue message: %w", err)
}
// Remove from DLQ (set to empty and trim)
m.redis.LSet(ctx, dlqKey, index, "DELETED")
m.redis.LRem(ctx, dlqKey, 0, "DELETED")
return nil
}
// getQueueName returns the queue name based on priority
func (m *QueueManager) getQueueName(priority notifications.NotificationPriority) string {
switch priority {
case notifications.PriorityHigh, notifications.PriorityUrgent:
return notifications.QueueHighPriority
case notifications.PriorityNormal:
return notifications.QueueNormalPriority
case notifications.PriorityLow:
return notifications.QueueLowPriority
default:
return notifications.QueueNormalPriority
}
}
// extractTimestamp extracts timestamp from a Redis stream entry
func extractTimestamp(entry redis.XMessage) time.Time {
if entry.ID == "" {
return time.Time{}
}
// ID format: timestamp-sequence
var ts int64
fmt.Sscanf(entry.ID, "%d-", &ts)
if ts > 0 {
return time.UnixMilli(ts)
}
return time.Time{}
}