eda033ff9b
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').
With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
300 lines
6.8 KiB
Go
300 lines
6.8 KiB
Go
// Package queue provides domain.Queue implementations used to buffer
|
|
// notifications between submission and delivery, including an in-memory
|
|
// LocalQueue with optional disk persistence.
|
|
package queue
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/igodwin/notifier/internal/domain"
|
|
)
|
|
|
|
// LocalQueue is an in-memory queue implementation
|
|
type LocalQueue struct {
|
|
queue chan *domain.QueueMessage
|
|
messages map[string]*domain.QueueMessage
|
|
mu sync.RWMutex
|
|
config *domain.LocalQueueConfig
|
|
persistToDisk bool
|
|
persistPath string
|
|
closed bool
|
|
closeChan chan struct{}
|
|
}
|
|
|
|
// NewLocalQueue creates a new local queue instance
|
|
func NewLocalQueue(config *domain.LocalQueueConfig) (*LocalQueue, error) {
|
|
if config == nil {
|
|
config = &domain.LocalQueueConfig{
|
|
BufferSize: 1000,
|
|
PersistToDisk: false,
|
|
}
|
|
}
|
|
|
|
lq := &LocalQueue{
|
|
queue: make(chan *domain.QueueMessage, config.BufferSize),
|
|
messages: make(map[string]*domain.QueueMessage),
|
|
config: config,
|
|
persistToDisk: config.PersistToDisk,
|
|
persistPath: config.PersistPath,
|
|
closeChan: make(chan struct{}),
|
|
}
|
|
|
|
// Load persisted messages if enabled
|
|
if lq.persistToDisk && lq.persistPath != "" {
|
|
if err := lq.loadFromDisk(); err != nil {
|
|
return nil, fmt.Errorf("failed to load persisted queue: %w", err)
|
|
}
|
|
}
|
|
|
|
return lq, nil
|
|
}
|
|
|
|
// Enqueue adds a notification to the queue.
|
|
//
|
|
// The mutex must NOT be held while sending on the channel: when the buffer is
|
|
// full the send blocks, and workers need the same mutex (Dequeue bookkeeping,
|
|
// Ack) to drain the channel — holding it here deadlocks the whole pool.
|
|
func (lq *LocalQueue) Enqueue(ctx context.Context, notification *domain.Notification) error {
|
|
if err := lq.checkOpen(); err != nil {
|
|
return err
|
|
}
|
|
|
|
msg := &domain.QueueMessage{
|
|
ID: uuid.New().String(),
|
|
Notification: notification,
|
|
Attempt: 0,
|
|
EnqueuedAt: time.Now().Unix(),
|
|
}
|
|
|
|
select {
|
|
case lq.queue <- msg:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-lq.closeChan:
|
|
return fmt.Errorf("queue is closed")
|
|
}
|
|
|
|
lq.mu.Lock()
|
|
defer lq.mu.Unlock()
|
|
lq.messages[msg.ID] = msg
|
|
notification.Status = domain.StatusQueued
|
|
|
|
if lq.persistToDisk {
|
|
return lq.persistToDiskSync()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnqueueBatch adds multiple notifications to the queue
|
|
func (lq *LocalQueue) EnqueueBatch(ctx context.Context, notifications []*domain.Notification) error {
|
|
for _, notification := range notifications {
|
|
if err := lq.Enqueue(ctx, notification); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkOpen reports an error if the queue has been closed.
|
|
func (lq *LocalQueue) checkOpen() error {
|
|
lq.mu.RLock()
|
|
defer lq.mu.RUnlock()
|
|
if lq.closed {
|
|
return fmt.Errorf("queue is closed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Dequeue retrieves the next notification from the queue
|
|
func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error) {
|
|
if lq.closed {
|
|
return nil, fmt.Errorf("queue is closed")
|
|
}
|
|
|
|
select {
|
|
case msg := <-lq.queue:
|
|
lq.mu.Lock()
|
|
msg.Attempt++
|
|
msg.Notification.Status = domain.StatusProcessing
|
|
lq.mu.Unlock()
|
|
return msg, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-lq.closeChan:
|
|
return nil, fmt.Errorf("queue is closed")
|
|
}
|
|
}
|
|
|
|
// Ack acknowledges successful processing of a message
|
|
func (lq *LocalQueue) Ack(_ context.Context, messageID string) error {
|
|
lq.mu.Lock()
|
|
defer lq.mu.Unlock()
|
|
|
|
if msg, exists := lq.messages[messageID]; exists {
|
|
msg.Notification.Status = domain.StatusSent
|
|
delete(lq.messages, messageID)
|
|
|
|
if lq.persistToDisk {
|
|
return lq.persistToDiskSync()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Nack indicates processing failure and may requeue the message.
|
|
// Like Enqueue, the requeue send happens without holding the mutex to avoid
|
|
// deadlocking against workers draining the channel.
|
|
func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool) error {
|
|
lq.mu.Lock()
|
|
msg, exists := lq.messages[messageID]
|
|
if !exists {
|
|
lq.mu.Unlock()
|
|
return fmt.Errorf("message not found: %s", messageID)
|
|
}
|
|
|
|
if !requeue {
|
|
msg.Notification.Status = domain.StatusFailed
|
|
delete(lq.messages, messageID)
|
|
|
|
var err error
|
|
if lq.persistToDisk {
|
|
err = lq.persistToDiskSync()
|
|
}
|
|
lq.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
msg.Notification.Status = domain.StatusRetrying
|
|
lq.mu.Unlock()
|
|
|
|
select {
|
|
case lq.queue <- msg:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-lq.closeChan:
|
|
return fmt.Errorf("queue is closed")
|
|
}
|
|
|
|
lq.mu.Lock()
|
|
defer lq.mu.Unlock()
|
|
if lq.persistToDisk {
|
|
return lq.persistToDiskSync()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Size returns the current number of messages in the queue
|
|
func (lq *LocalQueue) Size(_ context.Context) (int64, error) {
|
|
lq.mu.RLock()
|
|
defer lq.mu.RUnlock()
|
|
return int64(len(lq.queue)), nil
|
|
}
|
|
|
|
// Purge removes all messages from the queue
|
|
func (lq *LocalQueue) Purge(_ context.Context) error {
|
|
lq.mu.Lock()
|
|
defer lq.mu.Unlock()
|
|
|
|
// Drain the channel
|
|
for len(lq.queue) > 0 {
|
|
<-lq.queue
|
|
}
|
|
|
|
lq.messages = make(map[string]*domain.QueueMessage)
|
|
|
|
if lq.persistToDisk {
|
|
return lq.persistToDiskSync()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close cleanly shuts down the queue
|
|
func (lq *LocalQueue) Close() error {
|
|
lq.mu.Lock()
|
|
defer lq.mu.Unlock()
|
|
|
|
if lq.closed {
|
|
return nil
|
|
}
|
|
|
|
lq.closed = true
|
|
close(lq.closeChan)
|
|
|
|
if lq.persistToDisk {
|
|
if err := lq.persistToDiskSync(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// The queue channel is intentionally not closed: senders no longer hold
|
|
// the mutex while sending, so a concurrent close could panic. closeChan
|
|
// unblocks all pending senders and receivers instead.
|
|
return nil
|
|
}
|
|
|
|
// HealthCheck verifies the queue is operational
|
|
func (lq *LocalQueue) HealthCheck(_ context.Context) error {
|
|
lq.mu.RLock()
|
|
defer lq.mu.RUnlock()
|
|
|
|
if lq.closed {
|
|
return fmt.Errorf("queue is closed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// persistToDiskSync persists the queue state to disk (must be called with lock held)
|
|
func (lq *LocalQueue) persistToDiskSync() error {
|
|
if !lq.persistToDisk || lq.persistPath == "" {
|
|
return nil
|
|
}
|
|
|
|
data, err := json.Marshal(lq.messages)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal queue state: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(lq.persistPath, data, 0600); err != nil {
|
|
return fmt.Errorf("failed to write queue state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadFromDisk loads the queue state from disk
|
|
func (lq *LocalQueue) loadFromDisk() error {
|
|
if lq.persistPath == "" {
|
|
return nil
|
|
}
|
|
|
|
data, err := os.ReadFile(lq.persistPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // No persisted state yet
|
|
}
|
|
return fmt.Errorf("failed to read queue state: %w", err)
|
|
}
|
|
|
|
var messages map[string]*domain.QueueMessage
|
|
if err := json.Unmarshal(data, &messages); err != nil {
|
|
return fmt.Errorf("failed to unmarshal queue state: %w", err)
|
|
}
|
|
|
|
// Re-enqueue persisted messages
|
|
for _, msg := range messages {
|
|
lq.queue <- msg
|
|
lq.messages[msg.ID] = msg
|
|
}
|
|
|
|
return nil
|
|
}
|