Alerting: Fix panic in backtesting API when the testing interval is not times of evaluation interval (#68727)

* add test for the bug
* update backtesting evaluators to accept a number of evaluations instead of `to` to have control over the number evaluations in one place
This commit is contained in:
Yuri Tseretyan 2023-07-06 11:21:03 -04:00 committed by GitHub
parent d88046d3d4
commit 30fc075cd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 69 additions and 37 deletions

View File

@ -25,10 +25,10 @@ var (
backtestingEvaluatorFactory = newBacktestingEvaluator backtestingEvaluatorFactory = newBacktestingEvaluator
) )
type callbackFunc = func(now time.Time, results eval.Results) error type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error
type backtestingEvaluator interface { type backtestingEvaluator interface {
Eval(ctx context.Context, from, to time.Time, interval time.Duration, callback callbackFunc) error Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error
} }
type stateManager interface { type stateManager interface {
@ -84,8 +84,11 @@ func (e *Engine) Test(ctx context.Context, user *user.SignedInUser, rule *models
tsField := data.NewField("Time", nil, make([]time.Time, length)) tsField := data.NewField("Time", nil, make([]time.Time, length))
valueFields := make(map[string]*data.Field) valueFields := make(map[string]*data.Field)
err = evaluator.Eval(ruleCtx, from, to, time.Duration(rule.IntervalSeconds)*time.Second, func(currentTime time.Time, results eval.Results) error { err = evaluator.Eval(ruleCtx, from, time.Duration(rule.IntervalSeconds)*time.Second, length, func(idx int, currentTime time.Time, results eval.Results) error {
idx := int(currentTime.Sub(from).Seconds()) / int(rule.IntervalSeconds) if idx >= length {
logger.Info("Unexpected evaluation. Skipping", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluationTime", currentTime, "evaluationIndex", idx, "expectedEvaluations", length)
return nil
}
states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil) states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil)
tsField.Set(idx, currentTime) tsField.Set(idx, currentTime)
for _, s := range states { for _, s := range states {
@ -110,7 +113,7 @@ func (e *Engine) Test(ctx context.Context, user *user.SignedInUser, rule *models
for _, f := range valueFields { for _, f := range valueFields {
fields = append(fields, f) fields = append(fields, f)
} }
result := data.NewFrame("Backtesting results", fields...) result := data.NewFrame("Testing results", fields...)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -263,6 +263,36 @@ func TestEvaluatorTest(t *testing.T) {
}) })
}) })
t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) {
from := time.Unix(0, 0)
to := from.Add(5 * ruleInterval)
states := []state.StateTransition{
{
State: &state.State{
CacheID: "state-1",
Labels: models.GenerateAlertLabels(rand.Intn(5)+1, "test-"),
State: eval.Normal,
StateReason: util.GenerateShortUID(),
},
},
}
manager.stateCallback = func(now time.Time) []state.StateTransition {
return states
}
frame, err := engine.Test(context.Background(), nil, rule, from, to)
require.NoError(t, err)
expectedLen := frame.Rows()
for i := 0; i < 100; i++ {
jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond
frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter))
require.NoError(t, err)
require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter)
}
})
t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) { t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) {
from := time.Unix(0, 0) from := time.Unix(0, 0)
@ -359,18 +389,16 @@ type fakeBacktestingEvaluator struct {
evalCallback func(now time.Time) (eval.Results, error) evalCallback func(now time.Time) (eval.Results, error)
} }
func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from, to time.Time, interval time.Duration, callback callbackFunc) error { func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error {
idx := 0 for idx, now := 0, from; idx < evaluations; idx, now = idx+1, now.Add(interval) {
for now := from; now.Before(to); now = now.Add(interval) {
results, err := f.evalCallback(now) results, err := f.evalCallback(now)
if err != nil { if err != nil {
return err return err
} }
err = callback(now, results) err = callback(idx, now, results)
if err != nil { if err != nil {
return err return err
} }
idx++
} }
return nil return nil
} }

View File

