Public source validation / validate (push) Failing after 3m8s
189 lines
6.4 KiB
Go
189 lines
6.4 KiB
Go
package alertworker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/itworx/pulse/internal/alert"
|
|
)
|
|
|
|
type fakeSource struct {
|
|
rules []alert.Rule
|
|
}
|
|
|
|
func (s fakeSource) ListEnabled(_ context.Context, limit int) ([]alert.Rule, error) {
|
|
if limit > len(s.rules) {
|
|
limit = len(s.rules)
|
|
}
|
|
return s.rules[:limit], nil
|
|
}
|
|
|
|
type trackingEvaluator struct {
|
|
active atomic.Int32
|
|
max atomic.Int32
|
|
calls atomic.Int32
|
|
wait time.Duration
|
|
err error
|
|
}
|
|
|
|
func (e *trackingEvaluator) Evaluate(ctx context.Context, _ alert.Rule) error {
|
|
e.calls.Add(1)
|
|
active := e.active.Add(1)
|
|
for {
|
|
current := e.max.Load()
|
|
if active <= current || e.max.CompareAndSwap(current, active) {
|
|
break
|
|
}
|
|
}
|
|
defer e.active.Add(-1)
|
|
if e.wait > 0 {
|
|
timer := time.NewTimer(e.wait)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
func testRule(id string) alert.Rule {
|
|
return alert.Rule{Document: alert.Document{ID: id, EvaluationIntervalSeconds: 30}}
|
|
}
|
|
|
|
func testWorker(t *testing.T, source RuleSource, store LeaseStore, evaluator Evaluator, owner string, now time.Time, maxConcurrent int) *Worker {
|
|
t.Helper()
|
|
worker, err := New(source, store, evaluator, Config{MaxConcurrent: maxConcurrent, MaxBatch: 100, AttemptTimeout: 100 * time.Millisecond, LeaseTTL: time.Second, Owner: owner, Now: func() time.Time { return now }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &worker
|
|
}
|
|
|
|
func TestDuplicateWorkersDoNotEvaluateSameSlotTwice(t *testing.T) {
|
|
source := fakeSource{rules: []alert.Rule{testRule("rule-1")}}
|
|
store := NewMemoryLeaseStore()
|
|
evaluator := &trackingEvaluator{wait: 20 * time.Millisecond}
|
|
now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
|
|
first := testWorker(t, source, store, evaluator, "worker-1", now, 1)
|
|
second := testWorker(t, source, store, evaluator, "worker-2", now, 1)
|
|
var reports [2]RunReport
|
|
var wait sync.WaitGroup
|
|
wait.Add(2)
|
|
go func() { defer wait.Done(); reports[0], _ = first.RunOnce(context.Background()) }()
|
|
go func() { defer wait.Done(); reports[1], _ = second.RunOnce(context.Background()) }()
|
|
wait.Wait()
|
|
if evaluator.calls.Load() != 1 {
|
|
t.Fatalf("evaluation calls = %d, want 1", evaluator.calls.Load())
|
|
}
|
|
if reports[0].Skipped+reports[1].Skipped != 1 {
|
|
t.Fatalf("skips = %d, want 1", reports[0].Skipped+reports[1].Skipped)
|
|
}
|
|
third, err := first.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if third.Skipped != 1 || evaluator.calls.Load() != 1 {
|
|
t.Fatalf("repeat report=%#v calls=%d", third, evaluator.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestTimeoutIsVisibleInJobResult(t *testing.T) {
|
|
source := fakeSource{rules: []alert.Rule{testRule("rule-timeout")}}
|
|
store := NewMemoryLeaseStore()
|
|
evaluator := &trackingEvaluator{wait: time.Second}
|
|
now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
|
|
worker, err := New(source, store, evaluator, Config{MaxConcurrent: 1, MaxBatch: 1, AttemptTimeout: 10 * time.Millisecond, LeaseTTL: time.Second, Owner: "worker-timeout", Now: func() time.Time { return now }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := worker.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.Failed != 1 || len(report.Jobs) != 1 || report.Jobs[0].ErrorCode != "timeout" {
|
|
t.Fatalf("timeout report=%#v", report)
|
|
}
|
|
}
|
|
|
|
func TestShutdownCancelsOutstandingEvaluationSafely(t *testing.T) {
|
|
source := fakeSource{rules: []alert.Rule{testRule("rule-shutdown")}}
|
|
store := NewMemoryLeaseStore()
|
|
started := make(chan struct{})
|
|
evaluator := EvaluateFunc(func(ctx context.Context, _ alert.Rule) error {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
})
|
|
now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
|
|
worker := testWorker(t, source, store, evaluator, "worker-shutdown", now, 1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
result := make(chan RunReport, 1)
|
|
go func() {
|
|
report, _ := worker.RunOnce(ctx)
|
|
result <- report
|
|
}()
|
|
<-started
|
|
cancel()
|
|
report := <-result
|
|
if report.Canceled != 1 || len(report.Jobs) != 1 || report.Jobs[0].ErrorCode != "shutdown" {
|
|
t.Fatalf("shutdown report=%#v", report)
|
|
}
|
|
}
|
|
|
|
func TestLoadIsBoundedByMaxConcurrentAndBatch(t *testing.T) {
|
|
rules := make([]alert.Rule, 20)
|
|
for i := range rules {
|
|
rules[i] = testRule(alert.NewID())
|
|
}
|
|
store := NewMemoryLeaseStore()
|
|
evaluator := &trackingEvaluator{wait: 2 * time.Millisecond}
|
|
now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
|
|
worker := testWorker(t, fakeSource{rules: rules}, store, evaluator, "worker-scale", now, 3)
|
|
report, err := worker.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.Scheduled != 20 || report.Started != 20 || report.Completed != 20 || evaluator.max.Load() > 3 {
|
|
t.Fatalf("bounded report=%#v max=%d", report, evaluator.max.Load())
|
|
}
|
|
if metrics := worker.Metrics(); metrics.JobsStarted != 20 || metrics.JobsCompleted != 20 {
|
|
t.Fatalf("metrics=%#v", metrics)
|
|
}
|
|
}
|
|
|
|
func TestInvalidWorkerConfigurationAndEvaluationError(t *testing.T) {
|
|
_, err := New(fakeSource{}, NewMemoryLeaseStore(), EvaluateFunc(func(context.Context, alert.Rule) error { return nil }), Config{MaxConcurrent: 0, MaxBatch: 1, AttemptTimeout: time.Second, LeaseTTL: time.Second, Owner: "x", Now: time.Now})
|
|
if !errors.Is(err, ErrInvalidConfig) {
|
|
t.Fatalf("config error=%v", err)
|
|
}
|
|
source := fakeSource{rules: []alert.Rule{testRule("rule-error")}}
|
|
store := NewMemoryLeaseStore()
|
|
worker := testWorker(t, source, store, EvaluateFunc(func(context.Context, alert.Rule) error { return errors.New("upstream failed") }), "worker-error", time.Now().UTC(), 1)
|
|
report, err := worker.RunOnce(context.Background())
|
|
if err != nil || report.Failed != 1 || report.Jobs[0].ErrorCode != "evaluation_failed" {
|
|
t.Fatalf("error report=%#v err=%v", report, err)
|
|
}
|
|
}
|
|
|
|
func TestFailedSlotIsVisibleAndNotRetried(t *testing.T) {
|
|
source := fakeSource{rules: []alert.Rule{testRule("rule-failed")}}
|
|
store := NewMemoryLeaseStore()
|
|
evaluator := &trackingEvaluator{err: errors.New("upstream failed")}
|
|
now := time.Date(2026, 8, 2, 5, 0, 10, 0, time.UTC)
|
|
worker := testWorker(t, source, store, evaluator, "worker-failed", now, 1)
|
|
first, err := worker.RunOnce(context.Background())
|
|
if err != nil || first.Failed != 1 || first.Jobs[0].ErrorCode != "evaluation_failed" {
|
|
t.Fatalf("first report=%#v err=%v", first, err)
|
|
}
|
|
second, err := worker.RunOnce(context.Background())
|
|
if err != nil || second.Skipped != 1 || evaluator.calls.Load() != 1 {
|
|
t.Fatalf("second report=%#v calls=%d err=%v", second, evaluator.calls.Load(), err)
|
|
}
|
|
}
|