Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ protoc --proto_path=protos \

`app/pkg/clients/` contains the email notification client (separate from gRPC secondary adapters in domain layer).

### Reminder Batch Reporting

The reminder pipeline is the only batch processor in the app. `ProcessReminders` (`internal/domains/notification/domain/reminder_service.go`) returns a `*BatchResult` describing per-item outcomes; the cronjob uses `HasInfraFailures()` to distinguish transient infrastructure problems from invalid-input failures for alerting. The retry loop short-circuits on a small list of non-retryable sentinels (validation errors, missing template) so retries aren't wasted on errors guaranteed to keep failing. Both lists live in `batch_result.go`; promote to a shared package only when a second consumer needs the same pattern.

## Hexagonal Architecture Patterns

### Domain Layer (`domain/`)
Expand Down
9 changes: 8 additions & 1 deletion app/cmd/reminder/reminder.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,22 @@ PASSWORDEXCHANGE_REMINDER_INTERVAL: Hours between reminders (1-720, default: 24)

// Process reminders
ctx := context.Background()
if err := reminderService.ProcessReminders(ctx, reminderConfig); err != nil {
result, err := reminderService.ProcessReminders(ctx, reminderConfig)
if err != nil {
logging.Error().
Err(err).
Int("processedCount", result.SuccessCount).
Int("errorCount", result.FailureCount).
Bool("infraFailures", result.HasInfraFailures()).
Str("operation", "process_reminders").
Msg("Failed to process reminders")
return
}

logging.Info().
Int("processedCount", result.SuccessCount).
Int("errorCount", result.FailureCount).
Int("totalProcessed", result.TotalProcessed).
Str("operation", "processing_completed").
Msg("Reminder email processing completed")

Expand Down
98 changes: 98 additions & 0 deletions app/internal/domains/notification/domain/batch_result.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Package domain – reminder batch reporting.
//
// BatchResult records per-item outcomes from ProcessReminders so callers can
// distinguish total failure from partial success, and infrastructure failures
// from invalid-input failures. Lives in this package because the reminder
// pipeline is the only batch processor in the app today; promote to a shared
// package only when a second consumer appears.
package domain

import stderrors "errors"

// BatchError captures one failed item in a reminder batch.
type BatchError struct {
ItemID string
Err error
}

// BatchResult aggregates the outcome of a reminder batch.
type BatchResult struct {
TotalProcessed int
SuccessCount int
FailureCount int
Errors []BatchError
}

// NewBatchResult returns an empty BatchResult.
func NewBatchResult() *BatchResult { return &BatchResult{} }

// RecordSuccess increments the success counter.
func (r *BatchResult) RecordSuccess(itemID string) {
r.TotalProcessed++
r.SuccessCount++
}

// RecordFailure appends a failure entry.
func (r *BatchResult) RecordFailure(itemID string, err error) {
r.TotalProcessed++
r.FailureCount++
r.Errors = append(r.Errors, BatchError{ItemID: itemID, Err: err})
}

// HasFailures reports whether any item failed.
func (r *BatchResult) HasFailures() bool { return r.FailureCount > 0 }

// HasInfraFailures reports whether any failure looks like a transient
// infrastructure problem (queue, SMTP, storage). Useful for alerting:
// validation failures are not a system health concern, infra failures are.
func (r *BatchResult) HasInfraFailures() bool {
for _, e := range r.Errors {
if isInfraError(e.Err) {
return true
}
}
return false
}

// nonRetryableSentinels are domain errors where retrying is guaranteed to
// keep failing — validation problems and missing-config conditions. Anything
// not in this list is treated as transient and eligible for retry.
var nonRetryableSentinels = []error{
ErrInvalidEmailAddress,
ErrInvalidNotificationRequest,
ErrMessageUnmarshalFailed,
ErrEmptyMessageBody,
ErrTemplateNotFound,
ErrInvalidCheckAfterHours,
ErrInvalidMaxReminders,
ErrInvalidReminderInterval,
}

// isRetryable reports whether retryWithBackoff should attempt err again.
func isRetryable(err error) bool {
for _, s := range nonRetryableSentinels {
if stderrors.Is(err, s) {
return false
}
}
return true
}

// infraSentinels are domain errors that indicate transient infrastructure
// problems worth surfacing to monitoring.
var infraSentinels = []error{
ErrEmailSendFailed,
ErrQueueConnectionFailed,
ErrQueueConsumeFailed,
ErrTemplateRenderFailed,
ErrSMTPAuthFailed,
}

func isInfraError(err error) bool {
for _, s := range infraSentinels {
if stderrors.Is(err, s) {
return true
}
}
return false
}
20 changes: 10 additions & 10 deletions app/internal/domains/notification/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,33 @@ package domain
import "errors"

var (
// ErrInvalidEmailAddress indicates the email address is malformed
// ErrInvalidEmailAddress indicates the email address is malformed.
ErrInvalidEmailAddress = errors.New("invalid email address")

// ErrEmailSendFailed indicates the email sending operation failed
// ErrEmailSendFailed indicates the email sending operation failed.
ErrEmailSendFailed = errors.New("failed to send email")

// ErrQueueConnectionFailed indicates queue connection failed
// ErrQueueConnectionFailed indicates queue connection failed.
ErrQueueConnectionFailed = errors.New("failed to connect to message queue")

// ErrQueueConsumeFailed indicates queue consumption failed
// ErrQueueConsumeFailed indicates queue consumption failed.
ErrQueueConsumeFailed = errors.New("failed to consume from message queue")

// ErrTemplateRenderFailed indicates template rendering failed
// ErrTemplateRenderFailed indicates template rendering failed.
ErrTemplateRenderFailed = errors.New("failed to render notification template")

// ErrTemplateNotFound indicates the requested template was not found
// ErrTemplateNotFound indicates the requested template was not found.
ErrTemplateNotFound = errors.New("notification template not found")

// ErrInvalidNotificationRequest indicates the notification request is invalid
// ErrInvalidNotificationRequest indicates the notification request is invalid.
ErrInvalidNotificationRequest = errors.New("invalid notification request")

// ErrSMTPAuthFailed indicates SMTP authentication failed
// ErrSMTPAuthFailed indicates SMTP authentication failed.
ErrSMTPAuthFailed = errors.New("SMTP authentication failed")

// ErrMessageUnmarshalFailed indicates message unmarshaling failed
// ErrMessageUnmarshalFailed indicates message unmarshaling failed.
ErrMessageUnmarshalFailed = errors.New("failed to unmarshal queue message")

// ErrEmptyMessageBody indicates the message body is empty
// ErrEmptyMessageBody indicates the message body is empty.
ErrEmptyMessageBody = errors.New("message body is empty")
)
65 changes: 42 additions & 23 deletions app/internal/domains/notification/domain/reminder_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"time"

"github.com/Anthony-Bible/password-exchange/app/internal/domains/notification/ports/secondary"
Expand Down Expand Up @@ -100,11 +101,17 @@ func NewReminderService(
}
}

