Files
ITWorx-Pulse-Public/internal/workerruntime/runtime_test.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

526 lines
19 KiB
Go

package workerruntime
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/itworx/pulse/internal/config"
"github.com/itworx/pulse/internal/systemstatus"
)
// configFixture is a minimal valid application configuration for status tests.
func configFixture() config.Config { return config.Config{AuthMode: "mock"} }
// fakeClock is a controllable clock. The scheduling loop uses it for due-ness
// and for every recorded timestamp, so a test decides exactly when a job
// becomes due instead of sleeping.
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func newFakeClock() *fakeClock {
return &fakeClock{now: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
c.now = c.now.Add(d)
c.mu.Unlock()
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(nopWriter{}, &slog.HandlerOptions{Level: slog.LevelError + 1}))
}
type nopWriter struct{}
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
func testConfig(t *testing.T, clock *fakeClock, store LeaseStore) Config {
t.Helper()
return Config{
Owner: "worker-test", Tick: 10 * time.Millisecond, HeartbeatFile: filepath.Join(t.TempDir(), "healthy"),
DrainTimeout: 2 * time.Second, Leases: store, Logger: quietLogger(), Now: clock.Now,
}
}
// waitFor polls until condition holds or the deadline passes.
func waitFor(t *testing.T, timeout time.Duration, condition func() bool) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return true
}
time.Sleep(time.Millisecond)
}
return condition()
}
func TestNewRejectsInvalidConfiguration(t *testing.T) {
clock := newFakeClock()
valid := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { return Outcome{}, nil }}
for name, testCase := range map[string]struct {
mutate func(*Config)
jobs []Job
}{
"no owner": {mutate: func(c *Config) { c.Owner = "" }, jobs: []Job{valid}},
"no lease store": {mutate: func(c *Config) { c.Leases = nil }, jobs: []Job{valid}},
"no jobs": {mutate: func(*Config) {}, jobs: nil},
"tick above bound": {mutate: func(c *Config) { c.Tick = time.Minute }, jobs: []Job{valid}},
"duplicate job": {mutate: func(*Config) {}, jobs: []Job{valid, valid}},
"unknown component": {mutate: func(*Config) {}, jobs: []Job{{Name: "x-job", Component: "storage", Interval: time.Minute,
Timeout: time.Second, Run: valid.Run}}},
"missing run": {mutate: func(*Config) {}, jobs: []Job{{Name: "discovery", Component: systemstatus.ComponentWorker,
Interval: time.Minute, Timeout: time.Second}}},
"timeout above bound": {mutate: func(*Config) {}, jobs: []Job{{Name: "discovery", Component: systemstatus.ComponentWorker,
Interval: time.Minute, Timeout: time.Hour, Run: valid.Run}}},
} {
t.Run(name, func(t *testing.T) {
config := testConfig(t, clock, NewMemoryLeaseStore())
testCase.mutate(&config)
if _, err := New(config, testCase.jobs...); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("error = %v, want ErrInvalidConfig", err)
}
})
}
}
func TestJobRunsOncePerIntervalWindow(t *testing.T) {
clock := newFakeClock()
var runs atomic.Int64
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: 5 * time.Second,
Run: func(context.Context) (Outcome, error) {
runs.Add(1)
return Outcome{Counts: map[string]int64{"items": 1}}, nil
}}
runtime, err := New(testConfig(t, clock, NewMemoryLeaseStore()), job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, time.Second, func() bool { return runs.Load() == 1 }) {
t.Fatalf("first run count = %d, want 1", runs.Load())
}
// Many loop iterations pass without the clock moving: the job must not run
// again inside its interval.
time.Sleep(60 * time.Millisecond)
if runs.Load() != 1 {
t.Fatalf("run count inside interval = %d, want 1", runs.Load())
}
clock.Advance(time.Minute)
if !waitFor(t, time.Second, func() bool { return runs.Load() == 2 }) {
t.Fatalf("run count after interval = %d, want 2", runs.Load())
}
cancel()
if err := <-done; err != nil {
t.Fatalf("run: %v", err)
}
status := runtime.Status()[0]
if status.LastStatus != StatusCompleted || status.Runs != 2 || status.Successes != 2 || status.LastSuccessAt.IsZero() {
t.Fatalf("status = %#v", status)
}
}
func TestSecondRuntimeIsBlockedByTheLeaseAndRepeatedRunsAreIdempotent(t *testing.T) {
clock := newFakeClock()
store := NewMemoryLeaseStore()
var first, second atomic.Int64
build := func(counter *atomic.Int64, owner string) *Runtime {
config := testConfig(t, clock, store)
config.Owner = owner
runtime, err := New(config, Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: 5 * time.Second,
Run: func(context.Context) (Outcome, error) { counter.Add(1); return Outcome{}, nil }})
if err != nil {
t.Fatal(err)
}
return runtime
}
runtimeA, runtimeB := build(&first, "worker-a"), build(&second, "worker-b")
ctx, cancel := context.WithCancel(context.Background())
doneA, doneB := make(chan error, 1), make(chan error, 1)
go func() { doneA <- runtimeA.Run(ctx) }()
go func() { doneB <- runtimeB.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return first.Load()+second.Load() >= 1 }) {
t.Fatal("no worker executed the job")
}
time.Sleep(80 * time.Millisecond)
if total := first.Load() + second.Load(); total != 1 {
t.Fatalf("executions for one window = %d, want 1", total)
}
// The next window is a different lease key, so exactly one worker runs again.
clock.Advance(time.Minute)
if !waitFor(t, 2*time.Second, func() bool { return first.Load()+second.Load() == 2 }) {
t.Fatalf("executions after second window = %d, want 2", first.Load()+second.Load())
}
cancel()
<-doneA
<-doneB
if store.Windows() != 2 {
t.Fatalf("claimed windows = %d, want 2", store.Windows())
}
skips := uint64(0)
for _, status := range append(runtimeA.Status(), runtimeB.Status()...) {
skips += status.Skips
}
if skips == 0 {
t.Fatal("the worker that lost a window did not record a lease contention skip")
}
}
func TestOneFailingJobDoesNotStopTheOthers(t *testing.T) {
clock := newFakeClock()
var healthy, panicking, failing atomic.Int64
jobs := []Job{
{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) {
failing.Add(1)
return Outcome{}, errors.New("database is unreachable")
}},
{Name: "probe-execution", Component: systemstatus.ComponentProbes, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { panicking.Add(1); panic("probe exploded") }},
{Name: "notification-drain", Component: systemstatus.ComponentNotifications, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { healthy.Add(1); return Outcome{}, nil }},
}
runtime, err := New(testConfig(t, clock, NewMemoryLeaseStore()), jobs...)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return healthy.Load() == 1 && failing.Load() == 1 && panicking.Load() == 1 }) {
t.Fatalf("runs healthy=%d failing=%d panicking=%d", healthy.Load(), failing.Load(), panicking.Load())
}
clock.Advance(time.Minute)
if !waitFor(t, 2*time.Second, func() bool { return healthy.Load() == 2 && failing.Load() == 2 }) {
t.Fatalf("second window healthy=%d failing=%d", healthy.Load(), failing.Load())
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
byName := map[string]JobStatus{}
for _, status := range runtime.Status() {
byName[status.Name] = status
}
if byName["discovery"].LastStatus != StatusFailed || byName["discovery"].Failures == 0 || byName["discovery"].LastError == "" {
t.Fatalf("failing job status = %#v", byName["discovery"])
}
if !strings.Contains(byName["probe-execution"].LastError, "panicked") {
t.Fatalf("panicking job status = %#v", byName["probe-execution"])
}
if byName["notification-drain"].LastStatus != StatusCompleted || byName["notification-drain"].Successes == 0 {
t.Fatalf("healthy job status = %#v", byName["notification-drain"])
}
}
func TestJobTimeoutIsEnforcedAndRecorded(t *testing.T) {
clock := newFakeClock()
released := make(chan struct{})
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(ctx context.Context) (Outcome, error) {
<-ctx.Done()
close(released)
return Outcome{}, ctx.Err()
}}
runtime, err := New(testConfig(t, clock, NewMemoryLeaseStore()), job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
select {
case <-released:
case <-time.After(3 * time.Second):
t.Fatal("job was not cancelled by its timeout")
}
if !waitFor(t, 2*time.Second, func() bool { return runtime.Status()[0].LastStatus == StatusFailed }) {
t.Fatalf("status = %#v", runtime.Status()[0])
}
if reason := runtime.Status()[0].LastReason; reason != "timeout" {
t.Fatalf("reason = %q, want timeout", reason)
}
cancel()
<-done
}
func TestShutdownDrainsInFlightWork(t *testing.T) {
clock := newFakeClock()
started := make(chan struct{})
var finished atomic.Bool
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: 5 * time.Second,
Run: func(ctx context.Context) (Outcome, error) {
close(started)
select {
case <-time.After(150 * time.Millisecond):
case <-ctx.Done():
return Outcome{}, ctx.Err()
}
finished.Store(true)
return Outcome{}, nil
}}
runtime, err := New(testConfig(t, clock, NewMemoryLeaseStore()), job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown did not complete")
}
if !finished.Load() {
t.Fatal("in-flight job was cancelled instead of drained")
}
if status := runtime.Status()[0]; status.LastStatus != StatusCompleted {
t.Fatalf("drained job status = %#v", status)
}
}
func TestShutdownCancelsWorkThatOutlastsTheDrainBudget(t *testing.T) {
clock := newFakeClock()
config := testConfig(t, clock, NewMemoryLeaseStore())
config.DrainTimeout = time.Second
started := make(chan struct{})
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: 4 * time.Minute,
Run: func(ctx context.Context) (Outcome, error) {
close(started)
<-ctx.Done()
return Outcome{}, ctx.Err()
}}
runtime, err := New(config, job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("shutdown never terminated")
}
}
func TestHeartbeatIsWrittenByTheLoopAndSurvivesWriteFailure(t *testing.T) {
clock := newFakeClock()
directory := t.TempDir()
path := filepath.Join(directory, "healthy")
blocked := make(chan struct{})
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: 4 * time.Minute,
Run: func(ctx context.Context) (Outcome, error) {
// The loop must keep heartbeating while a job is stuck.
<-blocked
return Outcome{}, nil
}}
config := testConfig(t, clock, NewMemoryLeaseStore())
config.HeartbeatFile = path
runtime, err := New(config, job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return runtime.HeartbeatCount() >= 3 }) {
t.Fatalf("heartbeat count = %d while a job is stuck", runtime.HeartbeatCount())
}
close(blocked)
cancel()
if err := <-done; err != nil {
t.Fatalf("shutdown: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if _, err := time.Parse(time.RFC3339, strings.TrimSpace(string(content))); err != nil {
t.Fatalf("heartbeat content %q is not RFC3339: %v", content, err)
}
}
func TestHeartbeatFailureIsReportedWithoutStoppingTheLoop(t *testing.T) {
clock := newFakeClock()
config := testConfig(t, clock, NewMemoryLeaseStore())
// A directory can never be written as a file.
config.HeartbeatFile = t.TempDir()
var runs atomic.Int64
runtime, err := New(config, Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { runs.Add(1); return Outcome{}, nil }})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return runs.Load() == 1 }) {
t.Fatal("loop stopped after a heartbeat write failure")
}
if runtime.HeartbeatCount() != 0 {
t.Fatalf("heartbeat count = %d, want 0 after write failures", runtime.HeartbeatCount())
}
cancel()
<-done
}
func TestLeaseFailureIsReportedAsAFailedRun(t *testing.T) {
clock := newFakeClock()
config := testConfig(t, clock, failingLeaseStore{})
runtime, err := New(config, Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { t.Error("job ran without a lease"); return Outcome{}, nil }})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return runtime.Status()[0].LastStatus == StatusFailed }) {
t.Fatalf("status = %#v", runtime.Status()[0])
}
if reason := runtime.Status()[0].LastReason; reason != "lease_unavailable" {
t.Fatalf("reason = %q", reason)
}
cancel()
<-done
}
type failingLeaseStore struct{}
func (failingLeaseStore) Acquire(context.Context, string, string, time.Time, string, time.Time, time.Duration) (Lease, bool, error) {
return Lease{}, false, errors.New("job_runs is unavailable")
}
func (failingLeaseStore) Complete(context.Context, Lease, string, string, map[string]int64) error {
return nil
}
func TestJobHealthReportsOnlyJobsThatRan(t *testing.T) {
clock := newFakeClock()
release := make(chan struct{})
jobs := []Job{
{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) { return Outcome{}, nil }},
{Name: "probe-execution", Component: systemstatus.ComponentProbes, Interval: time.Minute, Timeout: 4 * time.Minute,
Run: func(context.Context) (Outcome, error) { <-release; return Outcome{}, nil }},
}
runtime, err := New(testConfig(t, clock, NewMemoryLeaseStore()), jobs...)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return len(runtime.JobHealth()) == 1 }) {
t.Fatalf("job health = %#v", runtime.JobHealth())
}
health := runtime.JobHealth()[0]
if health.Component != systemstatus.ComponentWorker || health.Status != systemstatus.JobCompleted || health.LastSuccessAt.IsZero() {
t.Fatalf("health = %#v", health)
}
// The still-running probe job has reported nothing, so its component keeps
// the Unknown "not recorded" state.
snapshot := systemstatus.Build(configFixture(), true, clock.Now(), nil, systemstatus.WithJobs(time.Minute, runtime.JobHealth()...))
for _, component := range snapshot.Components {
if component.ID == systemstatus.ComponentProbes && component.Reason != "probe_heartbeat_not_recorded" {
t.Fatalf("probes component = %#v", component)
}
if component.ID == systemstatus.ComponentWorker && component.State != systemstatus.StateHealthy {
t.Fatalf("worker component = %#v", component)
}
}
close(release)
cancel()
<-done
}
func TestScheduleUsesBoundedIntervalsAndDisablesMissingWork(t *testing.T) {
jobs := Schedule(ScheduleRuns{})
if len(jobs) != 4 {
t.Fatalf("job count = %d, want 4", len(jobs))
}
seen := map[string]Job{}
for _, job := range jobs {
if err := job.validate(); err != nil {
t.Fatalf("job %s: %v", job.Name, err)
}
if job.Timeout > 10*job.Interval {
t.Fatalf("job %s timeout %s is unreasonable for interval %s", job.Name, job.Timeout, job.Interval)
}
seen[job.Name] = job
}
for _, name := range []string{JobDiscovery, JobAlertEvaluation, JobProbeExecution, JobNotificationDrain} {
job, ok := seen[name]
if !ok {
t.Fatalf("job %s is not scheduled", name)
}
outcome, err := job.Run(context.Background())
if err != nil || !outcome.Disabled || outcome.Reason == "" {
t.Fatalf("unconfigured job %s outcome = %#v err = %v", name, outcome, err)
}
}
}
func TestASkippedRunNeverBecomesAHealthyComponent(t *testing.T) {
clock := newFakeClock()
store := NewMemoryLeaseStore()
job := Job{Name: "discovery", Component: systemstatus.ComponentWorker, Interval: time.Minute, Timeout: time.Second,
Run: func(context.Context) (Outcome, error) {
return Outcome{Skipped: true, Reason: "source_unavailable"}, nil
}}
runtime, err := New(testConfig(t, clock, store), job)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runtime.Run(ctx) }()
if !waitFor(t, 2*time.Second, func() bool { return len(runtime.JobHealth()) == 1 }) {
t.Fatal("the skipped run was not reported")
}
health := runtime.JobHealth()
if !health[0].LastSuccessAt.IsZero() {
t.Fatalf("a skipped run recorded a success: %#v", health[0])
}
snapshot := systemstatus.Build(configFixture(), true, clock.Now(), nil, systemstatus.WithJobs(time.Minute, health...))
for _, component := range snapshot.Components {
if component.ID == systemstatus.ComponentWorker && component.State == systemstatus.StateHealthy {
t.Fatalf("a job that only skips reported healthy: %#v", component)
}
}
if recorded := store.Status(job.JobType(), job.Name, clock.Now().Truncate(job.Interval)); recorded != StatusSkipped {
t.Fatalf("recorded lease status = %q, want %q", recorded, StatusSkipped)
}
cancel()
<-done
}