|
| 1 | +// Package alert tracks delivery health per destination: consecutive-failure |
| 2 | +// counting, alert thresholds, and retry exhaustion. It is a pure tracker — it |
| 3 | +// returns signals as data and performs no side effects outside its own state. |
| 4 | +// Acting on the signals (operator events, auto-disable, replay dedup) is the |
| 5 | +// caller's job. |
1 | 6 | package alert |
2 | 7 |
|
3 | 8 | import ( |
4 | | - "sort" |
| 9 | + "context" |
| 10 | + "fmt" |
5 | 11 | ) |
6 | 12 |
|
7 | | -// AlertEvaluator determines when alerts should be triggered |
8 | | -type AlertEvaluator interface { |
9 | | - // ShouldAlert determines if an alert should be sent and returns the alert level |
10 | | - ShouldAlert(failures int) (level int, shouldAlert bool) |
| 13 | +// Attempt is the tracker's input: the identity and outcome of one delivery |
| 14 | +// attempt, nothing more. |
| 15 | +type Attempt struct { |
| 16 | + TenantID string |
| 17 | + DestinationID string |
| 18 | + AttemptID string |
| 19 | + Number int // 1-indexed attempt number |
| 20 | + Success bool |
| 21 | + EligibleForRetry bool |
11 | 22 | } |
12 | 23 |
|
13 | | -type thresholdPair struct { |
14 | | - percentage int |
15 | | - failures int |
| 24 | +// Evaluation is the tracker's verdict on one attempt: one field per signal |
| 25 | +// kind, nil/false when that kind has nothing to report. An attempt can carry |
| 26 | +// several signals at once. Zero value = nothing to report (success, or a |
| 27 | +// failure that crossed no threshold and exhausted no retries). |
| 28 | +type Evaluation struct { |
| 29 | + // ConsecutiveFailure is non-nil when this attempt's consecutive-failure |
| 30 | + // count crossed an alert threshold. |
| 31 | + ConsecutiveFailure *ConsecutiveFailureSignal |
| 32 | + // RetriesExhausted reports that this attempt exceeded the retry budget for |
| 33 | + // a retry-eligible event. |
| 34 | + RetriesExhausted bool |
16 | 35 | } |
17 | 36 |
|
18 | | -type alertEvaluator struct { |
19 | | - thresholds []thresholdPair // sorted pairs of percentage and failure counts |
20 | | - autoDisableFailureCount int |
| 37 | +// ConsecutiveFailureSignal reports a crossed consecutive-failure threshold. |
| 38 | +type ConsecutiveFailureSignal struct { |
| 39 | + Failures int // current consecutive-failure count |
| 40 | + Max int // configured 100%-threshold failure count |
| 41 | + Level int // crossed threshold's percentage (e.g. 50/70/90/100) |
21 | 42 | } |
22 | 43 |
|
23 | | -// NewAlertEvaluator creates a new alert evaluator |
24 | | -func NewAlertEvaluator(thresholds []int, autoDisableFailureCount int) AlertEvaluator { |
25 | | - // Create pairs of percentage thresholds and their corresponding failure counts |
26 | | - finalThresholds := make([]thresholdPair, 0, len(thresholds)) |
| 44 | +// Option configures an evaluator. |
| 45 | +type Option func(*Evaluator) |
27 | 46 |
|
28 | | - // Convert percentages to failure counts |
29 | | - for _, percentage := range thresholds { |
30 | | - // Skip invalid percentages |
31 | | - if percentage <= 0 || percentage > 100 { |
32 | | - continue |
33 | | - } |
34 | | - // Ceiling division: (a + b - 1) / b |
35 | | - failures := (int(autoDisableFailureCount)*int(percentage) + 99) / 100 |
36 | | - finalThresholds = append(finalThresholds, thresholdPair{ |
37 | | - percentage: percentage, |
38 | | - failures: failures, |
39 | | - }) |
| 47 | +// WithAutoDisableFailureCount sets the consecutive-failure count that means |
| 48 | +// 100% — the denominator for threshold math. |
| 49 | +func WithAutoDisableFailureCount(count int) Option { |
| 50 | + return func(e *Evaluator) { |
| 51 | + e.autoDisableFailureCount = count |
40 | 52 | } |
| 53 | +} |
41 | 54 |
|
42 | | - sort.Slice(finalThresholds, func(i, j int) bool { return finalThresholds[i].failures < finalThresholds[j].failures }) |
| 55 | +// WithAlertThresholds sets the percentage thresholds at which alerts fire. |
| 56 | +func WithAlertThresholds(thresholds []int) Option { |
| 57 | + return func(e *Evaluator) { |
| 58 | + e.alertThresholds = thresholds |
| 59 | + } |
| 60 | +} |
43 | 61 |
|
44 | | - // Check if we need to add 100 |
45 | | - needsAutoDisable := true |
46 | | - if len(finalThresholds) > 0 && finalThresholds[len(finalThresholds)-1].percentage == 100 { |
47 | | - needsAutoDisable = false |
| 62 | +// WithConsecutiveFailureEnabled toggles consecutive-failure tracking. When set |
| 63 | +// to false the evaluator never tracks failures or crosses thresholds. |
| 64 | +// Defaults to true. |
| 65 | +func WithConsecutiveFailureEnabled(enabled bool) Option { |
| 66 | + return func(e *Evaluator) { |
| 67 | + e.consecutiveFailureEnabled = enabled |
48 | 68 | } |
| 69 | +} |
49 | 70 |
|
50 | | - // Auto-include 100% threshold if not present |
51 | | - if needsAutoDisable { |
52 | | - finalThresholds = append(finalThresholds, thresholdPair{ |
53 | | - percentage: 100, |
54 | | - failures: autoDisableFailureCount, |
55 | | - }) |
| 71 | +// WithExhaustedRetriesEnabled toggles the retry-exhaustion signal. Defaults to |
| 72 | +// true. |
| 73 | +func WithExhaustedRetriesEnabled(enabled bool) Option { |
| 74 | + return func(e *Evaluator) { |
| 75 | + e.exhaustedRetriesEnabled = enabled |
56 | 76 | } |
| 77 | +} |
| 78 | + |
| 79 | +// Evaluator evaluates delivery attempts against the destination's failure |
| 80 | +// history and returns the resulting signals as data. |
| 81 | +type Evaluator struct { |
| 82 | + store AlertStore |
| 83 | + thresholds thresholdEvaluator |
| 84 | + |
| 85 | + autoDisableFailureCount int |
| 86 | + alertThresholds []int |
| 87 | + retryMaxLimit int |
| 88 | + |
| 89 | + consecutiveFailureEnabled bool |
| 90 | + exhaustedRetriesEnabled bool |
| 91 | +} |
57 | 92 |
|
58 | | - return &alertEvaluator{ |
59 | | - thresholds: finalThresholds, |
60 | | - autoDisableFailureCount: autoDisableFailureCount, |
| 93 | +// NewEvaluator creates a new alert evaluator on the given store. |
| 94 | +func NewEvaluator(store AlertStore, retryMaxLimit int, opts ...Option) *Evaluator { |
| 95 | + e := &Evaluator{ |
| 96 | + store: store, |
| 97 | + retryMaxLimit: retryMaxLimit, |
| 98 | + alertThresholds: []int{50, 70, 90, 100}, // default thresholds |
| 99 | + consecutiveFailureEnabled: true, |
| 100 | + exhaustedRetriesEnabled: true, |
61 | 101 | } |
| 102 | + |
| 103 | + for _, opt := range opts { |
| 104 | + opt(e) |
| 105 | + } |
| 106 | + |
| 107 | + e.thresholds = newThresholdEvaluator(e.alertThresholds, e.autoDisableFailureCount) |
| 108 | + |
| 109 | + return e |
62 | 110 | } |
63 | 111 |
|
64 | | -func (e *alertEvaluator) ShouldAlert(failures int) (int, bool) { |
65 | | - // If no thresholds configured, never alert |
66 | | - if len(e.thresholds) == 0 { |
67 | | - return 0, false |
| 112 | +func (e *Evaluator) Evaluate(ctx context.Context, attempt Attempt) (Evaluation, error) { |
| 113 | + if attempt.Success { |
| 114 | + // Nothing is tracked when consecutive-failure tracking is disabled, so |
| 115 | + // there is no count to reset. |
| 116 | + if !e.consecutiveFailureEnabled { |
| 117 | + return Evaluation{}, nil |
| 118 | + } |
| 119 | + if err := e.store.ResetConsecutiveFailureCount(ctx, attempt.TenantID, attempt.DestinationID); err != nil { |
| 120 | + return Evaluation{}, err |
| 121 | + } |
| 122 | + return Evaluation{}, nil |
68 | 123 | } |
69 | 124 |
|
70 | | - // Get current alert level |
71 | | - // Iterate from highest to lowest threshold |
72 | | - for i := len(e.thresholds) - 1; i >= 0; i-- { |
73 | | - threshold := e.thresholds[i] |
74 | | - |
75 | | - // For the 100% threshold (auto-disable), use >= to ensure we don't miss it |
76 | | - // if concurrent processing causes us to skip over the exact count. |
77 | | - // For other thresholds, use exact match to avoid duplicate alerts. |
78 | | - if threshold.percentage == 100 { |
79 | | - if failures >= threshold.failures { |
80 | | - return threshold.percentage, true |
81 | | - } |
82 | | - } else { |
83 | | - if failures == threshold.failures { |
84 | | - return threshold.percentage, true |
| 125 | + var eval Evaluation |
| 126 | + |
| 127 | + if e.consecutiveFailureEnabled { |
| 128 | + count, err := e.store.IncrementConsecutiveFailureCount(ctx, attempt.TenantID, attempt.DestinationID, attempt.AttemptID) |
| 129 | + if err != nil { |
| 130 | + return Evaluation{}, fmt.Errorf("failed to track consecutive failures: %w", err) |
| 131 | + } |
| 132 | + if level, crossed := e.thresholds.shouldAlert(count); crossed { |
| 133 | + eval.ConsecutiveFailure = &ConsecutiveFailureSignal{ |
| 134 | + Failures: count, |
| 135 | + Max: e.autoDisableFailureCount, |
| 136 | + Level: level, |
85 | 137 | } |
86 | 138 | } |
87 | 139 | } |
88 | 140 |
|
89 | | - return 0, false |
| 141 | + // Exhausted retries check (independent of consecutive failure thresholds). |
| 142 | + // Attempt is 1-indexed: with retryMaxLimit=10, attempt 11 is the final one. |
| 143 | + // Skip if retryMaxLimit=0 (retries disabled — no exhausted state to report) |
| 144 | + // or if the exhausted-retries signal is disabled. |
| 145 | + if e.exhaustedRetriesEnabled && e.retryMaxLimit > 0 && attempt.EligibleForRetry && attempt.Number > e.retryMaxLimit { |
| 146 | + eval.RetriesExhausted = true |
| 147 | + } |
| 148 | + |
| 149 | + return eval, nil |
90 | 150 | } |
0 commit comments