Phase 3: dashboards and alerting
Saved, shareable multi-panel dashboards (table/line/bar/single-stat panels via gridstack + uPlot, global + per-panel time range, JSON export/import) and threshold/absence alert rules with an ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery. - New /metadata component: Postgres control-plane store for dashboards, panels, notification targets, alert rules/state, and delivery log -- see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree family isn't a fit for this access pattern (needs real row-level locking and read-your-writes consistency). - api/internal/dashboards: dashboard/panel CRUD, pure -- panel query execution stays client-side, reusing the existing /query endpoint. - New /alerting service: rule/target CRUD, a ticker-driven evaluator (claim-then-evaluate concurrency control, transactional-outbox delivery, query errors and threshold zero-rows never coerced into a false transition) and webhook/Slack/PagerDuty delivery with retry/backoff. See docs/phase-3-alerting-design.md for the full state-machine design and the four correctness properties it implements. - web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts list/get/apply, seeding a future Terraform provider's JSON contract. - hack/alert-load-test: 500 rules against real ClickHouse data, real measured results in docs/phase-3-runbook.md. Five real bugs found by actually running this against a live stack (documented in the runbook, not just fixed silently): a latent Phase 2 bug where ClickHouse rejected the timestamp format used for earliest=/latest= queries; a "now" literal token injected into query text; a GridStack/uPlot layout-timing race; JS's Date.parse being too lenient to use as a timestamp-detection heuristic; a rule's "enabled" field silently defaulting to false when omitted; and the evaluator's claim-batch-size and worker-pool-concurrency defaulting to the same value, causing 500 concurrently-due rules to take 125s to cycle through instead of the configured 60s.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package evaluator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sentry/sentry/alerting/internal/queryclient"
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
)
|
||||
|
||||
// evaluateCondition implements fixes 3 and 4 from
|
||||
// /docs/phase-3-alerting-design.md: a threshold rule's query must
|
||||
// resolve to exactly one row, and zero (or more than one) rows is
|
||||
// returned as an error, never coerced to a value -- "nothing ran" and
|
||||
// "ran and found nothing" are different failure/success modes, and
|
||||
// conflating them can hide the more alarming case. This function never
|
||||
// itself decides "condition false" on an error; it returns an error, and
|
||||
// the caller (evaluator.go) routes that to rulestore.RecordError, never
|
||||
// to ComputeTransition.
|
||||
func evaluateCondition(rule rulestore.Rule, result *queryclient.Result) (conditionTrue bool, value *float64, err error) {
|
||||
switch rule.ConditionType {
|
||||
case rulestore.ConditionAbsence:
|
||||
// The window is whatever earliest=/latest= the rule's own query
|
||||
// already expresses -- no separate window field, per the design doc.
|
||||
return len(result.Rows) == 0, nil, nil
|
||||
|
||||
case rulestore.ConditionThreshold:
|
||||
if len(result.Rows) != 1 {
|
||||
return false, nil, fmt.Errorf("threshold rule query returned %d rows, want exactly 1", len(result.Rows))
|
||||
}
|
||||
row := result.Rows[0]
|
||||
if len(row) == 0 {
|
||||
return false, nil, fmt.Errorf("threshold rule query returned a row with no columns")
|
||||
}
|
||||
v, ok := toFloat64(row[0])
|
||||
if !ok {
|
||||
return false, nil, fmt.Errorf("threshold rule's first column value %v is not numeric", row[0])
|
||||
}
|
||||
if rule.Comparator == nil || rule.ThresholdValue == nil {
|
||||
return false, nil, fmt.Errorf("threshold rule is missing comparator or threshold_value")
|
||||
}
|
||||
return compare(v, *rule.Comparator, *rule.ThresholdValue), &v, nil
|
||||
|
||||
default:
|
||||
return false, nil, fmt.Errorf("unknown condition_type %q", rule.ConditionType)
|
||||
}
|
||||
}
|
||||
|
||||
func compare(value float64, comparator rulestore.Comparator, threshold float64) bool {
|
||||
switch comparator {
|
||||
case rulestore.Gt:
|
||||
return value > threshold
|
||||
case rulestore.Gte:
|
||||
return value >= threshold
|
||||
case rulestore.Lt:
|
||||
return value < threshold
|
||||
case rulestore.Lte:
|
||||
return value <= threshold
|
||||
case rulestore.Eq:
|
||||
return value == threshold
|
||||
case rulestore.Ne:
|
||||
return value != threshold
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 handles the one shape /query responses actually come back
|
||||
// as: Go's encoding/json always decodes JSON numbers into interface{}
|
||||
// as float64, regardless of whether ClickHouse serialized an integer or
|
||||
// a float -- there's no separate int64 case to handle.
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package evaluator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/alerting/internal/queryclient"
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
)
|
||||
|
||||
func thresholdRule(comparator rulestore.Comparator, threshold float64) rulestore.Rule {
|
||||
return rulestore.Rule{
|
||||
ConditionType: rulestore.ConditionThreshold,
|
||||
Comparator: &comparator,
|
||||
ThresholdValue: &threshold,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionThresholdTrue(t *testing.T) {
|
||||
rule := thresholdRule(rulestore.Gt, 100)
|
||||
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{150.0}}}
|
||||
|
||||
got, value, err := evaluateCondition(rule, result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("expected condition true for 150 > 100")
|
||||
}
|
||||
if value == nil || *value != 150.0 {
|
||||
t.Fatalf("expected value=150, got %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionThresholdFalse(t *testing.T) {
|
||||
rule := thresholdRule(rulestore.Gt, 100)
|
||||
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{50.0}}}
|
||||
|
||||
got, _, err := evaluateCondition(rule, result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected condition false for 50 > 100")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateConditionThresholdZeroRowsIsError pins down fix 4: zero
|
||||
// rows on a threshold rule must be an error, not silently coerced to 0
|
||||
// (which would make `count > 100` falsely report "fine" when actually
|
||||
// nothing ran).
|
||||
func TestEvaluateConditionThresholdZeroRowsIsError(t *testing.T) {
|
||||
rule := thresholdRule(rulestore.Gt, 100)
|
||||
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{}}
|
||||
|
||||
_, value, err := evaluateCondition(rule, result)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for zero rows on a threshold rule, got condition evaluated with value=%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionThresholdMultipleRowsIsError(t *testing.T) {
|
||||
rule := thresholdRule(rulestore.Gt, 100)
|
||||
result := &queryclient.Result{Columns: []string{"host", "count"}, Rows: [][]any{{"h1", 50.0}, {"h2", 200.0}}}
|
||||
|
||||
_, _, err := evaluateCondition(rule, result)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for multiple rows on a threshold rule (no per-group alerting, a named non-goal)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionThresholdNonNumericIsError(t *testing.T) {
|
||||
rule := thresholdRule(rulestore.Gt, 100)
|
||||
result := &queryclient.Result{Columns: []string{"host"}, Rows: [][]any{{"host-01"}}}
|
||||
|
||||
_, _, err := evaluateCondition(rule, result)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for a non-numeric first column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionAbsenceTrueWhenZeroRows(t *testing.T) {
|
||||
rule := rulestore.Rule{ConditionType: rulestore.ConditionAbsence}
|
||||
result := &queryclient.Result{Columns: []string{"message"}, Rows: [][]any{}}
|
||||
|
||||
got, value, err := evaluateCondition(rule, result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("expected absence condition true for zero rows")
|
||||
}
|
||||
if value != nil {
|
||||
t.Fatalf("expected nil value for an absence rule, got %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionAbsenceFalseWhenRowsPresent(t *testing.T) {
|
||||
rule := rulestore.Rule{ConditionType: rulestore.ConditionAbsence}
|
||||
result := &queryclient.Result{Columns: []string{"message"}, Rows: [][]any{{"something happened"}}}
|
||||
|
||||
got, _, err := evaluateCondition(rule, result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("expected absence condition false when rows are present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionUnknownTypeIsError(t *testing.T) {
|
||||
rule := rulestore.Rule{ConditionType: "bogus"}
|
||||
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{1.0}}}
|
||||
|
||||
_, _, err := evaluateCondition(rule, result)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for an unknown condition_type")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package evaluator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/alerting/internal/delivery"
|
||||
"github.com/sentry/sentry/alerting/internal/notifystore"
|
||||
"github.com/sentry/sentry/alerting/internal/queryclient"
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
)
|
||||
|
||||
// Evaluator is the ticker-driven scheduler -- a bounded worker pool, not
|
||||
// a workflow engine, per the design doc's explicit instruction. Each
|
||||
// tick claims up to claimBatchSize due rules (rulestore.ClaimDueRules,
|
||||
// fix 1's atomic claim) and evaluates them concurrently up to
|
||||
// workerPoolSize at a time. These are deliberately different numbers --
|
||||
// see config.EvaluatorConfig's doc comment for the real bug this fixes
|
||||
// (500 rules due at once, both capped at 20, took 125s to cycle through
|
||||
// instead of the configured 60s).
|
||||
type Evaluator struct {
|
||||
rules *rulestore.Store
|
||||
notifications *notifystore.Store
|
||||
queryClient *queryclient.Client
|
||||
queryTimeout time.Duration
|
||||
claimBatchSize int
|
||||
workerPoolSize int
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(rules *rulestore.Store, notifications *notifystore.Store, queryClient *queryclient.Client, queryTimeout time.Duration, claimBatchSize, workerPoolSize int, logger *slog.Logger) *Evaluator {
|
||||
return &Evaluator{
|
||||
rules: rules, notifications: notifications, queryClient: queryClient,
|
||||
queryTimeout: queryTimeout, claimBatchSize: claimBatchSize, workerPoolSize: workerPoolSize, logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run ticks every tickInterval until ctx is cancelled.
|
||||
func (e *Evaluator) Run(ctx context.Context, tickInterval time.Duration) error {
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
e.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Evaluator) tick(ctx context.Context) {
|
||||
claimed, err := e.rules.ClaimDueRules(ctx, e.claimBatchSize)
|
||||
if err != nil {
|
||||
e.logger.Error("claiming due rules", "error", err)
|
||||
return
|
||||
}
|
||||
if len(claimed) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, e.workerPoolSize)
|
||||
var wg sync.WaitGroup
|
||||
for _, rule := range claimed {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(rule rulestore.RuleWithState) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
e.evaluateOne(ctx, rule)
|
||||
}(rule)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (e *Evaluator) evaluateOne(ctx context.Context, rule rulestore.RuleWithState) {
|
||||
result, err := e.queryClient.Query(ctx, rule.Query, rule.QueryLanguage, e.queryTimeout)
|
||||
if err != nil {
|
||||
// A failed /query call is an evaluation error, never
|
||||
// "condition false" -- fix 3. Recording it here, not routing it
|
||||
// through ComputeTransition at all, is what makes that guarantee
|
||||
// hold structurally rather than by convention.
|
||||
e.recordError(ctx, rule.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
conditionTrue, value, evalErr := evaluateCondition(rule.Rule, result)
|
||||
if evalErr != nil {
|
||||
e.recordError(ctx, rule.ID, evalErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var renotify *time.Duration
|
||||
if rule.RenotifyIntervalMinutes != nil {
|
||||
d := time.Duration(*rule.RenotifyIntervalMinutes) * time.Minute
|
||||
renotify = &d
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
transition := ComputeTransition(TransitionInput{
|
||||
CurrentState: rule.State.State,
|
||||
ConditionTrueSince: rule.State.ConditionTrueSince,
|
||||
FiredAt: rule.State.FiredAt,
|
||||
LastNotifiedAt: rule.State.LastNotifiedAt,
|
||||
ForMinutes: time.Duration(rule.ForMinutes) * time.Minute,
|
||||
RenotifyInterval: renotify,
|
||||
Now: now,
|
||||
ConditionTrue: conditionTrue,
|
||||
})
|
||||
|
||||
next := rulestore.AlertState{
|
||||
State: transition.NextState,
|
||||
ConditionTrueSince: transition.NextConditionTrueSince,
|
||||
FiredAt: transition.NextFiredAt,
|
||||
LastNotifiedAt: transition.NextLastNotifiedAt,
|
||||
LastValue: value,
|
||||
}
|
||||
|
||||
notify := e.buildNotifyEvent(ctx, rule, transition, value, now)
|
||||
|
||||
if err := e.rules.ApplyTransition(ctx, rule.ID, next, notify); err != nil {
|
||||
e.logger.Error("applying alert state transition", "rule_id", rule.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildNotifyEvent resolves the notification target and renders the
|
||||
// payload for a firing/resolved transition. A lookup or template
|
||||
// failure here is logged and treated as "no notification this time" --
|
||||
// the state still transitions correctly (that's the more important
|
||||
// guarantee), it just means a misconfigured target/template silently
|
||||
// drops one notification rather than blocking the whole evaluation.
|
||||
func (e *Evaluator) buildNotifyEvent(ctx context.Context, rule rulestore.RuleWithState, transition TransitionResult, value *float64, now time.Time) *rulestore.NotifyEvent {
|
||||
if transition.Notify == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
target, err := e.notifications.Get(ctx, rule.NotificationTargetID)
|
||||
if err != nil {
|
||||
e.logger.Error("looking up notification target", "rule_id", rule.ID, "target_id", rule.NotificationTargetID, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
comparator := ""
|
||||
if rule.Comparator != nil {
|
||||
comparator = string(*rule.Comparator)
|
||||
}
|
||||
payload, err := delivery.BuildPayload(*target, delivery.Event{
|
||||
RuleID: rule.ID, RuleName: rule.Name, EventType: *transition.Notify,
|
||||
ConditionType: string(rule.ConditionType), Comparator: comparator,
|
||||
ThresholdValue: rule.ThresholdValue, Value: value, Timestamp: now,
|
||||
})
|
||||
if err != nil {
|
||||
e.logger.Error("building notification payload", "rule_id", rule.ID, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &rulestore.NotifyEvent{
|
||||
NotificationTargetID: rule.NotificationTargetID,
|
||||
EventType: *transition.Notify,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Evaluator) recordError(ctx context.Context, ruleID, msg string) {
|
||||
if err := e.rules.RecordError(ctx, ruleID, msg); err != nil {
|
||||
e.logger.Error("recording evaluation error", "rule_id", ruleID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package evaluator implements the ticker-driven rule scheduler and the
|
||||
// firing/resolved state machine described in
|
||||
// /docs/phase-3-alerting-design.md. transitions.go is deliberately pure
|
||||
// (no DB, no HTTP, no clock reads beyond the `Now` passed in) --
|
||||
// evaluator.go is the only caller, and it's the single place worth
|
||||
// exhaustive table-driven testing given how easy this state machine is
|
||||
// to get subtly wrong.
|
||||
package evaluator
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
)
|
||||
|
||||
// TransitionInput is everything ComputeTransition needs: the rule's
|
||||
// current persisted state plus this evaluation's outcome. Evaluation
|
||||
// *errors* never reach this function at all -- per fix 3 in the design
|
||||
// doc, an error is recorded separately (rulestore.RecordError) and never
|
||||
// treated as ConditionTrue: false.
|
||||
type TransitionInput struct {
|
||||
CurrentState rulestore.State
|
||||
ConditionTrueSince *time.Time
|
||||
FiredAt *time.Time
|
||||
LastNotifiedAt *time.Time
|
||||
ForMinutes time.Duration
|
||||
RenotifyInterval *time.Duration // nil = notify once per firing episode, never again
|
||||
Now time.Time
|
||||
ConditionTrue bool
|
||||
}
|
||||
|
||||
// TransitionResult is what to persist (via rulestore.ApplyTransition)
|
||||
// and, if Notify is non-nil, which event ("firing" or "resolved") to
|
||||
// enqueue in the same transaction.
|
||||
type TransitionResult struct {
|
||||
NextState rulestore.State
|
||||
NextConditionTrueSince *time.Time
|
||||
NextFiredAt *time.Time
|
||||
NextLastNotifiedAt *time.Time
|
||||
Notify *string
|
||||
}
|
||||
|
||||
// ComputeTransition implements the ok/pending/firing state machine.
|
||||
// ConditionTrueSince is always a wall-clock timestamp, never a
|
||||
// consecutive-evaluation counter -- this is what makes the for_minutes
|
||||
// debounce survive evaluator restarts correctly (a counter would
|
||||
// silently lose progress across downtime; wall-clock math resumes
|
||||
// exactly where it left off). Don't "simplify" this into a counter.
|
||||
func ComputeTransition(in TransitionInput) TransitionResult {
|
||||
if in.ConditionTrue {
|
||||
return computeConditionTrue(in)
|
||||
}
|
||||
return computeConditionFalse(in)
|
||||
}
|
||||
|
||||
func computeConditionTrue(in TransitionInput) TransitionResult {
|
||||
switch in.CurrentState {
|
||||
case rulestore.StatePending:
|
||||
since := in.ConditionTrueSince
|
||||
if since == nil {
|
||||
// Defensive: pending with no recorded since is inconsistent
|
||||
// persisted state (shouldn't happen -- ok->pending always sets
|
||||
// it) -- treat as if the condition just became true rather than
|
||||
// dereferencing a nil or panicking.
|
||||
now := in.Now
|
||||
since = &now
|
||||
}
|
||||
if in.Now.Sub(*since) >= in.ForMinutes {
|
||||
return fire(*since, in.Now)
|
||||
}
|
||||
return TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: since}
|
||||
|
||||
case rulestore.StateFiring:
|
||||
result := TransitionResult{
|
||||
NextState: rulestore.StateFiring,
|
||||
NextConditionTrueSince: in.ConditionTrueSince,
|
||||
NextFiredAt: in.FiredAt,
|
||||
NextLastNotifiedAt: in.LastNotifiedAt,
|
||||
}
|
||||
if in.RenotifyInterval != nil && in.LastNotifiedAt != nil && in.Now.Sub(*in.LastNotifiedAt) >= *in.RenotifyInterval {
|
||||
now := in.Now
|
||||
event := "firing"
|
||||
result.NextLastNotifiedAt = &now
|
||||
result.Notify = &event
|
||||
}
|
||||
return result
|
||||
|
||||
default: // ok (or any unexpected value -- the DB CHECK constraint keeps this to 3 values)
|
||||
since := in.Now
|
||||
if in.ForMinutes <= 0 {
|
||||
// for_minutes=0 means "fire on first true evaluation" --
|
||||
// literally, not after one extra tick spent in `pending`.
|
||||
return fire(since, in.Now)
|
||||
}
|
||||
return TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: &since}
|
||||
}
|
||||
}
|
||||
|
||||
func computeConditionFalse(in TransitionInput) TransitionResult {
|
||||
switch in.CurrentState {
|
||||
case rulestore.StateFiring:
|
||||
event := "resolved"
|
||||
return TransitionResult{NextState: rulestore.StateOK, Notify: &event}
|
||||
default: // ok or pending: a false evaluation while pending is a blip
|
||||
// inside the debounce window -- deliberately no notification, that's
|
||||
// the entire point of for_minutes existing.
|
||||
return TransitionResult{NextState: rulestore.StateOK}
|
||||
}
|
||||
}
|
||||
|
||||
func fire(since, now time.Time) TransitionResult {
|
||||
firedAt := now
|
||||
event := "firing"
|
||||
return TransitionResult{
|
||||
NextState: rulestore.StateFiring,
|
||||
NextConditionTrueSince: &since,
|
||||
NextFiredAt: &firedAt,
|
||||
NextLastNotifiedAt: &firedAt,
|
||||
Notify: &event,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package evaluator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestComputeTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in TransitionInput
|
||||
want TransitionResult
|
||||
}{
|
||||
{
|
||||
name: "ok, condition true, for_minutes>0 -> pending, no notify",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateOK, ForMinutes: 5 * time.Minute,
|
||||
Now: t0, ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: ptr(t0)},
|
||||
},
|
||||
{
|
||||
name: "ok, condition true, for_minutes=0 -> fires immediately, no extra tick in pending",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateOK, ForMinutes: 0,
|
||||
Now: t0, ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: ptr("firing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending, still within for_minutes window -> stays pending, no notify",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
|
||||
ForMinutes: 5 * time.Minute, Now: t0.Add(2 * time.Minute), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: ptr(t0)},
|
||||
},
|
||||
{
|
||||
name: "pending, exactly at for_minutes boundary -> fires (>=, not >)",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
|
||||
ForMinutes: 5 * time.Minute, Now: t0.Add(5 * time.Minute), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
NextFiredAt: ptr(t0.Add(5 * time.Minute)), NextLastNotifiedAt: ptr(t0.Add(5 * time.Minute)), Notify: ptr("firing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending, past for_minutes -> fires",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
|
||||
ForMinutes: 5 * time.Minute, Now: t0.Add(10 * time.Minute), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
NextFiredAt: ptr(t0.Add(10 * time.Minute)), NextLastNotifiedAt: ptr(t0.Add(10 * time.Minute)), Notify: ptr("firing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firing, condition still true, no renotify interval -> stays firing, silent, preserves fired_at/last_notified",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
|
||||
RenotifyInterval: nil, Now: t0.Add(time.Hour), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firing, condition still true, renotify interval not yet elapsed -> stays firing, silent",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
|
||||
RenotifyInterval: ptr(4 * time.Hour), Now: t0.Add(time.Hour), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firing, condition still true, renotify interval elapsed -> re-fires, updates last_notified only",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
|
||||
RenotifyInterval: ptr(4 * time.Hour), Now: t0.Add(5 * time.Hour), ConditionTrue: true,
|
||||
},
|
||||
want: TransitionResult{
|
||||
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
|
||||
// fired_at (the episode start) is NOT bumped on a renotify -- only last_notified_at moves.
|
||||
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0.Add(5 * time.Hour)), Notify: ptr("firing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok, condition false -> stays ok, no-op",
|
||||
in: TransitionInput{CurrentState: rulestore.StateOK, Now: t0, ConditionTrue: false},
|
||||
want: TransitionResult{NextState: rulestore.StateOK},
|
||||
},
|
||||
{
|
||||
name: "pending, condition false -> back to ok, NO notification (a blip inside the debounce window)",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
|
||||
Now: t0.Add(time.Minute), ConditionTrue: false,
|
||||
},
|
||||
want: TransitionResult{NextState: rulestore.StateOK},
|
||||
},
|
||||
{
|
||||
name: "firing, condition false -> resolves, sends resolved notification",
|
||||
in: TransitionInput{
|
||||
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
|
||||
Now: t0.Add(time.Hour), ConditionTrue: false,
|
||||
},
|
||||
want: TransitionResult{NextState: rulestore.StateOK, Notify: ptr("resolved")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ComputeTransition(tt.in)
|
||||
if got.NextState != tt.want.NextState {
|
||||
t.Errorf("NextState = %v, want %v", got.NextState, tt.want.NextState)
|
||||
}
|
||||
if !timePtrEqual(got.NextConditionTrueSince, tt.want.NextConditionTrueSince) {
|
||||
t.Errorf("NextConditionTrueSince = %v, want %v", got.NextConditionTrueSince, tt.want.NextConditionTrueSince)
|
||||
}
|
||||
if !timePtrEqual(got.NextFiredAt, tt.want.NextFiredAt) {
|
||||
t.Errorf("NextFiredAt = %v, want %v", got.NextFiredAt, tt.want.NextFiredAt)
|
||||
}
|
||||
if !timePtrEqual(got.NextLastNotifiedAt, tt.want.NextLastNotifiedAt) {
|
||||
t.Errorf("NextLastNotifiedAt = %v, want %v", got.NextLastNotifiedAt, tt.want.NextLastNotifiedAt)
|
||||
}
|
||||
if !strPtrEqual(got.Notify, tt.want.Notify) {
|
||||
t.Errorf("Notify = %v, want %v", strPtrDeref(got.Notify), strPtrDeref(tt.want.Notify))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingConditionTrueSinceIsWallClockNotCounter pins down the
|
||||
// specific property the design doc calls out as easy to accidentally
|
||||
// "simplify" away: debounce progress survives an evaluator gap (e.g.
|
||||
// downtime) because condition_true_since is a timestamp, not a tick
|
||||
// count. Two evaluations three hours apart, both condition=true, with a
|
||||
// for_minutes=5m debounce -- the second evaluation must fire immediately
|
||||
// because wall-clock time already satisfies the debounce, regardless of
|
||||
// how many (or how few) evaluations happened in between.
|
||||
func TestPendingConditionTrueSinceIsWallClockNotCounter(t *testing.T) {
|
||||
first := ComputeTransition(TransitionInput{
|
||||
CurrentState: rulestore.StateOK, ForMinutes: 5 * time.Minute, Now: t0, ConditionTrue: true,
|
||||
})
|
||||
if first.NextState != rulestore.StatePending {
|
||||
t.Fatalf("first eval: state = %v, want pending", first.NextState)
|
||||
}
|
||||
|
||||
// Simulate a large gap (evaluator downtime) before the next evaluation.
|
||||
second := ComputeTransition(TransitionInput{
|
||||
CurrentState: rulestore.StatePending, ConditionTrueSince: first.NextConditionTrueSince,
|
||||
ForMinutes: 5 * time.Minute, Now: t0.Add(3 * time.Hour), ConditionTrue: true,
|
||||
})
|
||||
if second.NextState != rulestore.StateFiring {
|
||||
t.Fatalf("second eval after gap: state = %v, want firing (wall-clock debounce should already be satisfied)", second.NextState)
|
||||
}
|
||||
}
|
||||
|
||||
func timePtrEqual(a, b *time.Time) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return a.Equal(*b)
|
||||
}
|
||||
|
||||
func strPtrEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func strPtrDeref(s *string) string {
|
||||
if s == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
Reference in New Issue
Block a user