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:
2026-08-13 17:29:38 -07:00
parent fb5049a747
commit 9435115ab7
88 changed files with 7463 additions and 298 deletions
+74
View File
@@ -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")
}
}
+170
View File
@@ -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)
}
}
+121
View File
@@ -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
}