fiber/services_test.go
Manuel de la Peña 80db4de1a5
🔥 feat: Add Support for service dependencies (#3434)
* feat: support for starting devtime dependencies in an abstract manner

* feat: support for starting devtime dependencies in an abstract manner

* fix: spell

* fix: lint

* fix: markdown lint

* fix: b.Helper

* fix: lint spell

* fix: field padding

* chore: protect the usage of dev dependencies with the "dev" build tag

* fix: error message

* docs: fix type name

* fix: mock context cancellation

* docs: simpler

* fix: lint unused receiver

* fix: handle error in benchmarks

* lint: remove build tag

* fix: wrap error

* fix: lint

* fix: explain why lint exclusion

* chore: best effort while terminating dependencies

* gix: lintern name

* fix: reduce flakiness in tests

* chore: get dependency state for logs

* chore: protect dev time tests and benchmarks under build tag

* chore: add build tag in more places

* fix: more conservative context cancellation timeout in tests

* chore: remove build tags

* chore: rename to Services

* fix: update tests

* fix: lint

* fix: lint

* fix: apply coderrabit suggestion

* chore: add more unit tests

* chore: add more unit tests

* chore: refactor tests

* fix: avoid control flags in tests

* chore: consistent error message in start

* chore: simplify error logic

* chore: remove flag coupling

* chore: simplify benchmarks

* chore: add corerabbit suggetion

* fix: wording

* chore: log error on service termination

* docs: wording

* fix: typo in error message

* fix: wording

* fix: panic on startup error

* chore: store started services separately, so that we can terminate them properly

* docs: update example

* fix: use context provider instead of storing the context

* chore: use require.Empty

* fix: no tabs in docs

* chore: move field for better alignment

* docs: do not use interface as method receiver

* docs: proper usage of JSON bind

* fix: use startup context for bootstrap log

* chore: move happy path to the left

* fix: use configured consistently

* chore: terminate started services in reverse order

* fix: consistent access to the config context

* chore: test names and benchmarks location

* chore: benchmark refinement

* chore: store the services into the global State

* chore: add functions to access the Services in the state

* chore: hex-encode the hashes

* chore: consistent var name for services

* chore: non racey service initialisation

* fix: wrong range iteration in service keys

* fix: use inline

* chore: more tests for the generics functions for services

* chore: add benchmarks for service functions

* fix: benchmarks refactor was wrong

* fix. refine error message

* fix: do not cause overhead in newState, instead pre-calculate the prefix hash at init

* chore: simplify hashing

* chore: use smaller, and testable function for initServices

* chore: initialize services in the app.init

* chore: init services before blocking the app init

* Revert "chore: init services before blocking the app init"

This reverts commit bb67cf6380cb71ad5ae4ab4807cdfbf0c7eafa1b.

* chore: move happy path to the left at initServices

* fix: register shutdown hooks for services after app's mutext is unlocked

---------

Co-authored-by: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
2025-05-19 14:35:13 +02:00

750 lines
18 KiB
Go

package fiber
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v3/log"
"github.com/stretchr/testify/require"
)
const (
terminateErrorMessage = "terminate error"
startErrorMessage = "start error"
)
// mockService implements Service interface for testing
type mockService struct {
startError error
terminateError error
stateError error
name string
started bool
terminated bool
startDelay time.Duration
terminateDelay time.Duration
}
func (m *mockService) Start(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("context canceled: %w", ctx.Err())
default:
}
if m.startDelay > 0 {
timer := time.NewTimer(m.startDelay)
select {
case <-ctx.Done():
timer.Stop()
return fmt.Errorf("context canceled: %w", ctx.Err())
case <-timer.C:
// Continue after delay
}
}
if m.startError != nil {
m.started = false
return m.startError
}
m.started = true
return nil
}
func (m *mockService) String() string {
return m.name
}
func (m *mockService) State(ctx context.Context) (string, error) {
if ctx.Err() != nil {
return "", fmt.Errorf("context canceled: %w", ctx.Err())
}
if m.stateError != nil {
return "error", m.stateError
}
if m.started {
return "running", nil
}
if m.terminated {
return "stopped", nil
}
return "unknown", nil
}
func (m *mockService) Terminate(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("context canceled: %w", ctx.Err())
default:
}
if m.terminateDelay > 0 {
timer := time.NewTimer(m.terminateDelay)
select {
case <-ctx.Done():
timer.Stop()
return fmt.Errorf("context canceled: %w", ctx.Err())
case <-timer.C:
// Continue after delay
}
}
if m.terminateError != nil {
m.terminated = false
return m.terminateError
}
m.started = false
m.terminated = true
return nil
}
func Test_HasConfiguredServices(t *testing.T) {
testHasConfiguredServicesFn := func(t *testing.T, app *App, expected bool) {
t.Helper()
result := app.hasConfiguredServices()
require.Equal(t, expected, result)
}
t.Run("no-services", func(t *testing.T) {
testHasConfiguredServicesFn(t, &App{configured: Config{}}, false)
})
t.Run("has-services", func(t *testing.T) {
testHasConfiguredServicesFn(t, &App{configured: Config{Services: []Service{&mockService{name: "test-dep"}}}}, true)
})
}
func Test_InitServices(t *testing.T) {
t.Run("no-services", func(t *testing.T) {
app := &App{configured: Config{}}
require.NotPanics(t, app.initServices)
})
t.Run("start/success", func(t *testing.T) {
// Initialize the app using the struct and defining the state and hooks manually,
// because we are not checking the shutdown hooks in this test.
app := &App{
configured: Config{
Services: []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2"},
},
},
state: newState(),
}
app.hooks = newHooks(app)
require.NotPanics(t, app.initServices)
})
t.Run("start/error", func(t *testing.T) {
// Initialize the app using the struct and defining the state and hooks manually,
// because we are not checking the shutdown hooks in this test.
app := &App{
configured: Config{
Services: []Service{
&mockService{name: "dep1", startError: errors.New(startErrorMessage + " 1")},
&mockService{name: "dep2", startError: errors.New(startErrorMessage + " 2")},
&mockService{name: "dep3"},
},
},
state: newState(),
}
app.hooks = newHooks(app)
require.Panics(t, app.initServices)
})
t.Run("shutdown-hooks/success", func(t *testing.T) {
// Initialize the app using the New function to verify that the shutdown hooks are registered
// and the app mutex is not causing a deadlock.
app := New(Config{
Services: []Service{&mockService{name: "dep1"}},
})
require.NotPanics(t, app.initServices)
type stringsLogger struct {
strings.Builder
}
var buf stringsLogger
log.SetOutput(&buf)
app.Hooks().executeOnPostShutdownHooks(nil)
require.NotContains(t, buf.String(), "failed to call post shutdown hook:")
})
t.Run("shutdown-hooks/error", func(t *testing.T) {
// Initialize the app using the New function to verify that the shutdown hooks are registered
// and the app mutex is not causing a deadlock.
app := New(Config{
Services: []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2", terminateError: errors.New(terminateErrorMessage + " 2")},
},
})
require.NotPanics(t, app.initServices)
type stringsLogger struct {
strings.Builder
}
var buf stringsLogger
log.SetOutput(&buf)
app.Hooks().executeOnPostShutdownHooks(nil)
require.Contains(t, buf.String(), "failed to shutdown services: service dep2 terminate: terminate error 2")
})
}
func Test_StartServices(t *testing.T) {
t.Run("no-services", func(t *testing.T) {
app := &App{
configured: Config{
Services: []Service{},
},
state: newState(),
}
err := app.startServices(context.Background())
require.NoError(t, err)
require.Zero(t, app.state.ServicesLen())
})
t.Run("successful-start", func(t *testing.T) {
app := &App{
configured: Config{
Services: []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2"},
},
},
state: newState(),
}
err := app.startServices(context.Background())
require.NoError(t, err)
require.Equal(t, 2, app.state.ServicesLen())
})
t.Run("failed-start", func(t *testing.T) {
app := &App{
configured: Config{
Services: []Service{
&mockService{name: "dep1", startError: errors.New(startErrorMessage + " 1")},
&mockService{name: "dep2", startError: errors.New(startErrorMessage + " 2")},
&mockService{name: "dep3"},
},
},
state: newState(),
}
err := app.startServices(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), startErrorMessage+" 1")
require.Contains(t, err.Error(), startErrorMessage+" 2")
require.Equal(t, 1, app.state.ServicesLen())
})
t.Run("context", func(t *testing.T) {
t.Run("already-canceled", func(t *testing.T) {
app := &App{
configured: Config{
Services: []Service{
&mockService{name: "dep1"},
},
},
state: newState(),
}
// Create a context that is already canceled
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := app.startServices(ctx)
require.ErrorIs(t, err, context.Canceled)
require.Zero(t, app.state.ServicesLen())
})
t.Run("cancellation", func(t *testing.T) {
// Create a service that takes some time to start
slowDep := &mockService{name: "slow-dep", startDelay: 200 * time.Millisecond}
app := &App{
configured: Config{
Services: []Service{slowDep},
},
state: newState(),
}
// Create a context that will be canceled immediately
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Start services with a delay that is longer than the timeout
err := app.startServices(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Zero(t, app.state.ServicesLen())
})
})
}
func Test_ShutdownServices(t *testing.T) {
t.Run("no-services", func(t *testing.T) {
app := &App{
configured: Config{
Services: []Service{},
},
state: newState(),
}
err := app.shutdownServices(context.Background())
require.NoError(t, err)
require.Zero(t, app.state.ServicesLen())
})
t.Run("successful-shutdown", func(t *testing.T) {
srv1 := &mockService{name: "dep1"}
srv2 := &mockService{name: "dep2"}
// Expected state, including the two started services
expectedState := newState()
expectedState.setService(srv1)
expectedState.setService(srv2)
app := &App{
configured: Config{
Services: []Service{srv1, srv2},
},
state: expectedState,
}
err := app.shutdownServices(context.Background())
require.NoError(t, err)
require.Zero(t, app.state.ServicesLen())
})
t.Run("failed-shutdown", func(t *testing.T) {
srv1 := &mockService{name: "dep1", terminateError: errors.New(terminateErrorMessage + " 1")}
srv2 := &mockService{name: "dep2", terminateError: errors.New(terminateErrorMessage + " 2")}
srv3 := &mockService{name: "dep3"}
// Expected state, including the two started services
expectedState := newState()
expectedState.setService(srv1)
expectedState.setService(srv2)
expectedState.setService(srv3)
app := &App{
configured: Config{
Services: []Service{srv1, srv2, srv3},
},
state: expectedState,
}
err := app.shutdownServices(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), terminateErrorMessage+" 1")
require.Contains(t, err.Error(), terminateErrorMessage+" 2")
require.Equal(t, 2, app.state.ServicesLen()) // 2 services failed to terminate
})
t.Run("context", func(t *testing.T) {
t.Run("already-canceled", func(t *testing.T) {
srv1 := &mockService{name: "dep1"}
// Expected state, including the two started services
expectedState := newState()
expectedState.setService(srv1)
app := &App{
configured: Config{
Services: []Service{srv1},
},
state: expectedState,
}
// Create a context that is already canceled
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := app.shutdownServices(ctx)
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
require.Contains(t, err.Error(), "service dep1 terminate")
require.Equal(t, 1, app.state.ServicesLen())
})
t.Run("cancellation", func(t *testing.T) {
// Create a service that takes some time to terminate
slowDep := &mockService{name: "slow-dep", terminateDelay: 200 * time.Millisecond}
// Expected state, including the two started services
expectedState := newState()
expectedState.setService(slowDep)
app := &App{
configured: Config{
Services: []Service{slowDep},
},
state: expectedState,
}
// Create a new context for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Shutdown services with canceled context
err := app.shutdownServices(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Equal(t, 1, app.state.ServicesLen())
})
})
}
func Test_LogServices(t *testing.T) {
// Service with successful State
runningService := &mockService{name: "running", started: true}
// Service with State error
errorService := &mockService{name: "error", stateError: errors.New("state error")}
expectedState := newState()
expectedState.setService(runningService)
expectedState.setService(errorService)
app := &App{
configured: Config{
Services: []Service{runningService, errorService},
},
state: expectedState,
}
var buf bytes.Buffer
colors := Colors{
Green: "\033[32m",
Reset: "\033[0m",
Blue: "\033[34m",
Red: "\033[31m",
}
app.logServices(context.Background(), &buf, colors)
output := buf.String()
for _, srv := range app.state.Services() {
stateColor := colors.Blue
state := "RUNNING"
if _, err := srv.State(context.Background()); err != nil {
stateColor = colors.Red
state = "ERROR"
}
expected := fmt.Sprintf("%sINFO%s 🥡 %s[ %s ] %s%s\n", colors.Green, colors.Reset, stateColor, strings.ToUpper(state), srv.String(), colors.Reset)
require.Contains(t, output, expected)
}
}
func Test_ServiceContextProviders(t *testing.T) {
t.Run("no-provider", func(t *testing.T) {
app := &App{
configured: Config{},
state: newState(),
}
require.Equal(t, context.Background(), app.servicesStartupCtx())
require.Equal(t, context.Background(), app.servicesShutdownCtx())
})
t.Run("with-provider", func(t *testing.T) {
ctx := context.TODO()
app := &App{
configured: Config{
ServicesStartupContextProvider: func() context.Context {
return ctx
},
ServicesShutdownContextProvider: func() context.Context {
return ctx
},
},
state: newState(),
}
require.Equal(t, ctx, app.servicesStartupCtx())
require.Equal(t, ctx, app.servicesShutdownCtx())
})
}
func Benchmark_StartServices(b *testing.B) {
benchmarkFn := func(b *testing.B, services []Service) {
b.Helper()
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: services,
})
ctx := context.Background()
if err := app.startServices(ctx); err != nil {
b.Fatal("Expected no error but got", err)
}
}
}
b.Run("no-services", func(b *testing.B) {
benchmarkFn(b, []Service{})
})
b.Run("single-service", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
})
})
b.Run("multiple-services", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2"},
&mockService{name: "dep3"},
})
})
b.Run("multiple-services-with-delays", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", startDelay: 1 * time.Millisecond},
&mockService{name: "dep2", startDelay: 2 * time.Millisecond},
&mockService{name: "dep3", startDelay: 3 * time.Millisecond},
})
})
}
func Benchmark_ShutdownServices(b *testing.B) {
benchmarkFn := func(b *testing.B, services []Service) {
b.Helper()
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: services,
})
ctx := context.Background()
if err := app.shutdownServices(ctx); err != nil {
b.Fatal("Expected no error but got", err)
}
}
}
b.Run("no-services", func(b *testing.B) {
benchmarkFn(b, []Service{})
})
b.Run("single-service", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
})
})
b.Run("multiple-services", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2"},
&mockService{name: "dep3"},
})
})
b.Run("multiple-services-with-delays", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", terminateDelay: 1 * time.Millisecond},
&mockService{name: "dep2", terminateDelay: 2 * time.Millisecond},
&mockService{name: "dep3", terminateDelay: 3 * time.Millisecond},
})
})
}
func Benchmark_StartServices_withContextCancellation(b *testing.B) {
benchmarkFn := func(b *testing.B, services []Service, timeout time.Duration) {
b.Helper()
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: services,
})
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := app.startServices(ctx)
// We expect an error here due to the short timeout
if err == nil && timeout < time.Second {
b.Fatal("Expected error due to context cancellation but got none")
}
cancel()
}
}
b.Run("single-service/immediate-cancellation", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", startDelay: 100 * time.Millisecond},
}, 10*time.Millisecond)
})
b.Run("multiple-services/immediate-cancellation", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", startDelay: 100 * time.Millisecond},
&mockService{name: "dep2", startDelay: 200 * time.Millisecond},
&mockService{name: "dep3", startDelay: 300 * time.Millisecond},
}, 10*time.Millisecond)
})
b.Run("multiple-services/successful-completion", func(b *testing.B) {
const timeout = 500 * time.Millisecond
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: []Service{
&mockService{name: "dep1", startDelay: 10 * time.Millisecond},
&mockService{name: "dep2", startDelay: 20 * time.Millisecond},
&mockService{name: "dep3", startDelay: 30 * time.Millisecond},
},
})
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := app.startServices(ctx)
if err != nil {
b.Fatal("Expected no error but got", err)
}
cancel()
}
})
}
func Benchmark_ShutdownServices_withContextCancellation(b *testing.B) {
benchmarkFn := func(b *testing.B, services []Service, timeout time.Duration) {
b.Helper()
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: services,
})
err := app.startServices(context.Background())
if err != nil {
b.Fatal("Expected no error during startup but got", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err = app.shutdownServices(ctx)
// We expect an error here due to the short timeout
if err == nil && timeout < time.Second {
b.Fatal("Expected error due to context cancellation but got none")
}
cancel()
}
}
b.Run("single-service/immediate-cancellation", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", terminateDelay: 100 * time.Millisecond},
}, 10*time.Millisecond)
})
b.Run("multiple-services/immediate-cancellation", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1", terminateDelay: 100 * time.Millisecond},
&mockService{name: "dep2", terminateDelay: 200 * time.Millisecond},
&mockService{name: "dep3", terminateDelay: 300 * time.Millisecond},
}, 10*time.Millisecond)
})
b.Run("multiple-services/successful-completion", func(b *testing.B) {
const timeout = 500 * time.Millisecond
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(Config{
Services: []Service{
&mockService{name: "dep1", terminateDelay: 10 * time.Millisecond},
&mockService{name: "dep2", terminateDelay: 20 * time.Millisecond},
&mockService{name: "dep3", terminateDelay: 30 * time.Millisecond},
},
})
err := app.startServices(context.Background())
if err != nil {
b.Fatal("Expected no error but got", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err = app.shutdownServices(ctx)
if err != nil {
b.Fatal("Expected no error but got", err)
}
cancel()
}
})
}
func Benchmark_ServicesMemory(b *testing.B) {
benchmarkFn := func(b *testing.B, services []Service) {
b.Helper()
b.ResetTimer()
b.ReportAllocs()
var err error
for i := 0; i < b.N; i++ {
app := New(Config{
Services: services,
})
ctx := context.Background()
err = app.startServices(ctx)
if err != nil {
continue
}
err = app.shutdownServices(ctx)
}
require.NoError(b, err)
}
b.Run("no-services", func(b *testing.B) {
benchmarkFn(b, []Service{})
})
b.Run("single-service", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
})
})
b.Run("multiple-services", func(b *testing.B) {
benchmarkFn(b, []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2"},
&mockService{name: "dep3"},
})
})
}