mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
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:
parent
c982b1e76d
commit
ac81eab7bb
@ -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(),
|
||||
)
|
||||
|
||||
start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli()
|
||||
end := ts.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))
|
||||
st, en := r.Timestamps(ts)
|
||||
start := st.UnixMilli()
|
||||
end := en.UnixMilli()
|
||||
|
||||
compositeQuery := r.Condition().CompositeQuery
|
||||
|
||||
|
||||
@ -3,8 +3,10 @@ package rules
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"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
|
||||
|
||||
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 {
|
||||
// create a threshold rule
|
||||
tr, err := baserules.NewThresholdRule(
|
||||
@ -40,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, tr)
|
||||
|
||||
// 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 {
|
||||
|
||||
@ -62,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, pr)
|
||||
|
||||
// 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 {
|
||||
// create anomaly rule
|
||||
@ -84,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, ar)
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/converter"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
@ -87,6 +88,8 @@ type BaseRule struct {
|
||||
TemporalityMap map[string]map[v3.Temporality]bool
|
||||
|
||||
sqlstore sqlstore.SQLStore
|
||||
|
||||
evaluation ruletypes.Evaluation
|
||||
}
|
||||
|
||||
type RuleOption func(*BaseRule)
|
||||
@ -129,6 +132,10 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
|
||||
if err != nil {
|
||||
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{
|
||||
id: id,
|
||||
@ -146,6 +153,7 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
|
||||
reader: reader,
|
||||
TemporalityMap: make(map[string]map[v3.Temporality]bool),
|
||||
Threshold: threshold,
|
||||
evaluation: evaluation,
|
||||
}
|
||||
|
||||
if baseRule.evalWindow == 0 {
|
||||
@ -248,8 +256,10 @@ func (r *BaseRule) Unit() string {
|
||||
}
|
||||
|
||||
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 {
|
||||
start = start - int64(r.evalDelay.Milliseconds())
|
||||
|
||||
@ -12,12 +12,11 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
||||
@ -147,6 +146,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
var task Task
|
||||
|
||||
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 {
|
||||
// create a threshold rule
|
||||
tr, err := NewThresholdRule(
|
||||
@ -167,7 +172,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
rules = append(rules, tr)
|
||||
|
||||
// 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 {
|
||||
|
||||
@ -189,7 +194,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
rules = append(rules, pr)
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
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() {
|
||||
@ -593,7 +598,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
|
||||
|
||||
if err != nil {
|
||||
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() {
|
||||
|
||||
@ -123,8 +123,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
|
||||
|
||||
prevState := r.State()
|
||||
|
||||
start := ts.Add(-r.evalWindow)
|
||||
end := ts
|
||||
start, end := r.Timestamps(ts)
|
||||
interval := 60 * time.Second // TODO(srikanthccv): this should be configurable
|
||||
|
||||
valueFormatter := formatter.FromUnit(r.Unit())
|
||||
|
||||
@ -28,8 +28,10 @@ func TestPromRuleShouldAlert(t *testing.T) {
|
||||
AlertName: "Test Rule",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypePromQL,
|
||||
|
||||
@ -34,8 +34,10 @@ func TestThresholdRuleShouldAlert(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -889,8 +891,10 @@ func TestPrepareLinksToLogs(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -941,8 +945,10 @@ func TestPrepareLinksToLogsV5(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1000,8 +1006,10 @@ func TestPrepareLinksToTracesV5(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1059,8 +1067,10 @@ func TestPrepareLinksToTraces(t *testing.T) {
|
||||
AlertName: "Links to traces test",
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1111,8 +1121,10 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1217,8 +1229,10 @@ func TestThresholdRuleEvalDelay(t *testing.T) {
|
||||
AlertName: "Test Eval Delay",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeClickHouseSQL,
|
||||
@ -1278,8 +1292,10 @@ func TestThresholdRuleClickHouseTmpl(t *testing.T) {
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeClickHouseSQL,
|
||||
@ -1345,8 +1361,10 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
AlertName: "Units test",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1538,8 +1556,10 @@ func TestThresholdRuleNoData(t *testing.T) {
|
||||
AlertName: "No data test",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1641,8 +1661,10 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
AlertName: "Traces link test",
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1766,8 +1788,10 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
AlertName: "Logs link test",
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
@ -1904,8 +1928,10 @@ func TestThresholdRuleShiftBy(t *testing.T) {
|
||||
AlertName: "Logs link test",
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
@ -1976,8 +2002,10 @@ func TestMultipleThresholdRule(t *testing.T) {
|
||||
AlertName: "Mulitple threshold test",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{
|
||||
EvalWindow: ruletypes.Duration(5 * time.Minute),
|
||||
Frequency: ruletypes.Duration(1 * time.Minute),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
|
||||
@ -50,6 +50,8 @@ type PostableRule struct {
|
||||
PreferredChannels []string `json:"preferredChannels,omitempty"`
|
||||
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
|
||||
}
|
||||
|
||||
func (r *PostableRule) processRuleDefaults() error {
|
||||
@ -98,6 +100,9 @@ func (r *PostableRule) processRuleDefaults() error {
|
||||
r.RuleCondition.Thresholds = &thresholdData
|
||||
}
|
||||
}
|
||||
if r.Evaluation == nil {
|
||||
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
|
||||
}
|
||||
|
||||
return r.Validate()
|
||||
}
|
||||
|
||||
287
pkg/types/ruletypes/evaluation.go
Normal file
287
pkg/types/ruletypes/evaluation.go
Normal 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")
|
||||
}
|
||||
878
pkg/types/ruletypes/evaluation_test.go
Normal file
878
pkg/types/ruletypes/evaluation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user