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(),
)
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

View File

@ -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)

View File

@ -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())

View File

@ -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() {

View File

@ -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())

View File

@ -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,

View File

@ -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,

View File

@ -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()
}

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)
}
}
})
}
}