// ProcessReminders finds and processes messages eligible for reminder emails
func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig ReminderConfig) error {
// ProcessReminders finds and processes messages eligible for reminder emails.
// It returns a BatchResult describing per-item outcomes alongside any
// top-level error (config validation, fetch failure). When at least one
// message was processed successfully the error is nil even if other items
// failed — callers inspect the BatchResult to report partial success.
func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig ReminderConfig) (*BatchResult, error) {
result := NewBatchResult()

// Check context cancellation early
if err := ctx.Err(); err != nil {
return err
return result, err
}

// Validate configuration
Expand All @@ -116,12 +123,12 @@ func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig R
Int("maxReminders", reminderConfig.MaxReminders).
Int("reminderInterval", reminderConfig.Interval).
Msg("Invalid reminder configuration")
return err
return result, err
}

if !reminderConfig.Enabled {
r.logger.Info().Bool("enabled", false).Msg("Reminder system is disabled")
return nil
return result, nil
}

r.logger.Info().
Expand All @@ -145,21 +152,21 @@ func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig R
return err
}, "get_unviewed_messages")
if err != nil {
return fmt.Errorf("failed to get unviewed messages: %w", err)
return result, fmt.Errorf("failed to get unviewed messages: %w", err)
}

r.logger.Info().Int("count", len(messages)).Msg("Found messages eligible for reminders")

