Files

472 lines
12 KiB
Go
Raw Permalink Normal View History

2025-08-12 06:37:16 -07:00
package backgroundtask
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestSignal_TriggersWork verifies that Signal() triggers work execution
func TestSignal_TriggersWork(t *testing.T) {
config := NewTestConfigMap()
testConfig, _ := GetTestConfig(config)
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(1*time.Hour), // Very long interval to ensure only signal triggers work
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Signal work
if err := runner.Signal(); err != nil {
t.Fatalf("Signal failed: %v", err)
}
// Wait for work to complete
if !WaitForWorkCycles(runner, 1, 500*time.Millisecond) {
t.Fatal("Expected at least 1 work cycle after signal")
}
stats := runner.Stats()
if stats.WorkCycles() < 1 {
t.Errorf("Expected at least 1 work cycle, got %d", stats.WorkCycles())
}
if testConfig.Counter.Load() < 1 {
t.Errorf("Expected at least 1 counter increment, got %d", testConfig.Counter.Load())
}
}
// TestTicker_TriggersWork verifies that the ticker triggers work execution
func TestTicker_TriggersWork(t *testing.T) {
config := NewTestConfigMap()
testConfig, _ := GetTestConfig(config)
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(50*time.Millisecond), // Fast interval for testing
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Wait for ticker-triggered work (should happen at least twice)
if !WaitForWorkCycles(runner, 2, 200*time.Millisecond) {
t.Fatal("Expected at least 2 work cycles from ticker")
}
stats := runner.Stats()
if stats.WorkCycles() < 2 {
t.Errorf("Expected at least 2 work cycles, got %d", stats.WorkCycles())
}
if testConfig.Counter.Load() < 2 {
t.Errorf("Expected at least 2 counter increments, got %d", testConfig.Counter.Load())
}
}
// TestSignal_AfterShutdown verifies that Signal() returns error after shutdown
func TestSignal_AfterShutdown(t *testing.T) {
config := NewTestConfigMap()
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(100*time.Millisecond),
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
// Shutdown the runner
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown failed: %v", err)
}
// Signal should return ErrStopped
if err := runner.Signal(); err != ErrStopped {
t.Errorf("Expected ErrStopped after shutdown, got: %v", err)
}
}
// TestSignal_Coalescing verifies that multiple rapid signals are coalesced
func TestSignal_Coalescing(t *testing.T) {
config := NewTestConfigMap()
testConfig, _ := GetTestConfig(config)
testConfig.WorkDuration = 100 * time.Millisecond // Make work take some time
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(1*time.Hour), // Very long interval
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Send many rapid signals
const signalCount = 20
for i := 0; i < signalCount; i++ {
if err := runner.Signal(); err != nil {
t.Fatalf("Signal %d failed: %v", i, err)
}
}
// Wait for work to complete
time.Sleep(500 * time.Millisecond)
// Should have fewer work cycles than signals due to coalescing
stats := runner.Stats()
workCycles := stats.WorkCycles()
if workCycles == 0 {
t.Fatal("Expected some work cycles")
}
// Should be significantly less than signalCount due to coalescing
if workCycles >= signalCount {
t.Errorf("Expected coalescing: work cycles (%d) should be less than signals (%d)", workCycles, signalCount)
}
// But should have at least processed some work
if testConfig.Counter.Load() == 0 {
t.Error("Expected some work to be done")
}
}
// TestWork_NoConcurrency verifies that work functions don't run concurrently
func TestWork_NoConcurrency(t *testing.T) {
var concurrentCount int64
var maxConcurrent int64
// Create a work function that tracks concurrent executions
workFunc := func(ctx context.Context, logger *slog.Logger, config map[string]any) error {
current := atomic.AddInt64(&concurrentCount, 1)
defer atomic.AddInt64(&concurrentCount, -1)
// Track max concurrent
for {
max := atomic.LoadInt64(&maxConcurrent)
if current <= max || atomic.CompareAndSwapInt64(&maxConcurrent, max, current) {
break
}
}
// Simulate some work
time.Sleep(50 * time.Millisecond)
return nil
}
config := map[string]any{"test": true}
runner, err := Initialize(
config,
slog.Default(),
workFunc,
WithInterval(25*time.Millisecond), // Faster than work duration
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
// Let it run for a while to trigger multiple potential concurrent executions
time.Sleep(200 * time.Millisecond)
// Send some signals too
for i := 0; i < 5; i++ {
if err := runner.Signal(); err != nil {
t.Fatalf("Signal failed: %v", err)
}
}
time.Sleep(200 * time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown failed: %v", err)
}
// Should never have more than 1 concurrent execution
if maxConcurrent > 1 {
t.Errorf("Expected max concurrent executions to be 1, got %d", maxConcurrent)
}
// Should have done some work
stats := runner.Stats()
if stats.WorkCycles() == 0 {
t.Error("Expected some work cycles")
}
}
// TestWork_PendingWork verifies that pending work is executed after current work completes
func TestWork_PendingWork(t *testing.T) {
config := NewTestConfigMap()
testConfig, _ := GetTestConfig(config)
testConfig.WorkDuration = 100 * time.Millisecond // Make work take time
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(1*time.Hour), // Very long interval
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Send first signal to start work
if err := runner.Signal(); err != nil {
t.Fatalf("First signal failed: %v", err)
}
// Wait a bit for work to start
time.Sleep(25 * time.Millisecond)
// Send more signals while work is in progress
for i := 0; i < 3; i++ {
if err := runner.Signal(); err != nil {
t.Fatalf("Signal %d failed: %v", i, err)
}
}
// Wait for all work to complete
time.Sleep(500 * time.Millisecond)
// Should have at least 2 work cycles (initial + pending)
stats := runner.Stats()
if stats.WorkCycles() < 2 {
t.Errorf("Expected at least 2 work cycles for pending work, got %d", stats.WorkCycles())
}
if testConfig.Counter.Load() < 2 {
t.Errorf("Expected at least 2 counter increments, got %d", testConfig.Counter.Load())
}
}
// TestWork_ErrorHandling verifies that work function errors are handled gracefully
func TestWork_ErrorHandling(t *testing.T) {
config := NewTestConfigMap()
runner, err := Initialize(
config,
slog.Default(),
ErrorWorkFunc, // Always returns error
WithInterval(1*time.Hour),
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Signal work that will error
if err := runner.Signal(); err != nil {
t.Fatalf("Signal failed: %v", err)
}
// Wait for work to complete
if !WaitForWorkCycles(runner, 1, 500*time.Millisecond) {
t.Fatal("Expected at least 1 work cycle")
}
// Check that error is recorded in stats
stats := runner.Stats()
if stats.LastErr() == nil {
t.Error("Expected error to be recorded in stats")
}
// Runner should still be functional after error
if err := runner.Signal(); err != nil {
t.Fatalf("Signal after error failed: %v", err)
}
if !WaitForWorkCycles(runner, 2, 500*time.Millisecond) {
t.Fatal("Expected runner to continue working after error")
}
}
// TestWork_ContextCancellation verifies that work functions receive cancellation signals
func TestWork_ContextCancellation(t *testing.T) {
config := NewTestConfigMap()
testConfig, _ := GetTestConfig(config)
testConfig.WorkDuration = 1 * time.Second // Long duration
testConfig.RespectContext = true // Make it respect context
runner, err := Initialize(
config,
slog.Default(),
TestWorkFunc,
WithInterval(1*time.Hour),
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
// Signal work to start
if err := runner.Signal(); err != nil {
t.Fatalf("Signal failed: %v", err)
}
// Let work start
time.Sleep(50 * time.Millisecond)
// Shutdown while work is in progress
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
start := time.Now()
if err := runner.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown failed: %v", err)
}
elapsed := time.Since(start)
// Should shutdown quickly due to context cancellation, not wait for full work duration
if elapsed > 500*time.Millisecond {
t.Errorf("Shutdown took too long (%v), context cancellation may not be working", elapsed)
}
// Work should have been attempted
stats := runner.Stats()
if stats.WorkCycles() == 0 {
t.Error("Expected at least one work cycle to start")
}
}
// TestMultipleSignalsRapidly verifies handling of many rapid signals
func TestMultipleSignalsRapidly(t *testing.T) {
config := NewTestConfigMap()
runner, err := Initialize(
config,
slog.Default(),
FastWorkFunc, // Very fast work function
WithInterval(1*time.Hour),
)
if err != nil {
t.Fatalf("Failed to initialize runner: %v", err)
}
if err := runner.Run(); err != nil {
t.Fatalf("Failed to start runner: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
if err := runner.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}()
// Send many signals from multiple goroutines
const numGoroutines = 10
const signalsPerGoroutine = 10
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < signalsPerGoroutine; j++ {
if err := runner.Signal(); err != nil && err != ErrStopped {
t.Errorf("Signal failed: %v", err)
}
}
}()
}
wg.Wait()
// Wait for work to settle
time.Sleep(100 * time.Millisecond)
// Should have processed some work without crashing
stats := runner.Stats()
if stats.WorkCycles() == 0 {
t.Error("Expected some work cycles")
}
// Max concurrent should still be 1
if stats.MaxConcurrent() > 1 {
t.Errorf("Expected max concurrent to be 1, got %d", stats.MaxConcurrent())
}
}