@ -37,10 +37,9 @@ func newDataEvaluator(refID string, frame *data.Frame) (*dataEvaluator, error) {
}, nil }, nil
} }
func (d *dataEvaluator) Eval(_ context.Context, from, to time.Time, interval time.Duration, callback callbackFunc) error { func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error {
var resampled = make([]mathexp.Series, 0, len(d.data)) var resampled = make([]mathexp.Series, 0, len(d.data))
to := from.Add(time.Duration(evaluations) * interval)
iterations := 0
for _, s := range d.data { for _, s := range d.data {
// making sure the input data frame is aligned with the interval // making sure the input data frame is aligned with the interval
r, err := s.Resample(d.refID, interval, d.downsampleFunction, d.upsampleFunction, from, to.Add(-interval)) // we want to query [from,to) r, err := s.Resample(d.refID, interval, d.downsampleFunction, d.upsampleFunction, from, to.Add(-interval)) // we want to query [from,to)
@ -48,10 +47,9 @@ func (d *dataEvaluator) Eval(_ context.Context, from, to time.Time, interval tim
return err return err
} }
resampled = append(resampled, r) resampled = append(resampled, r)
iterations = r.Len()
} }
for i := 0; i < iterations; i++ { for i := 0; i < evaluations; i++ {
result := make([]eval.Result, 0, len(resampled)) result := make([]eval.Result, 0, len(resampled))
var now time.Time var now time.Time
for _, series := range resampled { for _, series := range resampled {
@ -87,7 +85,7 @@ func (d *dataEvaluator) Eval(_ context.Context, from, to time.Time, interval tim
EvaluatedAt: now, EvaluatedAt: now,
}) })
} }
err := callback(now, result) err := callback(i, now, result)
if err != nil { if err != nil {
return err return err
} }

View File

