chore: added cumulative window support (#8828)

* feat(multi-threshold): added multi threshold

* Update pkg/types/ruletypes/api_params.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(multiple-threshold): added multiple thresholds

* Update pkg/types/ruletypes/alerting.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(multiple-threshold): added multiple thresholds

* feat(cumulative-window): added cumulative window

* feat(multi-threshold): added recovery min points

* Update pkg/query-service/rules/threshold_rule.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(multi-threshold): fixed log lines

* feat(multi-threshold): added severity as threshold name

* feat(cumulative-window): added cumulative window for alerts v2

* feat(multi-threshold): removed break to send multi threshold alerts

* feat(multi-threshold): removed break to send multi threshold alerts

* feat(cumulative-window): segregated json marshalling with evaluation logic

* feat(multi-threshold): corrected the test cases

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added segregation on json marshalling and actual threhsold logic

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threhsold): added error wrapper

* feat(multi-threhsold): added error wrapper

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threhsold): added error wrapper

* Update pkg/types/ruletypes/threshold.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(cumulative-window): segregated json marshalling and evaluation logic

* feat(multi-threshold): added validation and error propagation

* feat(multi-notification): removed pre defined labels from links of log and traces

* feat(multi-notification): removed pre defined labels from links of log and traces

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added json parser for gettable rule

* feat(multi-threshold): added umnarshaller for postable rule

* feat(multi-threshold): added umnarshaller for postable rule

* feat(cumulative-window): added validation check

* Update pkg/types/ruletypes/evaluation.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(multi-threhsold): removed yaml support for alerts

* Update pkg/types/ruletypes/evaluation.go

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* Update pkg/types/ruletypes/evaluation.go

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* chore(cumulative-window): renamed funcitons

* chore(cumulative-window): removed naked errors

* chore(cumulative-window): added reset boundary condition tests

* chore(cumulative-window): added reset boundary condition tests

* chore(cumulative-window): sorted imports

* chore(cumulative-window): sorted imports

* chore(cumulative-window): sorted imports

* chore(cumulative-window): removed error from next window for

* chore(cumulative-window): removed error from next window for

* chore(cumulative-window): added case for timezone

* chore(cumulative-window): added validation for eval window

* chore(cumulative-window): updated api structure for cumulative window

* chore(cumulative-window): updated schedule enum

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
This commit is contained in:
aniketio-ctrl 2025-09-15 15:00:12 +05:30 committed by GitHub
parent c982b1e76d
commit ac81eab7bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1311 additions and 98 deletions

View File

@ -166,16 +166,9 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(), ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
) )
start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli() st, en := r.Timestamps(ts)
end := ts.UnixMilli() start := st.UnixMilli()
end := en.UnixMilli()
if r.EvalDelay() > 0 {
start = start - int64(r.EvalDelay().Milliseconds())
end = end - int64(r.EvalDelay().Milliseconds())
}
// round to minute otherwise we could potentially miss data
start = start - (start % (60 * 1000))
end = end - (end % (60 * 1000))
compositeQuery := r.Condition().CompositeQuery compositeQuery := r.Condition().CompositeQuery

View File

@ -3,8 +3,10 @@ package rules
import ( import (
"context" "context"
"fmt" "fmt"
"time" "time"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model" basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules" baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels" "github.com/SigNoz/signoz/pkg/query-service/utils/labels"
@ -20,6 +22,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
var task baserules.Task var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName) ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule // create a threshold rule
tr, err := baserules.NewThresholdRule( tr, err := baserules.NewThresholdRule(
@ -40,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr) rules = append(rules, tr)
// create ch rule task for evalution // create ch rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm { } else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@ -62,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr) rules = append(rules, pr)
// create promql rule task for evalution // create promql rule task for evalution
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly { } else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule // create anomaly rule
@ -84,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar) rules = append(rules, ar)
// create anomaly rule task for evalution // create anomaly rule task for evalution
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else { } else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)

View File