if len(messages) == 0 {
r.logger.Info().Msg("No messages found requiring reminders")
return nil
return result, nil
}

// Process each message with individual error recovery
// Strategy: Continue processing other messages even if some fail (graceful degradation)
processedCount := 0
errorCount := 0
for _, message := range messages {
itemID := strconv.Itoa(message.MessageID)

// Create reminder request
reminderReq := ReminderRequest{
MessageID: message.MessageID,
Expand All @@ -174,7 +181,7 @@ func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig R
return r.ProcessMessageReminder(ctx, reminderReq)
}, fmt.Sprintf("process_message_%d", message.MessageID))
if err != nil {
errorCount++
result.RecordFailure(itemID, err)
r.logger.Error().
Err(err).
Int("messageID", message.MessageID).
Expand All @@ -184,36 +191,36 @@ func (r *ReminderService) ProcessReminders(ctx context.Context, reminderConfig R
Msg("Failed to process reminder for message after all retry attempts")
continue // Continue processing other messages
}
processedCount++
result.RecordSuccess(itemID)
}

r.logger.Info().
Int("totalMessages", len(messages)).
Int("processedCount", processedCount).
Int("errorCount", errorCount).
Int("processedCount", result.SuccessCount).
Int("errorCount", result.FailureCount).
Msg("Reminder processing completed")

// Implement graceful degradation: return success if at least some messages were processed
// This allows partial success rather than all-or-nothing failure
if processedCount > 0 {
if result.SuccessCount > 0 {
r.logger.Info().
Int("processedCount", processedCount).
Int("errorCount", errorCount).
Float64("successRate", float64(processedCount)/float64(len(messages))*100).
Int("processedCount", result.SuccessCount).
Int("errorCount", result.FailureCount).
Float64("successRate", float64(result.SuccessCount)/float64(len(messages))*100).
Msg("Reminder processing completed with partial success")
return nil
return result, nil
}

// If no messages were processed and we had errors, this indicates a more serious issue
if errorCount > 0 {
if result.FailureCount > 0 {
r.logger.Error().
Int("errorCount", errorCount).
Int("errorCount", result.FailureCount).
Int("totalMessages", len(messages)).
Msg("Failed to process any reminder messages")
return fmt.Errorf("failed to process any of %d reminder messages", len(messages))
return result, fmt.Errorf("failed to process any of %d reminder messages", len(messages))
}

return nil
return result, nil
}

// ProcessMessageReminder sends a reminder email for a specific message
Expand Down Expand Up @@ -341,6 +348,18 @@ func (r *ReminderService) retryWithBackoff(ctx context.Context, operation func()
}

lastErr = err

// Fail fast on validation / missing-config errors. Retrying them is
// guaranteed to keep failing, and we don't want to trip the circuit
// breaker on user-supplied invalid input.
if !isRetryable(err) {
r.logger.Debug().
Err(err).
Str("operation", operationName).
Msg("Operation failed with non-retryable error; skipping retry")
return err
}

r.circuitBreaker.RecordFailure()

// Don't retry on last attempt
Expand Down Expand Up @@ -375,7 +394,7 @@ func (r *ReminderService) retryWithBackoff(ctx context.Context, operation func()
Int("maxRetries", MaxRetries).
Msg("Operation failed after all retry attempts")

return fmt.Errorf("%w: %s failed after %d attempts: %v", ErrMaxRetriesExceeded, operationName, MaxRetries, lastErr)
return fmt.Errorf("%w: %s failed after %d attempts: %w", ErrMaxRetriesExceeded, operationName, MaxRetries, lastErr)
}

// validateReminderConfig validates all reminder configuration parameters
Expand Down
Loading
Loading