Skip to content

Commit 296121e

Browse files
alexluongclaude
andcommitted
refactor(alert,logmq): alert as pure tracker; logmq pipeline owns the effects
The alert package shrinks to a tracker: Monitor and Notifier are deleted; Evaluator evaluates one attempt against the destination's failure history and returns the verdict as data (Evaluation). NewEvaluator takes an AlertStore; deployment scoping lives in NewRedisAlertStore. The logmq batch loop now owns acting on the verdict, per entry and still serial: auto-disable, operator-event construction and delivery, and replay dedup. The per-attempt replay gate (split-phase idempotence) replaces the bespoke cfeval "evaluated" set — a replay of a fully processed failed attempt is skipped instead of re-counting or re-alerting, and the gate survives a success reset. The exhausted-retries suppression window moves behind the SuppressionWindow interface, wired per event at plan time. Processing order and observable behavior are unchanged; the characterization suite's ordered assertions run as-is. New tests pin the gate semantics (stale replay after reset) and the suppression window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 22eac8c commit 296121e

16 files changed

Lines changed: 1258 additions & 1487 deletions

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@ require (
196196
github.com/spf13/afero v1.15.0 // indirect
197197
github.com/spf13/cast v1.10.0 // indirect
198198
github.com/spf13/pflag v1.0.10 // indirect
199-
github.com/stretchr/objx v0.5.3 // indirect
200199
github.com/subosito/gotenv v1.6.0 // indirect
201200
github.com/tklauser/go-sysconf v0.4.0 // indirect
202201
github.com/tklauser/numcpus v0.12.0 // indirect

internal/alert/evaluator.go

Lines changed: 121 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,150 @@
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.
16
package alert
27

38
import (
4-
"sort"
9+
"context"
10+
"fmt"
511
)
612

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
1122
}
1223

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
1635
}
1736

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)
2142
}
2243

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)
2746

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
4052
}
53+
}
4154

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+
}
4361

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
4868
}
69+
}
4970

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
5676
}
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+
}
5792

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,
61101
}
102+
103+
for _, opt := range opts {
104+
opt(e)
105+
}
106+
107+
e.thresholds = newThresholdEvaluator(e.alertThresholds, e.autoDisableFailureCount)
108+
109+
return e
62110
}
63111

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
68123
}
69124

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,
85137
}
86138
}
87139
}
88140

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
90150
}

0 commit comments

Comments
 (0)