@ -96,11 +96,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
t.Run("should use data points when frame resolution matches evaluation interval", func(t *testing.T) { t.Run("should use data points when frame resolution matches evaluation interval", func(t *testing.T) {
r := make([]results, 0, frame.Rows()) r := make([]results, 0, frame.Rows())
invterval := time.Second interval := time.Second
resultsCount := int(to.Sub(from).Seconds() / invterval.Seconds()) resultsCount := int(to.Sub(from).Seconds() / interval.Seconds())
err = evaluator.Eval(context.Background(), from, to, time.Second, func(now time.Time, res eval.Results) error { err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{ r = append(r, results{
now, res, now, res,
}) })
@ -164,7 +164,7 @@ func TestDataEvaluator_Eval(t *testing.T) {
size := to.Sub(from).Milliseconds() / interval.Milliseconds() size := to.Sub(from).Milliseconds() / interval.Milliseconds()
r := make([]results, 0, size) r := make([]results, 0, size)
err = evaluator.Eval(context.Background(), from, to, interval, func(now time.Time, res eval.Results) error { err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{ r = append(r, results{
now, res, now, res,
}) })
@ -195,7 +195,7 @@ func TestDataEvaluator_Eval(t *testing.T) {
size := int(to.Sub(from).Seconds() / interval.Seconds()) size := int(to.Sub(from).Seconds() / interval.Seconds())
r := make([]results, 0, size) r := make([]results, 0, size)
err = evaluator.Eval(context.Background(), from, to, interval, func(now time.Time, res eval.Results) error { err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{ r = append(r, results{
now, res, now, res,
}) })
@ -230,7 +230,7 @@ func TestDataEvaluator_Eval(t *testing.T) {
t.Run("should be noData until the frame interval", func(t *testing.T) { t.Run("should be noData until the frame interval", func(t *testing.T) {
newFrom := from.Add(-10 * time.Second) newFrom := from.Add(-10 * time.Second)
r := make([]results, 0, int(to.Sub(newFrom).Seconds())) r := make([]results, 0, int(to.Sub(newFrom).Seconds()))
err = evaluator.Eval(context.Background(), newFrom, to, time.Second, func(now time.Time, res eval.Results) error { err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{ r = append(r, results{
now, res, now, res,
}) })
@ -258,7 +258,7 @@ func TestDataEvaluator_Eval(t *testing.T) {
t.Run("should be the last value after the frame interval", func(t *testing.T) { t.Run("should be the last value after the frame interval", func(t *testing.T) {
newTo := to.Add(10 * time.Second) newTo := to.Add(10 * time.Second)
r := make([]results, 0, int(newTo.Sub(from).Seconds())) r := make([]results, 0, int(newTo.Sub(from).Seconds()))
err = evaluator.Eval(context.Background(), from, newTo, time.Second, func(now time.Time, res eval.Results) error { err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{ r = append(r, results{
now, res, now, res,
}) })
@ -282,12 +282,10 @@ func TestDataEvaluator_Eval(t *testing.T) {
}) })
t.Run("should stop if callback error", func(t *testing.T) { t.Run("should stop if callback error", func(t *testing.T) {
expectedError := errors.New("error") expectedError := errors.New("error")
evals := 0 err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error {
err = evaluator.Eval(context.Background(), from, to, time.Second, func(now time.Time, res eval.Results) error { if idx == 5 {
if evals > 5 {
return expectedError return expectedError
} }
evals++
return nil return nil
}) })
require.ErrorIs(t, err, expectedError) require.ErrorIs(t, err, expectedError)

View File

@ -12,13 +12,13 @@ type queryEvaluator struct {
eval eval.ConditionEvaluator eval eval.ConditionEvaluator
} }
func (d *queryEvaluator) Eval(ctx context.Context, from, to time.Time, interval time.Duration, callback callbackFunc) error { func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error {
for now := from; now.Before(to); now = now.Add(interval) { for idx, now := 0, from; idx < evaluations; idx, now = idx+1, now.Add(interval) {
results, err := d.eval.Evaluate(ctx, now) results, err := d.eval.Evaluate(ctx, now)
if err != nil { if err != nil {
return err return err
} }
err = callback(now, results) err = callback(idx, now, results)
if err != nil { if err != nil {
return err return err
} }

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -18,8 +19,7 @@ func TestQueryEvaluator_Eval(t *testing.T) {
ctx := context.Background() ctx := context.Background()
interval := time.Duration(rand.Int63n(9)+1) * time.Second interval := time.Duration(rand.Int63n(9)+1) * time.Second
times := rand.Intn(11) + 5 times := rand.Intn(11) + 5
to := time.Now() from := time.Now().Add(-time.Duration(times) * interval)
from := to.Add(-time.Duration(times) * interval)
t.Run("should evaluate query", func(t *testing.T) { t.Run("should evaluate query", func(t *testing.T) {
m := &eval_mocks.ConditionEvaluatorMock{} m := &eval_mocks.ConditionEvaluatorMock{}
@ -29,15 +29,20 @@ func TestQueryEvaluator_Eval(t *testing.T) {
eval: m, eval: m,
} }
intervals := make([]time.Time, 0, times) intervals := make([]time.Time, times)
err := evaluator.Eval(ctx, from, to, interval, func(now time.Time, results eval.Results) error { err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
intervals = append(intervals, now) intervals[idx] = now
return nil return nil
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, intervals, times) require.Len(t, intervals, times)
expected := from
for idx, actual := range intervals {
assert.Equalf(t, expected, actual, "item at index %d is not times of interval %v", idx, interval)
expected = expected.Add(interval)
}
m.AssertNumberOfCalls(t, "Evaluate", times) m.AssertNumberOfCalls(t, "Evaluate", times)
for _, now := range intervals { for _, now := range intervals {
m.AssertCalled(t, "Evaluate", ctx, now) m.AssertCalled(t, "Evaluate", ctx, now)
@ -57,7 +62,7 @@ func TestQueryEvaluator_Eval(t *testing.T) {
intervals := make([]time.Time, 0, times) intervals := make([]time.Time, 0, times)
err := evaluator.Eval(ctx, from, to, interval, func(now time.Time, results eval.Results) error { err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
intervals = append(intervals, now) intervals = append(intervals, now)
return nil return nil
}) })
@ -76,7 +81,7 @@ func TestQueryEvaluator_Eval(t *testing.T) {
intervals := make([]time.Time, 0, times) intervals := make([]time.Time, 0, times)
err := evaluator.Eval(ctx, from, to, interval, func(now time.Time, results eval.Results) error { err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
if len(intervals) > 3 { if len(intervals) > 3 {
return expectedError return expectedError
} }