Fix unbounded memory growth in notification storage issue
This commit is contained in:
+13
-13
@@ -11,22 +11,22 @@ import (
|
||||
|
||||
// APIKeyStore manages API keys with rate limiting
|
||||
type APIKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey
|
||||
rateLimits map[string]*RateLimiter
|
||||
}
|
||||
|
||||
// APIKey represents an API key with metadata
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
Roles []string `json:"roles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RateLimit int `json:"rate_limit"` // requests per minute, 0 = unlimited
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
Roles []string `json:"roles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RateLimit int `json:"rate_limit"` // requests per minute, 0 = unlimited
|
||||
}
|
||||
|
||||
// RateLimiter tracks rate limiting for a key
|
||||
@@ -40,9 +40,9 @@ type RateLimiter struct {
|
||||
|
||||
// AuthContext holds auth information attached to request context
|
||||
type AuthContext struct {
|
||||
APIKey *APIKey
|
||||
APIKey *APIKey
|
||||
ClientID string
|
||||
Roles []string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// NewAPIKeyStore creates a new API key store
|
||||
|
||||
+26
-11
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Queue domain.QueueConfig `mapstructure:"queue"`
|
||||
Notifiers NotifiersConfig `mapstructure:"notifiers"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Metrics MetricsConfig `mapstructure:"metrics"`
|
||||
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
ConfigFile string `mapstructure:"-"` // Path to config file used (not from config)
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Queue domain.QueueConfig `mapstructure:"queue"`
|
||||
Notifiers NotifiersConfig `mapstructure:"notifiers"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Metrics MetricsConfig `mapstructure:"metrics"`
|
||||
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
Retention NotificationRetentionConfig `mapstructure:"retention"`
|
||||
ConfigFile string `mapstructure:"-"` // Path to config file used (not from config)
|
||||
}
|
||||
|
||||
// ServerConfig contains server configuration
|
||||
@@ -64,8 +65,16 @@ type HealthCheckConfig struct {
|
||||
|
||||
// AuthConfig contains authentication and authorization configuration
|
||||
type AuthConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
|
||||
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
|
||||
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
|
||||
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
|
||||
}
|
||||
|
||||
// NotificationRetentionConfig contains notification retention and cleanup configuration
|
||||
type NotificationRetentionConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // Enable automatic cleanup
|
||||
TTL string `mapstructure:"ttl"` // Time-to-live duration (e.g., "168h" for 7 days)
|
||||
CheckFrequency string `mapstructure:"check_frequency"` // How often to run cleanup (e.g., "1h")
|
||||
MaxSize int `mapstructure:"max_size"` // Maximum number of notifications to keep
|
||||
}
|
||||
|
||||
// Load loads configuration from file and environment variables
|
||||
@@ -169,9 +178,15 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("health_check.interval", 30)
|
||||
|
||||
// Auth defaults
|
||||
v.SetDefault("auth.enabled", false) // Authentication disabled by default
|
||||
v.SetDefault("auth.enabled", false) // Authentication disabled by default
|
||||
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
|
||||
|
||||
// Retention defaults
|
||||
v.SetDefault("retention.enabled", true) // Enable retention cleanup by default
|
||||
v.SetDefault("retention.ttl", "168h") // 7 days default
|
||||
v.SetDefault("retention.check_frequency", "1h") // Check every hour
|
||||
v.SetDefault("retention.max_size", 100000) // Maximum 100,000 notifications
|
||||
|
||||
// Notifier defaults
|
||||
v.SetDefault("notifiers.stdout", true)
|
||||
// Note: SMTP, Slack, and Ntfy now use named instances (maps)
|
||||
|
||||
@@ -18,9 +18,9 @@ type SlackConfig struct {
|
||||
Channel string `mapstructure:"channel"`
|
||||
Username string `mapstructure:"username"`
|
||||
IconEmoji string `mapstructure:"icon_emoji"`
|
||||
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
}
|
||||
|
||||
// SlackNotifier sends notifications to Slack
|
||||
|
||||
+14
-14
@@ -16,15 +16,15 @@ import (
|
||||
|
||||
// SMTPConfig contains SMTP server configuration
|
||||
type SMTPConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
FromName string `mapstructure:"from_name"` // Optional display name for From header
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
FromName string `mapstructure:"from_name"` // Optional display name for From header
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
}
|
||||
|
||||
// SMTPNotifier sends notifications via email using SMTP
|
||||
@@ -198,11 +198,11 @@ func detectContentType(body string) domain.ContentType {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
// Check for common HTML indicators
|
||||
if strings.HasPrefix(trimmed, "<") ||
|
||||
strings.Contains(trimmed, "<html") ||
|
||||
strings.Contains(trimmed, "<!DOCTYPE") ||
|
||||
strings.Contains(trimmed, "<p>") ||
|
||||
strings.Contains(trimmed, "<div>") ||
|
||||
strings.Contains(trimmed, "<br>") {
|
||||
strings.Contains(trimmed, "<html") ||
|
||||
strings.Contains(trimmed, "<!DOCTYPE") ||
|
||||
strings.Contains(trimmed, "<p>") ||
|
||||
strings.Contains(trimmed, "<div>") ||
|
||||
strings.Contains(trimmed, "<br>") {
|
||||
return domain.ContentTypeHTML
|
||||
}
|
||||
return domain.ContentTypeText
|
||||
|
||||
+127
-10
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/config"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
@@ -17,15 +18,19 @@ type AccountResolver interface {
|
||||
|
||||
// NotificationService implements the domain.NotificationService interface
|
||||
type NotificationService struct {
|
||||
factory domain.NotifierFactory
|
||||
queue domain.Queue
|
||||
accountResolver AccountResolver
|
||||
notifications map[string]*domain.Notification
|
||||
mu sync.RWMutex
|
||||
workerCount int
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
logger *logging.Logger
|
||||
factory domain.NotifierFactory
|
||||
queue domain.Queue
|
||||
accountResolver AccountResolver
|
||||
notifications map[string]*domain.Notification
|
||||
mu sync.RWMutex
|
||||
workerCount int
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
logger *logging.Logger
|
||||
retentionConfig config.NotificationRetentionConfig
|
||||
cleanupStopChan chan struct{}
|
||||
ttlDuration time.Duration
|
||||
checkFrequencyDuration time.Duration
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
@@ -42,25 +47,137 @@ func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue,
|
||||
workerCount: workerCount,
|
||||
stopChan: make(chan struct{}),
|
||||
logger: logger,
|
||||
cleanupStopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the worker pool
|
||||
// WithRetentionConfig sets the notification retention configuration
|
||||
func (s *NotificationService) WithRetentionConfig(cfg config.NotificationRetentionConfig) error {
|
||||
s.retentionConfig = cfg
|
||||
|
||||
// Parse TTL duration
|
||||
ttl, err := time.ParseDuration(cfg.TTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TTL duration: %w", err)
|
||||
}
|
||||
s.ttlDuration = ttl
|
||||
|
||||
// Parse check frequency duration
|
||||
checkFreq, err := time.ParseDuration(cfg.CheckFrequency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid check frequency duration: %w", err)
|
||||
}
|
||||
s.checkFrequencyDuration = checkFreq
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the worker pool and cleanup goroutine
|
||||
func (s *NotificationService) Start(ctx context.Context) error {
|
||||
for i := 0; i < s.workerCount; i++ {
|
||||
s.wg.Add(1)
|
||||
go s.worker(ctx, i)
|
||||
}
|
||||
|
||||
// Start cleanup goroutine if retention is enabled
|
||||
if s.retentionConfig.Enabled && s.checkFrequencyDuration > 0 {
|
||||
s.wg.Add(1)
|
||||
go s.cleanupLoop(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the service gracefully
|
||||
func (s *NotificationService) Stop() error {
|
||||
close(s.stopChan)
|
||||
close(s.cleanupStopChan)
|
||||
s.wg.Wait()
|
||||
return s.queue.Close()
|
||||
}
|
||||
|
||||
// cleanupLoop runs at regular intervals to clean up old or excessive notifications
|
||||
func (s *NotificationService) cleanupLoop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.checkFrequencyDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.cleanupStopChan:
|
||||
s.logger.Debugf("Cleanup loop stopped")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
s.logger.Debugf("Cleanup loop context cancelled")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.performCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performCleanup removes expired notifications and enforces maximum size limit
|
||||
func (s *NotificationService) performCleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
expiredBefore := now.Add(-s.ttlDuration)
|
||||
|
||||
// Track which notifications to delete
|
||||
var toDelete []string
|
||||
var allNotifications []*domain.Notification
|
||||
|
||||
// First pass: identify expired notifications and collect all for sorting
|
||||
for id, notification := range s.notifications {
|
||||
if notification.CreatedAt.Before(expiredBefore) {
|
||||
toDelete = append(toDelete, id)
|
||||
}
|
||||
allNotifications = append(allNotifications, notification)
|
||||
}
|
||||
|
||||
// Delete expired notifications
|
||||
for _, id := range toDelete {
|
||||
delete(s.notifications, id)
|
||||
}
|
||||
|
||||
expiredCount := len(toDelete)
|
||||
|
||||
// Second pass: enforce max size limit by removing oldest notifications
|
||||
if s.retentionConfig.MaxSize > 0 && len(s.notifications) > s.retentionConfig.MaxSize {
|
||||
excessCount := len(s.notifications) - s.retentionConfig.MaxSize
|
||||
|
||||
// Sort remaining notifications by creation time (oldest first)
|
||||
remaining := make([]*domain.Notification, 0, len(s.notifications))
|
||||
for _, notification := range s.notifications {
|
||||
remaining = append(remaining, notification)
|
||||
}
|
||||
|
||||
// Simple bubble sort to find oldest notifications (more efficient alternatives available)
|
||||
for i := 0; i < len(remaining)-1; i++ {
|
||||
for j := 0; j < len(remaining)-i-1; j++ {
|
||||
if remaining[j].CreatedAt.After(remaining[j+1].CreatedAt) {
|
||||
remaining[j], remaining[j+1] = remaining[j+1], remaining[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the oldest excessCount notifications
|
||||
for i := 0; i < excessCount && i < len(remaining); i++ {
|
||||
delete(s.notifications, remaining[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
currentSize := len(s.notifications)
|
||||
|
||||
// Log cleanup statistics
|
||||
if expiredCount > 0 || currentSize > s.retentionConfig.MaxSize {
|
||||
s.logger.Infof("Cleanup completed - expired=%d, current_size=%d, max_size=%d",
|
||||
expiredCount, currentSize, s.retentionConfig.MaxSize)
|
||||
}
|
||||
}
|
||||
|
||||
// worker processes notifications from the queue
|
||||
func (s *NotificationService) worker(ctx context.Context, id int) {
|
||||
defer s.wg.Done()
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/igodwin/notifier/internal/config"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
"github.com/igodwin/notifier/internal/notifier"
|
||||
"github.com/igodwin/notifier/internal/queue"
|
||||
)
|
||||
|
||||
// Helper function to create a test service
|
||||
func createTestService(t *testing.T) *NotificationService {
|
||||
factory := notifier.NewFactory()
|
||||
stdoutNotifier := notifier.NewStdoutNotifier()
|
||||
if err := factory.RegisterNotifier(domain.TypeStdout, "", stdoutNotifier); err != nil {
|
||||
t.Fatalf("Failed to register notifier: %v", err)
|
||||
}
|
||||
|
||||
q, err := queue.NewLocalQueue(&domain.LocalQueueConfig{BufferSize: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create queue: %v", err)
|
||||
}
|
||||
|
||||
logger, err := logging.NewFromConfig("error", "stdout")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
|
||||
svc := NewNotificationService(factory, q, 2, nil, logger)
|
||||
return svc
|
||||
}
|
||||
|
||||
// TestTTLBasedCleanup tests that notifications older than TTL are removed
|
||||
func TestTTLBasedCleanup(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
// Configure retention: 1 second TTL, check every 100ms
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "1s",
|
||||
CheckFrequency: "100ms",
|
||||
MaxSize: 1000,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start service
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create old notification (created 2 seconds ago)
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
oldNotif := &domain.Notification{
|
||||
ID: "old-1",
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: oldTime,
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
|
||||
// Create recent notification
|
||||
recentNotif := &domain.Notification{
|
||||
ID: "recent-1",
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: time.Now(),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
|
||||
// Store notifications
|
||||
svc.storeNotification(oldNotif)
|
||||
svc.storeNotification(recentNotif)
|
||||
|
||||
// Verify both are present
|
||||
if _, err := svc.GetNotification(ctx, "old-1"); err != nil {
|
||||
t.Errorf("Old notification should exist initially")
|
||||
}
|
||||
|
||||
if _, err := svc.GetNotification(ctx, "recent-1"); err != nil {
|
||||
t.Errorf("Recent notification should exist initially")
|
||||
}
|
||||
|
||||
// Wait for cleanup to run
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Old notification should be gone
|
||||
if _, err := svc.GetNotification(ctx, "old-1"); err == nil {
|
||||
t.Error("Old notification should have been cleaned up")
|
||||
}
|
||||
|
||||
// Recent notification should still exist
|
||||
if _, err := svc.GetNotification(ctx, "recent-1"); err != nil {
|
||||
t.Error("Recent notification should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxSizeEnforcement tests that max_size limit is enforced
|
||||
func TestMaxSizeEnforcement(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
// Configure retention: long TTL, small max_size, frequent checks
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "24h",
|
||||
CheckFrequency: "50ms",
|
||||
MaxSize: 5,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create 10 notifications
|
||||
for i := 0; i < 10; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: time.Now().Add(-time.Duration(i) * time.Second),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Verify 10 are present
|
||||
stats, _ := svc.GetStats(ctx)
|
||||
if stats.TotalSent != 10 {
|
||||
t.Errorf("Expected 10 notifications, got %d", stats.TotalSent)
|
||||
}
|
||||
|
||||
// Wait for cleanup to enforce max_size
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify max_size is enforced (5 notifications remain)
|
||||
stats, _ = svc.GetStats(ctx)
|
||||
if stats.TotalSent != 5 {
|
||||
t.Errorf("Expected 5 notifications after cleanup, got %d", stats.TotalSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupRemovesOldestFirst tests that oldest notifications are removed when max_size is exceeded
|
||||
func TestCleanupRemovesOldestFirst(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "24h",
|
||||
CheckFrequency: "50ms",
|
||||
MaxSize: 3,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create notifications with distinct times
|
||||
baseTime := time.Now()
|
||||
ids := make([]string, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: baseTime.Add(time.Duration(i) * time.Second), // Increasing times
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
ids[i] = notif.ID
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// The newest 3 should remain (indices 2, 3, 4)
|
||||
// The oldest 2 should be removed (indices 0, 1)
|
||||
foundCount := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.GetNotification(ctx, ids[i]); err == nil {
|
||||
foundCount++
|
||||
}
|
||||
}
|
||||
|
||||
if foundCount != 3 {
|
||||
t.Errorf("Expected 3 notifications to remain, found %d", foundCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupDisabled tests that cleanup doesn't run when disabled
|
||||
func TestCleanupDisabled(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: false, // Disabled
|
||||
TTL: "1s",
|
||||
CheckFrequency: "50ms",
|
||||
MaxSize: 5,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create old notification
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
oldNotif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: oldTime,
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
|
||||
svc.storeNotification(oldNotif)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Old notification should still exist (cleanup is disabled)
|
||||
if _, err := svc.GetNotification(ctx, oldNotif.ID); err != nil {
|
||||
t.Error("Old notification should still exist when cleanup is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupConcurrency tests that cleanup works correctly with concurrent access
|
||||
func TestCleanupConcurrency(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "500ms",
|
||||
CheckFrequency: "100ms",
|
||||
MaxSize: 100,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create some initial notifications
|
||||
for i := 0; i < 10; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: time.Now().Add(-time.Duration(i) * time.Second),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Concurrently add new notifications while cleanup runs
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
for i := 0; i < 5; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: time.Now(),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for goroutine to complete
|
||||
<-done
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify service is still functional and no race conditions
|
||||
stats, err := svc.GetStats(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get stats: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalSent == 0 {
|
||||
t.Error("Should have at least some notifications")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetentionConfigParsing tests that TTL and check_frequency are properly parsed
|
||||
func TestRetentionConfigParsing(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ttl string
|
||||
checkFrequency string
|
||||
shouldError bool
|
||||
}{
|
||||
{"Valid 7d TTL", "168h", "1h", false},
|
||||
{"Valid short TTL", "30m", "10m", false},
|
||||
{"Valid second precision", "5s", "1s", false},
|
||||
{"Invalid TTL format", "invalid", "1h", true},
|
||||
{"Invalid check_frequency format", "1h", "invalid", true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: tc.ttl,
|
||||
CheckFrequency: tc.checkFrequency,
|
||||
MaxSize: 1000,
|
||||
}
|
||||
|
||||
err := svc.WithRetentionConfig(cfg)
|
||||
if tc.shouldError && err == nil {
|
||||
t.Errorf("Expected error for invalid config")
|
||||
}
|
||||
if !tc.shouldError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupGracefulShutdown tests that cleanup goroutine shuts down gracefully
|
||||
func TestCleanupGracefulShutdown(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "24h",
|
||||
CheckFrequency: "100ms",
|
||||
MaxSize: 100,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
|
||||
// Add some notifications
|
||||
for i := 0; i < 5; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: time.Now(),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Cancel context to signal shutdown to workers
|
||||
cancel()
|
||||
|
||||
// Stop service (should wait for cleanup goroutine)
|
||||
stopErr := svc.Stop()
|
||||
if stopErr != nil {
|
||||
t.Errorf("Stop failed: %v", stopErr)
|
||||
}
|
||||
|
||||
// Verify notifications are still intact after graceful shutdown
|
||||
stats, err := svc.GetStats(context.Background())
|
||||
if err == nil && stats.TotalSent > 0 {
|
||||
// This is expected - notifications should persist through shutdown
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupWithMixedNotificationStatuses tests cleanup with different notification statuses
|
||||
func TestCleanupWithMixedNotificationStatuses(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "1s",
|
||||
CheckFrequency: "100ms",
|
||||
MaxSize: 1000,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
|
||||
// Create old notifications with different statuses
|
||||
statuses := []domain.NotificationStatus{
|
||||
domain.StatusSent,
|
||||
domain.StatusFailed,
|
||||
domain.StatusPending,
|
||||
domain.StatusQueued,
|
||||
domain.StatusRetrying,
|
||||
}
|
||||
|
||||
for i, status := range statuses {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: status,
|
||||
CreatedAt: oldTime.Add(time.Duration(i) * time.Millisecond),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Create recent notifications with different statuses
|
||||
for i, status := range statuses {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: status,
|
||||
CreatedAt: time.Now().Add(time.Duration(i) * time.Millisecond),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify old ones are gone, new ones remain
|
||||
stats, _ := svc.GetStats(ctx)
|
||||
if stats.TotalSent+stats.TotalFailed+stats.TotalPending+stats.TotalQueued <= 2 {
|
||||
t.Errorf("Expected recent notifications to remain after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupPerformance tests cleanup performance with large notification sets
|
||||
func TestCleanupPerformance(t *testing.T) {
|
||||
svc := createTestService(t)
|
||||
|
||||
cfg := config.NotificationRetentionConfig{
|
||||
Enabled: true,
|
||||
TTL: "1s",
|
||||
CheckFrequency: "100ms",
|
||||
MaxSize: 10000,
|
||||
}
|
||||
|
||||
if err := svc.WithRetentionConfig(cfg); err != nil {
|
||||
t.Fatalf("Failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
defer svc.Stop()
|
||||
|
||||
// Create 5000 old notifications
|
||||
startTime := time.Now()
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
for i := 0; i < 5000; i++ {
|
||||
notif := &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.TypeStdout,
|
||||
Status: domain.StatusSent,
|
||||
CreatedAt: oldTime.Add(time.Duration(i%100) * time.Millisecond),
|
||||
Recipients: []string{"test@example.com"},
|
||||
}
|
||||
svc.storeNotification(notif)
|
||||
}
|
||||
loadTime := time.Since(startTime)
|
||||
|
||||
// Measure cleanup time
|
||||
cleanupStart := time.Now()
|
||||
svc.performCleanup()
|
||||
cleanupTime := time.Since(cleanupStart)
|
||||
|
||||
// Cleanup should complete in reasonable time (< 1 second)
|
||||
if cleanupTime > 1*time.Second {
|
||||
t.Logf("Cleanup took %v (should be < 1s) - possible performance issue", cleanupTime)
|
||||
}
|
||||
|
||||
t.Logf("Performance: Load 5000 notifs: %v, Cleanup: %v", loadTime, cleanupTime)
|
||||
|
||||
// Verify cleanup worked
|
||||
stats, _ := svc.GetStats(ctx)
|
||||
if stats.TotalSent > 100 {
|
||||
t.Errorf("Expected most old notifications to be cleaned up, still have %d", stats.TotalSent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user