@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/converter" "github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/query-service/model"
@ -87,6 +88,8 @@ type BaseRule struct {
TemporalityMap map[string]map[v3.Temporality]bool TemporalityMap map[string]map[v3.Temporality]bool
sqlstore sqlstore.SQLStore sqlstore sqlstore.SQLStore
evaluation ruletypes.Evaluation
} }
type RuleOption func(*BaseRule) type RuleOption func(*BaseRule)
@ -129,6 +132,10 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
if err != nil { if err != nil {
return nil, err return nil, err
} }
evaluation, err := p.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
}
baseRule := &BaseRule{ baseRule := &BaseRule{
id: id, id: id,
@ -146,6 +153,7 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
reader: reader, reader: reader,
TemporalityMap: make(map[string]map[v3.Temporality]bool), TemporalityMap: make(map[string]map[v3.Temporality]bool),
Threshold: threshold, Threshold: threshold,
evaluation: evaluation,
} }
if baseRule.evalWindow == 0 { if baseRule.evalWindow == 0 {
@ -248,8 +256,10 @@ func (r *BaseRule) Unit() string {
} }
func (r *BaseRule) Timestamps(ts time.Time) (time.Time, time.Time) { func (r *BaseRule) Timestamps(ts time.Time) (time.Time, time.Time) {
start := ts.Add(-time.Duration(r.evalWindow)).UnixMilli()
end := ts.UnixMilli() st, en := r.evaluation.NextWindowFor(ts)
start := st.UnixMilli()
end := en.UnixMilli()
if r.evalDelay > 0 { if r.evalDelay > 0 {
start = start - int64(r.evalDelay.Milliseconds()) start = start - int64(r.evalDelay.Milliseconds())

View File

@ -12,12 +12,11 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"errors"
"github.com/go-openapi/strfmt" "github.com/go-openapi/strfmt"
"github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/prometheus"
querierV5 "github.com/SigNoz/signoz/pkg/querier" querierV5 "github.com/SigNoz/signoz/pkg/querier"
@ -147,6 +146,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
var task Task var task Task
ruleId := RuleIdFromTaskName(opts.TaskName) ruleId := RuleIdFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule // create a threshold rule
tr, err := NewThresholdRule( tr, err := NewThresholdRule(
@ -167,7 +172,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, tr) rules = append(rules, tr)
// create ch rule task for evalution // create ch rule task for evalution
task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm { } else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@ -189,7 +194,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, pr) rules = append(rules, pr)
// create promql rule task for evalution // create promql rule task for evalution
task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else { } else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@ -400,7 +405,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
if err != nil { if err != nil {
zap.L().Error("loading tasks failed", zap.Error(err)) zap.L().Error("loading tasks failed", zap.Error(err))
return errors.New("error preparing rule with given parameters, previous rule set restored") return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
} }
for _, r := range newTask.Rules() { for _, r := range newTask.Rules() {
@ -593,7 +598,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
if err != nil { if err != nil {
zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err)) zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err))
return errors.New("error loading rules, previous rule set restored") return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
} }
for _, r := range newTask.Rules() { for _, r := range newTask.Rules() {

View File

@ -123,8 +123,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
prevState := r.State() prevState := r.State()
start := ts.Add(-r.evalWindow) start, end := r.Timestamps(ts)
end := ts
interval := 60 * time.Second // TODO(srikanthccv): this should be configurable interval := 60 * time.Second // TODO(srikanthccv): this should be configurable
valueFormatter := formatter.FromUnit(r.Unit()) valueFormatter := formatter.FromUnit(r.Unit())

View File

@ -25,11 +25,13 @@ func getVectorValues(vectors []ruletypes.Sample) []float64 {
func TestPromRuleShouldAlert(t *testing.T) { func TestPromRuleShouldAlert(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Test Rule", AlertName: "Test Rule",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm, RuleType: ruletypes.RuleTypeProm,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL, QueryType: v3.QueryTypePromQL,

View File

@ -31,11 +31,13 @@ import (
func TestThresholdRuleShouldAlert(t *testing.T) { func TestThresholdRuleShouldAlert(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -886,11 +888,13 @@ func TestNormalizeLabelName(t *testing.T) {
func TestPrepareLinksToLogs(t *testing.T) { func TestPrepareLinksToLogs(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeLogs, AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -938,11 +942,13 @@ func TestPrepareLinksToLogs(t *testing.T) {
func TestPrepareLinksToLogsV5(t *testing.T) { func TestPrepareLinksToLogsV5(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeLogs, AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -997,11 +1003,13 @@ func TestPrepareLinksToLogsV5(t *testing.T) {
func TestPrepareLinksToTracesV5(t *testing.T) { func TestPrepareLinksToTracesV5(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeTraces, AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1056,11 +1064,13 @@ func TestPrepareLinksToTracesV5(t *testing.T) {
func TestPrepareLinksToTraces(t *testing.T) { func TestPrepareLinksToTraces(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Links to traces test", AlertName: "Links to traces test",
AlertType: ruletypes.AlertTypeTraces, AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1108,11 +1118,13 @@ func TestPrepareLinksToTraces(t *testing.T) {
func TestThresholdRuleLabelNormalization(t *testing.T) { func TestThresholdRuleLabelNormalization(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1214,11 +1226,13 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
func TestThresholdRuleEvalDelay(t *testing.T) { func TestThresholdRuleEvalDelay(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Test Eval Delay", AlertName: "Test Eval Delay",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeClickHouseSQL, QueryType: v3.QueryTypeClickHouseSQL,
@ -1275,11 +1289,13 @@ func TestThresholdRuleEvalDelay(t *testing.T) {
func TestThresholdRuleClickHouseTmpl(t *testing.T) { func TestThresholdRuleClickHouseTmpl(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests", AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeClickHouseSQL, QueryType: v3.QueryTypeClickHouseSQL,
@ -1342,11 +1358,13 @@ func (m *queryMatcherAny) Match(x string, y string) error {
func TestThresholdRuleUnitCombinations(t *testing.T) { func TestThresholdRuleUnitCombinations(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Units test", AlertName: "Units test",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1535,11 +1553,13 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
func TestThresholdRuleNoData(t *testing.T) { func TestThresholdRuleNoData(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "No data test", AlertName: "No data test",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1638,11 +1658,13 @@ func TestThresholdRuleNoData(t *testing.T) {
func TestThresholdRuleTracesLink(t *testing.T) { func TestThresholdRuleTracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Traces link test", AlertName: "Traces link test",
AlertType: ruletypes.AlertTypeTraces, AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1763,11 +1785,13 @@ func TestThresholdRuleTracesLink(t *testing.T) {
func TestThresholdRuleLogsLink(t *testing.T) { func TestThresholdRuleLogsLink(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Logs link test", AlertName: "Logs link test",
AlertType: ruletypes.AlertTypeLogs, AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,
@ -1901,11 +1925,13 @@ func TestThresholdRuleLogsLink(t *testing.T) {
func TestThresholdRuleShiftBy(t *testing.T) { func TestThresholdRuleShiftBy(t *testing.T) {
target := float64(10) target := float64(10)
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Logs link test", AlertName: "Logs link test",
AlertType: ruletypes.AlertTypeLogs, AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
Thresholds: &ruletypes.RuleThresholdData{ Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind, Kind: ruletypes.BasicThresholdKind,
@ -1973,11 +1999,13 @@ func TestThresholdRuleShiftBy(t *testing.T) {
func TestMultipleThresholdRule(t *testing.T) { func TestMultipleThresholdRule(t *testing.T) {
postableRule := ruletypes.PostableRule{ postableRule := ruletypes.PostableRule{
AlertName: "Mulitple threshold test", AlertName: "Mulitple threshold test",
AlertType: ruletypes.AlertTypeMetric, AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold, RuleType: ruletypes.RuleTypeThreshold,
EvalWindow: ruletypes.Duration(5 * time.Minute), Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
Frequency: ruletypes.Duration(1 * time.Minute), EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{ RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{ CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder, QueryType: v3.QueryTypeBuilder,

View File

@ -50,6 +50,8 @@ type PostableRule struct {
PreferredChannels []string `json:"preferredChannels,omitempty"` PreferredChannels []string `json:"preferredChannels,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
} }
func (r *PostableRule) processRuleDefaults() error { func (r *PostableRule) processRuleDefaults() error {
@ -98,6 +100,9 @@ func (r *PostableRule) processRuleDefaults() error {
r.RuleCondition.Thresholds = &thresholdData r.RuleCondition.Thresholds = &thresholdData
} }
} }
if r.Evaluation == nil {
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
}
return r.Validate() return r.Validate()
} }

View File

@ -0,0 +1,287 @@
package ruletypes
import (
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type EvaluationKind struct {
valuer.String
}
var (
RollingEvaluation = EvaluationKind{valuer.NewString("rolling")}
CumulativeEvaluation = EvaluationKind{valuer.NewString("cumulative")}
)
type Evaluation interface {
NextWindowFor(curr time.Time) (time.Time, time.Time)
GetFrequency() Duration
}
type RollingWindow struct {
EvalWindow Duration `json:"evalWindow"`
Frequency Duration `json:"frequency"`
}
func (rollingWindow RollingWindow) Validate() error {
if rollingWindow.EvalWindow <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow must be greater than zero")
}
if rollingWindow.Frequency <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "frequency must be greater than zero")
}
return nil
}
func (rollingWindow RollingWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) {
return curr.Add(time.Duration(-rollingWindow.EvalWindow)), curr
}
func (rollingWindow RollingWindow) GetFrequency() Duration {
return rollingWindow.Frequency
}
type CumulativeWindow struct {
Schedule CumulativeSchedule `json:"schedule"`
Frequency Duration `json:"frequency"`
Timezone string `json:"timezone"`
}
type CumulativeSchedule struct {
Type ScheduleType `json:"type"`
Minute *int `json:"minute,omitempty"` // 0-59, for all types
Hour *int `json:"hour,omitempty"` // 0-23, for daily/weekly/monthly
Day *int `json:"day,omitempty"` // 1-31, for monthly
Weekday *int `json:"weekday,omitempty"` // 0-6 (Sunday=0), for weekly
}
type ScheduleType struct {
valuer.String
}
var (
ScheduleTypeHourly = ScheduleType{valuer.NewString("hourly")}
ScheduleTypeDaily = ScheduleType{valuer.NewString("daily")}
ScheduleTypeWeekly = ScheduleType{valuer.NewString("weekly")}
ScheduleTypeMonthly = ScheduleType{valuer.NewString("monthly")}
)
func (cumulativeWindow CumulativeWindow) Validate() error {
// Validate schedule
if err := cumulativeWindow.Schedule.Validate(); err != nil {
return err
}
if _, err := time.LoadLocation(cumulativeWindow.Timezone); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is invalid")
}
if cumulativeWindow.Frequency <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "frequency must be greater than zero")
}
return nil
}
func (cs CumulativeSchedule) Validate() error {
switch cs.Type {
case ScheduleTypeHourly:
if cs.Minute == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be specified for hourly schedule")
}
if *cs.Minute < 0 || *cs.Minute > 59 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59")
}
case ScheduleTypeDaily:
if cs.Hour == nil || cs.Minute == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour and minute must be specified for daily schedule")
}
if *cs.Hour < 0 || *cs.Hour > 23 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23")
}
if *cs.Minute < 0 || *cs.Minute > 59 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59")
}
case ScheduleTypeWeekly:
if cs.Weekday == nil || cs.Hour == nil || cs.Minute == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "weekday, hour and minute must be specified for weekly schedule")
}
if *cs.Weekday < 0 || *cs.Weekday > 6 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "weekday must be between 0 and 6 (Sunday=0)")
}
if *cs.Hour < 0 || *cs.Hour > 23 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23")
}
if *cs.Minute < 0 || *cs.Minute > 59 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59")
}
case ScheduleTypeMonthly:
if cs.Day == nil || cs.Hour == nil || cs.Minute == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "day, hour and minute must be specified for monthly schedule")
}
if *cs.Day < 1 || *cs.Day > 31 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "day must be between 1 and 31")
}
if *cs.Hour < 0 || *cs.Hour > 23 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23")
}
if *cs.Minute < 0 || *cs.Minute > 59 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59")
}
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid schedule type")
}
return nil
}
func (cumulativeWindow CumulativeWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) {
loc := time.UTC
if cumulativeWindow.Timezone != "" {
if tz, err := time.LoadLocation(cumulativeWindow.Timezone); err == nil {
loc = tz
}
}
currInTZ := curr.In(loc)
windowStart := cumulativeWindow.getLastScheduleTime(currInTZ, loc)
return windowStart.In(time.UTC), currInTZ.In(time.UTC)
}
func (cw CumulativeWindow) getLastScheduleTime(curr time.Time, loc *time.Location) time.Time {
schedule := cw.Schedule
switch schedule.Type {
case ScheduleTypeHourly:
// Find the most recent hour boundary with the specified minute
minute := *schedule.Minute
candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), curr.Hour(), minute, 0, 0, loc)
if candidate.After(curr) {
candidate = candidate.Add(-time.Hour)
}
return candidate
case ScheduleTypeDaily:
// Find the most recent day boundary with the specified hour and minute
hour := *schedule.Hour
minute := *schedule.Minute
candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), hour, minute, 0, 0, loc)
if candidate.After(curr) {
candidate = candidate.AddDate(0, 0, -1)
}
return candidate
case ScheduleTypeWeekly:
weekday := time.Weekday(*schedule.Weekday)
hour := *schedule.Hour
minute := *schedule.Minute
// Calculate days to subtract to reach the target weekday
daysBack := int(curr.Weekday() - weekday)
if daysBack < 0 {
daysBack += 7
}
candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), hour, minute, 0, 0, loc).AddDate(0, 0, -daysBack)
if candidate.After(curr) {
candidate = candidate.AddDate(0, 0, -7)
}
return candidate
case ScheduleTypeMonthly:
// Find the most recent month boundary with the specified day, hour and minute
targetDay := *schedule.Day
hour := *schedule.Hour
minute := *schedule.Minute
// Try current month first
lastDayOfCurrentMonth := time.Date(curr.Year(), curr.Month()+1, 0, 0, 0, 0, 0, loc).Day()
dayInCurrentMonth := targetDay
if targetDay > lastDayOfCurrentMonth {
dayInCurrentMonth = lastDayOfCurrentMonth
}
candidate := time.Date(curr.Year(), curr.Month(), dayInCurrentMonth, hour, minute, 0, 0, loc)
if candidate.After(curr) {
prevMonth := curr.AddDate(0, -1, 0)
lastDayOfPrevMonth := time.Date(prevMonth.Year(), prevMonth.Month()+1, 0, 0, 0, 0, 0, loc).Day()
dayInPrevMonth := targetDay
if targetDay > lastDayOfPrevMonth {
dayInPrevMonth = lastDayOfPrevMonth
}
candidate = time.Date(prevMonth.Year(), prevMonth.Month(), dayInPrevMonth, hour, minute, 0, 0, loc)
}
return candidate
default:
return curr
}
}
func (cumulativeWindow CumulativeWindow) GetFrequency() Duration {
return cumulativeWindow.Frequency
}
type EvaluationEnvelope struct {
Kind EvaluationKind `json:"kind"`
Spec any `json:"spec"`
}
func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal evaluation: %v", err)
}
if err := json.Unmarshal(raw["kind"], &e.Kind); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal evaluation kind: %v", err)
}
switch e.Kind {
case RollingEvaluation:
var rollingWindow RollingWindow
if err := json.Unmarshal(raw["spec"], &rollingWindow); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal rolling window: %v", err)
}
err := rollingWindow.Validate()
if err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to validate rolling window: %v", err)
}
e.Spec = rollingWindow
case CumulativeEvaluation:
var cumulativeWindow CumulativeWindow
if err := json.Unmarshal(raw["spec"], &cumulativeWindow); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal cumulative window: %v", err)
}
err := cumulativeWindow.Validate()
if err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to validate cumulative window: %v", err)
}
e.Spec = cumulativeWindow
default:
return errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind")
}
return nil
}
func (e *EvaluationEnvelope) GetEvaluation() (Evaluation, error) {
if e.Kind.IsZero() {
e.Kind = RollingEvaluation
}
switch e.Kind {
case RollingEvaluation:
if rolling, ok := e.Spec.(RollingWindow); ok {
return rolling, nil
}
case CumulativeEvaluation:
if cumulative, ok := e.Spec.(CumulativeWindow); ok {
return cumulative, nil
}
default:
return nil, errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind")
}
return nil, errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind")
}

View File

@ -0,0 +1,878 @@
package ruletypes
import (
"encoding/json"
"testing"
"time"
)
func TestRollingWindow_EvaluationTime(t *testing.T) {
tests := []struct {
name string
evalWindow Duration
current time.Time
wantStart time.Time
wantEnd time.Time
}{
{
name: "5 minute rolling window",
evalWindow: Duration(5 * time.Minute),
current: time.Date(2023, 12, 1, 12, 30, 0, 0, time.UTC),
wantStart: time.Date(2023, 12, 1, 12, 25, 0, 0, time.UTC),
wantEnd: time.Date(2023, 12, 1, 12, 30, 0, 0, time.UTC),
},
{
name: "1 hour rolling window",
evalWindow: Duration(1 * time.Hour),
current: time.Date(2023, 12, 1, 15, 45, 30, 0, time.UTC),
wantStart: time.Date(2023, 12, 1, 14, 45, 30, 0, time.UTC),
wantEnd: time.Date(2023, 12, 1, 15, 45, 30, 0, time.UTC),
},
{
name: "30 second rolling window",
evalWindow: Duration(30 * time.Second),
current: time.Date(2023, 12, 1, 12, 30, 15, 0, time.UTC),
wantStart: time.Date(2023, 12, 1, 12, 29, 45, 0, time.UTC),
wantEnd: time.Date(2023, 12, 1, 12, 30, 15, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rw := &RollingWindow{
EvalWindow: tt.evalWindow,
Frequency: Duration(1 * time.Minute),
}
gotStart, gotEnd := rw.NextWindowFor(tt.current)
if !gotStart.Equal(tt.wantStart) {
t.Errorf("RollingWindow.NextWindowFor() start time = %v, want %v", gotStart, tt.wantStart)
}
if !gotEnd.Equal(tt.wantEnd) {
t.Errorf("RollingWindow.NextWindowFor() end time = %v, want %v", gotEnd, tt.wantEnd)
}
})
}
}
func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
tests := []struct {
name string
window CumulativeWindow
current time.Time
wantErr bool
}{
{
name: "hourly schedule - minute 15",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(15),
},
Frequency: Duration(5 * time.Minute),
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantErr: false,
},
{
name: "daily schedule - 9:30 AM IST",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(9),
Minute: intPtr(30),
},
Frequency: Duration(1 * time.Hour),
Timezone: "Asia/Kolkata",
},
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC),
wantErr: false,
},
{
name: "weekly schedule - Monday 2:00 PM",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(1), // Monday
Hour: intPtr(14),
Minute: intPtr(0),
},
Frequency: Duration(24 * time.Hour),
Timezone: "America/New_York",
},
current: time.Date(2025, 3, 18, 19, 0, 0, 0, time.UTC), // Tuesday
wantErr: false,
},
{
name: "monthly schedule - 1st at midnight",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(1),
Hour: intPtr(0),
Minute: intPtr(0),
},
Frequency: Duration(24 * time.Hour),
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
wantErr: false,
},
{
name: "invalid schedule - missing minute for hourly",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
},
Frequency: Duration(5 * time.Minute),
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test validation
err := tt.window.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("CumulativeWindow.Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Test NextWindowFor
start, end := tt.window.NextWindowFor(tt.current)
// Basic validation
if start.After(end) {
t.Errorf("Window start should not be after end: start=%v, end=%v", start, end)
}
if end.After(tt.current) {
t.Errorf("Window end should not be after current time: end=%v, current=%v", end, tt.current)
}
}
})
}
}
func intPtr(i int) *int {
return &i
}
func TestCumulativeWindow_NextWindowFor(t *testing.T) {
tests := []struct {
name string
window CumulativeWindow
current time.Time
wantStart time.Time
wantEnd time.Time
}{
// Hourly schedule tests
{
name: "hourly - current at exact minute",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
},
{
name: "hourly - current after scheduled minute",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(15),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC),
},
{
name: "hourly - current before scheduled minute",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 13, 30, 0, 0, time.UTC), // Previous hour
wantEnd: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
},
{
name: "hourly - current before scheduled minute",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 12, 30, 0, 0, time.UTC), // Previous hour
wantEnd: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
},
{
name: "hourly - current before scheduled minute",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "Asia/Kolkata",
},
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 13, 00, 0, 0, time.UTC), // Previous hour
wantEnd: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
},
// Daily schedule tests
{
name: "daily - current at exact time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
},
{
name: "daily - current after scheduled time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC),
},
{
name: "daily - current before scheduled time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 14, 9, 30, 0, 0, time.UTC), // Previous day
wantEnd: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC),
},
{
name: "daily - with timezone IST",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "Asia/Kolkata",
},
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), // 9:00 PM IST
wantStart: time.Date(2025, 3, 15, 4, 0, 0, 0, time.UTC), // 9:30 AM IST in UTC
wantEnd: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC),
},
// Weekly schedule tests
{
name: "weekly - current on scheduled day at exact time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(1), // Monday
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Monday
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC),
},
{
name: "weekly - current on different day",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(1), // Monday
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC), // Wednesday
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Previous Monday
wantEnd: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC),
},
{
name: "weekly - current before scheduled time on same day",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(2), // Tuesday
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC), // Tuesday before 2 PM
wantStart: time.Date(2025, 3, 11, 14, 0, 0, 0, time.UTC), // Previous Tuesday
wantEnd: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC),
},
// Monthly schedule tests
{
name: "monthly - current on scheduled day at exact time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(15),
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
},
{
name: "monthly - current after scheduled time",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(1),
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC),
},
{
name: "monthly - current before scheduled day",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(15),
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 2, 15, 12, 0, 0, 0, time.UTC), // Previous month
wantEnd: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC),
},
{
name: "monthly - day 31 in february (edge case)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(31),
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), // Feb 28 (last day of Feb)
wantEnd: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC),
},
// Comprehensive timezone-based test cases
{
name: "Asia/Tokyo timezone - hourly schedule",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(45),
},
Timezone: "Asia/Tokyo",
},
current: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC), // 11:30 AM JST
wantStart: time.Date(2023, 12, 15, 1, 45, 0, 0, time.UTC), // 10:45 AM JST in UTC
wantEnd: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC),
},
{
name: "America/New_York timezone - daily schedule (EST)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(8), // 8 AM EST
Minute: intPtr(0),
},
Timezone: "America/New_York",
},
current: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC), // 3:30 PM EST
wantStart: time.Date(2023, 12, 15, 13, 0, 0, 0, time.UTC), // 8 AM EST in UTC
wantEnd: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC),
},
{
name: "Europe/London timezone - weekly schedule (GMT)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(1), // Monday
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Europe/London",
},
current: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC), // Friday 3 PM GMT
wantStart: time.Date(2023, 12, 11, 12, 0, 0, 0, time.UTC), // Previous Monday 12 PM GMT
wantEnd: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC),
},
{
name: "Australia/Sydney timezone - monthly schedule (AEDT)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(1),
Hour: intPtr(0), // Midnight AEDT
Minute: intPtr(0),
},
Timezone: "Australia/Sydney",
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 4 PM AEDT on 15th
wantStart: time.Date(2023, 11, 30, 13, 0, 0, 0, time.UTC), // Midnight AEDT on Dec 1st in UTC (Nov 30 13:00 UTC)
wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC),
},
{
name: "Pacific/Honolulu timezone - hourly schedule (HST)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "Pacific/Honolulu",
},
current: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC), // 12:45 PM HST
wantStart: time.Date(2023, 12, 15, 22, 30, 0, 0, time.UTC), // 12:30 PM HST in UTC
wantEnd: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC),
},
{
name: "America/Los_Angeles timezone - DST transition daily",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(2), // 2 AM PST/PDT
Minute: intPtr(0),
},
Timezone: "America/Los_Angeles",
},
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day after DST starts
wantStart: time.Date(2023, 3, 12, 9, 0, 0, 0, time.UTC), // 2 AM PDT in UTC (PDT = UTC-7)
wantEnd: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC),
},
{
name: "Europe/Berlin timezone - weekly schedule (CET)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(5), // Friday
Hour: intPtr(16), // 4 PM CET
Minute: intPtr(30),
},
Timezone: "Europe/Berlin",
},
current: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC), // Monday 11 AM CET
wantStart: time.Date(2023, 12, 15, 15, 30, 0, 0, time.UTC), // Previous Friday 4:30 PM CET
wantEnd: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC),
},
{
name: "Asia/Kolkata timezone - monthly edge case (IST)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(31), // 31st (edge case for Feb)
Hour: intPtr(23),
Minute: intPtr(59),
},
Timezone: "Asia/Kolkata",
},
current: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC), // March 10th 5:30 PM IST
wantStart: time.Date(2023, 2, 28, 18, 29, 0, 0, time.UTC), // Feb 28 11:59 PM IST (last day of Feb)
wantEnd: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC),
},
{
name: "America/Chicago timezone - hourly across midnight (CST)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(0), // Top of hour
},
Timezone: "America/Chicago",
},
current: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC), // 12:30 AM CST
wantStart: time.Date(2023, 12, 15, 6, 0, 0, 0, time.UTC), // Midnight CST in UTC
wantEnd: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC),
},
// Boundary condition test cases
{
name: "boundary - end of year transition (Dec 31 to Jan 1)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), // Jan 1st noon
wantStart: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Jan 1st midnight
wantEnd: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
},
{
name: "boundary - leap year Feb 29th monthly schedule",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(29),
Hour: intPtr(15),
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (leap year)
wantStart: time.Date(2024, 2, 29, 15, 30, 0, 0, time.UTC), // Feb 29th exists in leap year
wantEnd: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC),
},
{
name: "boundary - non-leap year Feb 29th request (fallback to Feb 28th)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(29),
Hour: intPtr(15),
Minute: intPtr(30),
},
Timezone: "UTC",
},
current: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (non-leap year)
wantStart: time.Date(2023, 2, 28, 15, 30, 0, 0, time.UTC), // Feb 28th (fallback)
wantEnd: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC),
},
{
name: "boundary - day 31 in April (30-day month fallback)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(31),
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC), // May 15th
wantStart: time.Date(2023, 4, 30, 12, 0, 0, 0, time.UTC), // April 30th (fallback from 31st)
wantEnd: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC),
},
{
name: "boundary - weekly Sunday to Monday transition",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(0), // Sunday
Hour: intPtr(23),
Minute: intPtr(59),
},
Timezone: "UTC",
},
current: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC), // Monday 1 AM
wantStart: time.Date(2023, 12, 10, 23, 59, 0, 0, time.UTC), // Previous Sunday 11:59 PM
wantEnd: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC),
},
{
name: "boundary - hourly minute 59 to minute 0 transition",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(59),
},
Timezone: "UTC",
},
current: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC), // 14:05
wantStart: time.Date(2023, 12, 15, 13, 59, 0, 0, time.UTC), // 13:59 (previous hour)
wantEnd: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC),
},
{
name: "boundary - DST spring forward (2 AM doesn't exist)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(2), // 2 AM (skipped during DST)
Minute: intPtr(30),
},
Timezone: "America/New_York",
},
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day DST starts
wantStart: time.Date(2023, 3, 12, 6, 30, 0, 0, time.UTC), // Same day 2:30 AM EDT (adjusted for DST)
wantEnd: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC),
},
{
name: "boundary - DST fall back (2 AM occurs twice)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(2), // 2 AM (occurs twice)
Minute: intPtr(30),
},
Timezone: "America/New_York",
},
current: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC), // Day DST ends
wantStart: time.Date(2023, 11, 5, 7, 30, 0, 0, time.UTC), // Same day 2:30 AM EST (after fall back)
wantEnd: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC),
},
{
name: "boundary - month transition January to February",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeMonthly,
Day: intPtr(31),
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC), // February 15th
wantStart: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC), // January 31st (exists)
wantEnd: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC),
},
{
name: "boundary - extreme timezone offset (+14 hours)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Pacific/Kiritimati", // UTC+14
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 7 PM local time
wantStart: time.Date(2023, 12, 14, 22, 0, 0, 0, time.UTC), // 12 PM local time (previous day in UTC)
wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC),
},
{
name: "boundary - extreme timezone offset (-12 hours)",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeDaily,
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Etc/GMT+12", // UTC-12 (use standard timezone name)
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 5 PM previous day local time
wantStart: time.Date(2023, 12, 15, 0, 0, 0, 0, time.UTC), // 12 PM local time (same day in UTC)
wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC),
},
{
name: "boundary - week boundary Saturday to Sunday",
window: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeWeekly,
Weekday: intPtr(6), // Saturday
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
},
current: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC), // Sunday noon
wantStart: time.Date(2023, 12, 16, 0, 0, 0, 0, time.UTC), // Saturday midnight
wantEnd: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStart, gotEnd := tt.window.NextWindowFor(tt.current)
if !gotStart.Equal(tt.wantStart) {
t.Errorf("NextWindowFor() start = %v, want %v", gotStart, tt.wantStart)
}
if !gotEnd.Equal(tt.wantEnd) {
t.Errorf("NextWindowFor() end = %v, want %v", gotEnd, tt.wantEnd)
}
// Validate basic invariants
if gotStart.After(gotEnd) {
t.Errorf("Window start should not be after end: start=%v, end=%v", gotStart, gotEnd)
}
if gotEnd.After(tt.current) {
t.Errorf("Window end should not be after current time: end=%v, current=%v", gotEnd, tt.current)
}
duration := gotEnd.Sub(gotStart)
// Validate window length is reasonable
if duration < 0 {
t.Errorf("Window duration should not be negative: %v", duration)
}
if duration > 366*24*time.Hour {
t.Errorf("Window duration should not exceed 1 year: %v", duration)
}
})
}
}
func TestEvaluationEnvelope_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
jsonInput string
wantKind EvaluationKind
wantSpec interface{}
wantError bool
}{
{
name: "rolling evaluation with valid data",
jsonInput: `{"kind":"rolling","spec":{"evalWindow":"5m","frequency":"1m"}}`,
wantKind: RollingEvaluation,
wantSpec: RollingWindow{
EvalWindow: Duration(5 * time.Minute),
Frequency: Duration(1 * time.Minute),
},
},
{
name: "cumulative evaluation with valid data",
jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly","minute":30},"frequency":"2m","timezone":"UTC"}}`,
wantKind: CumulativeEvaluation,
wantSpec: CumulativeWindow{
Schedule: CumulativeSchedule{
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Frequency: Duration(2 * time.Minute),
Timezone: "UTC",
},
},
{
name: "rolling evaluation with validation error - zero evalWindow",
jsonInput: `{"kind":"rolling","spec":{"evalWindow":"0s","frequency":"1m"}}`,
wantError: true,
},
{
name: "rolling evaluation with validation error - zero frequency",
jsonInput: `{"kind":"rolling","spec":{"evalWindow":"5m","frequency":"0s"}}`,
wantError: true,
},
{
name: "cumulative evaluation with validation error - zero frequency",
jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly","minute":30},"frequency":"0s","timezone":"UTC"}}`,
wantError: true,
},
{
name: "cumulative evaluation with validation error - invalid timezone",
jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"daily","hour":9,"minute":30},"frequency":"1m","timezone":"Invalid/Timezone"}}`,
wantError: true,
},
{
name: "cumulative evaluation with validation error - missing minute for hourly",
jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly"},"frequency":"1m","timezone":"UTC"}}`,
wantError: true,
},
{
name: "unknown evaluation kind",
jsonInput: `{"kind":"unknown","spec":{"evalWindow":"5m","frequency":"1h"}}`,
wantError: true,
},
{
name: "invalid JSON",
jsonInput: `{"kind":"rolling","spec":invalid}`,
wantError: true,
},
{
name: "missing kind field",
jsonInput: `{"spec":{"evalWindow":"5m","frequency":"1m"}}`,
wantError: true,
},
{
name: "missing spec field",
jsonInput: `{"kind":"rolling"}`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var envelope EvaluationEnvelope
err := json.Unmarshal([]byte(tt.jsonInput), &envelope)
if tt.wantError {
if err == nil {
t.Errorf("EvaluationEnvelope.UnmarshalJSON() expected error, got none")
}
return
}
if err != nil {
t.Fatalf("EvaluationEnvelope.UnmarshalJSON() unexpected error = %v", err)
}
if envelope.Kind != tt.wantKind {
t.Errorf("EvaluationEnvelope.Kind = %v, want %v", envelope.Kind, tt.wantKind)
}
// Check spec content based on type
switch tt.wantKind {
case RollingEvaluation:
gotSpec, ok := envelope.Spec.(RollingWindow)
if !ok {
t.Fatalf("Expected RollingWindow spec, got %T", envelope.Spec)
}
wantSpec := tt.wantSpec.(RollingWindow)
if gotSpec.EvalWindow != wantSpec.EvalWindow {
t.Errorf("RollingWindow.EvalWindow = %v, want %v", gotSpec.EvalWindow, wantSpec.EvalWindow)
}
if gotSpec.Frequency != wantSpec.Frequency {
t.Errorf("RollingWindow.Frequency = %v, want %v", gotSpec.Frequency, wantSpec.Frequency)
}
case CumulativeEvaluation:
gotSpec, ok := envelope.Spec.(CumulativeWindow)
if !ok {
t.Fatalf("Expected CumulativeWindow spec, got %T", envelope.Spec)
}
wantSpec := tt.wantSpec.(CumulativeWindow)
if gotSpec.Schedule.Type != wantSpec.Schedule.Type {
t.Errorf("CumulativeWindow.Schedule.Type = %v, want %v", gotSpec.Schedule.Type, wantSpec.Schedule.Type)
}
if (gotSpec.Schedule.Minute == nil) != (wantSpec.Schedule.Minute == nil) ||
(gotSpec.Schedule.Minute != nil && wantSpec.Schedule.Minute != nil && *gotSpec.Schedule.Minute != *wantSpec.Schedule.Minute) {
t.Errorf("CumulativeWindow.Schedule.Minute = %v, want %v", gotSpec.Schedule.Minute, wantSpec.Schedule.Minute)
}
if gotSpec.Frequency != wantSpec.Frequency {
t.Errorf("CumulativeWindow.Frequency = %v, want %v", gotSpec.Frequency, wantSpec.Frequency)
}
if gotSpec.Timezone != wantSpec.Timezone {
t.Errorf("CumulativeWindow.Timezone = %v, want %v", gotSpec.Timezone, wantSpec.Timezone)
}
}
})
}
}