diff --git a/.github/workflows/auto-bump-modules.yml b/.github/workflows/auto-bump-modules.yml index a0f52f6a..d1b87844 100644 --- a/.github/workflows/auto-bump-modules.yml +++ b/.github/workflows/auto-bump-modules.yml @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Determine version diff --git a/.github/workflows/bdd-matrix.yml b/.github/workflows/bdd-matrix.yml index 17dc404e..16a0d109 100644 --- a/.github/workflows/bdd-matrix.yml +++ b/.github/workflows/bdd-matrix.yml @@ -21,7 +21,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: # Discover modules (reused for matrix) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f26ff5ba..d130ea2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: branches: [ main ] env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: test: diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 27dca2a5..9f27003b 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -22,7 +22,7 @@ on: default: 'patch' env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' permissions: contents: write diff --git a/.github/workflows/contract-check.yml b/.github/workflows/contract-check.yml index ba7eae7b..47d394da 100644 --- a/.github/workflows/contract-check.yml +++ b/.github/workflows/contract-check.yml @@ -16,7 +16,7 @@ permissions: actions: read env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: contract-check: @@ -59,7 +59,7 @@ jobs: - name: Checkout PR branch run: | - git checkout ${{ github.head_ref }} + git checkout ${{ github.event.pull_request.head.sha }} - name: Extract contracts from PR branch run: | diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 7593d425..5132f7b8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' cache-dependency-path: go.sum # Install Go dependencies and development tools diff --git a/.github/workflows/examples-ci.yml b/.github/workflows/examples-ci.yml index 16a485fe..5e908ba3 100644 --- a/.github/workflows/examples-ci.yml +++ b/.github/workflows/examples-ci.yml @@ -12,7 +12,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: validate-examples: @@ -448,10 +448,10 @@ jobs: echo "🔍 Verifying go.mod configuration for ${{ matrix.example }}..." - # Check that replace directives point to correct paths - if ! grep -q "replace.*=> ../../" go.mod; then + # Check that replace directives point to correct local paths + if ! grep -q "replace.*=> \.\./\.\." go.mod; then echo "❌ Missing or incorrect replace directive in ${{ matrix.example }}/go.mod" - echo "Expected: replace github.com/GoCodeAlone/modular => ../../" + echo "Expected: replace github.com/GoCodeAlone/modular => ../.." cat go.mod exit 1 fi diff --git a/.github/workflows/module-release.yml b/.github/workflows/module-release.yml index b7986bc6..f8990908 100644 --- a/.github/workflows/module-release.yml +++ b/.github/workflows/module-release.yml @@ -138,7 +138,7 @@ jobs: if: steps.skipcheck.outputs.changed == 'true' uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli diff --git a/.github/workflows/modules-ci.yml b/.github/workflows/modules-ci.yml index 565c1404..c6b8b942 100644 --- a/.github/workflows/modules-ci.yml +++ b/.github/workflows/modules-ci.yml @@ -20,7 +20,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: # This job identifies which modules have been modified diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml index 308c45ac..222bc52f 100644 --- a/.github/workflows/release-all.yml +++ b/.github/workflows/release-all.yml @@ -172,7 +172,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | @@ -275,7 +275,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | @@ -354,7 +354,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ec4ffe1..45b21ed5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: if: steps.detect.outputs.core_changed == 'true' uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli diff --git a/application.go b/application.go index 854f2285..c73a3157 100644 --- a/application.go +++ b/application.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "maps" "os" "os/signal" "reflect" "slices" "strings" + "sync" + "sync/atomic" "syscall" "time" ) @@ -254,6 +257,21 @@ type Application interface { OnConfigLoaded(hook func(app Application) error) } +// PhaseAware is an optional interface for applications that expose lifecycle phase tracking. +type PhaseAware interface { + Phase() AppPhase +} + +// ReloadableApp is an optional interface for applications that support dynamic config reload. +type ReloadableApp interface { + RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error +} + +// MetricsCollector is an optional interface for applications that aggregate module metrics. +type MetricsCollector interface { + CollectAllMetrics(ctx context.Context) []ModuleMetrics +} + // TenantApplication extends Application with multi-tenant functionality. // This interface adds tenant-aware capabilities to the standard Application, // allowing the same application instance to serve multiple tenants with @@ -336,6 +354,14 @@ type StdApplication struct { configFeeders []Feeder // Optional per-application feeders (override global ConfigFeeders if non-nil) startTime time.Time // Tracks when the application was started configLoadedHooks []func(Application) error // Hooks to run after config loading but before module initialization + dependencyHints []DependencyEdge // Config-driven dependency edges injected via WithModuleDependency + drainTimeout time.Duration // Timeout for pre-stop drain phase + phase atomic.Int32 // Current lifecycle phase (AppPhase) + parallelInit bool // Enable parallel module initialization at same topo depth + initMu sync.Mutex // Guards SetCurrentModule/ClearCurrentModule in parallel init + dynamicReload bool // Enable dynamic reload orchestrator + reloadOrchestrator *ReloadOrchestrator // Coordinates config reload across Reloadable modules + phaseChangeHook func(old, new AppPhase) // Optional hook called on phase transitions (used by ObservableApplication) } // NewStdApplication creates a new application instance with the provided configuration and logger. @@ -482,7 +508,7 @@ func (app *StdApplication) GetService(name string, target any) error { } targetValue := reflect.ValueOf(target) - if targetValue.Kind() != reflect.Ptr || targetValue.IsNil() { + if targetValue.Kind() != reflect.Pointer || targetValue.IsNil() { return ErrTargetNotPointer } @@ -517,7 +543,7 @@ func (app *StdApplication) GetService(name string, target any) error { if serviceType.AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service)) return nil - } else if serviceType.Kind() == reflect.Ptr && serviceType.Elem().AssignableTo(targetType) { + } else if serviceType.Kind() == reflect.Pointer && serviceType.Elem().AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service).Elem()) return nil } @@ -526,6 +552,110 @@ func (app *StdApplication) GetService(name string, target any) error { ErrServiceIncompatible, name, serviceType, targetType) } +// Phase returns the current lifecycle phase of the application. +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + old := AppPhase(app.phase.Swap(int32(p))) + if app.phaseChangeHook != nil { + app.phaseChangeHook(old, p) + } +} + +// computeDepthLevels groups module names from a topological order into levels +// where modules at the same level have no dependencies on each other and can +// be initialized concurrently. The graph parameter is the fully resolved +// dependency graph (including implicit service dependencies) from resolveDependencies. +func computeDepthLevels(order []string, graph map[string][]string) [][]string { + // Build dependency set per module from the full resolved graph + deps := make(map[string]map[string]bool) + for _, name := range order { + deps[name] = make(map[string]bool) + for _, d := range graph[name] { + deps[name][d] = true + } + } + + placed := make(map[string]bool) + var levels [][]string + + for len(placed) < len(order) { + var level []string + for _, name := range order { + if placed[name] { + continue + } + // Check if all deps are placed + ready := true + for dep := range deps[name] { + if !placed[dep] { + ready = false + break + } + } + if ready { + level = append(level, name) + } + } + if len(level) == 0 { + // No progress — remaining modules have unresolvable dependencies + break + } + for _, name := range level { + placed[name] = true + } + levels = append(levels, level) + } + return levels +} + +// initModule initializes a single module: injects services, calls Init, registers provided services. +// Thread-safe for parallel init: uses RegisterServiceForModule to associate services with the +// correct module without relying on the shared currentModule field. +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + app.initMu.Lock() + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + var err error + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + app.initMu.Unlock() + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + app.initMu.Unlock() + + if err := module.Init(appToPass); err != nil { + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + // Register provided services with explicit module association (no shared state). + if sa, ok := module.(ServiceAware); ok { + for _, svc := range sa.ProvidesServices() { + if app.enhancedSvcRegistry != nil { + actualName, err := app.enhancedSvcRegistry.RegisterServiceForModule(svc.Name, svc.Instance, module) + if err != nil { + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + app.initMu.Lock() + app.svcRegistry[actualName] = svc.Instance + app.initMu.Unlock() + } else { + if err := app.RegisterService(svc.Name, svc.Instance); err != nil { + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, module)) + return nil +} + // Init initializes the application with the provided modules func (app *StdApplication) Init() error { return app.InitWithApp(app) @@ -543,6 +673,8 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { return nil } + app.setPhase(PhaseInitializing) + errs := make([]error, 0) for name, module := range app.moduleRegistry { configurableModule, ok := module.(Configurable) @@ -583,52 +715,44 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { } // Build dependency graph - moduleOrder, err := app.resolveDependencies() + moduleOrder, depGraph, err := app.resolveDependencies() if err != nil { errs = append(errs, fmt.Errorf("failed to resolve module dependencies: %w", err)) } // Initialize modules in order - for _, moduleName := range moduleOrder { - module := app.moduleRegistry[moduleName] - - if _, ok := module.(ServiceAware); ok { - // Inject required services - app.moduleRegistry[moduleName], err = app.injectServices(module) - if err != nil { - errs = append(errs, fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err)) - continue - } - module = app.moduleRegistry[moduleName] // Update reference after injection - } - - // Set current module context for service registration tracking - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.SetCurrentModule(module) - } - - if err = module.Init(appToPass); err != nil { - errs = append(errs, fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err)) - continue - } - - if _, ok := module.(ServiceAware); ok { - // Register services provided by modules - for _, svc := range module.(ServiceAware).ProvidesServices() { - if err = app.RegisterService(svc.Name, svc.Instance); err != nil { - // Collect registration errors (e.g., duplicates) for reporting - errs = append(errs, fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err)) - continue + if app.parallelInit { + // Parallel init: group modules by topological depth and init each level concurrently + levels := computeDepthLevels(moduleOrder, depGraph) + for _, level := range levels { + if len(level) == 1 { + if initErr := app.initModule(appToPass, level[0]); initErr != nil { + errs = append(errs, initErr) + } + } else { + var wg sync.WaitGroup + var mu sync.Mutex + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if initErr := app.initModule(appToPass, name); initErr != nil { + mu.Lock() + errs = append(errs, initErr) + mu.Unlock() + } + }(moduleName) } + wg.Wait() } } - - // Clear current module context - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.ClearCurrentModule() + } else { + // Sequential init (original behavior) + for _, moduleName := range moduleOrder { + if initErr := app.initModule(appToPass, moduleName); initErr != nil { + errs = append(errs, initErr) + } } - - app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) } // Initialize tenant configuration after modules have registered their configurations @@ -636,9 +760,24 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { errs = append(errs, fmt.Errorf("failed to initialize tenant configurations: %w", err)) } + // Wire up the ReloadOrchestrator if dynamic reload is enabled + if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } + } + // Mark as initialized only after completing Init flow if len(errs) == 0 { app.initialized = true + app.setPhase(PhaseInitialized) } return errors.Join(errs...) @@ -676,6 +815,8 @@ func (app *StdApplication) initTenantConfigurations() error { // Start starts the application func (app *StdApplication) Start() error { + app.setPhase(PhaseStarting) + // Record the start time app.startTime = time.Now() @@ -685,7 +826,7 @@ func (app *StdApplication) Start() error { app.cancel = cancel // Start modules in dependency order - modules, err := app.resolveDependencies() + modules, _, err := app.resolveDependencies() if err != nil { return err } @@ -703,13 +844,24 @@ func (app *StdApplication) Start() error { } } + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) + } + + app.setPhase(PhaseRunning) return nil } // Stop stops the application func (app *StdApplication) Stop() error { + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() + } + + app.setPhase(PhaseDraining) + // Get modules in reverse dependency order - modules, err := app.resolveDependencies() + modules, _, err := app.resolveDependencies() if err != nil { return err } @@ -717,7 +869,27 @@ func (app *StdApplication) Stop() error { // Reverse the slice slices.Reverse(modules) - // Create timeout context for shutdown + // Phase 1: Drain + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + if drainable, ok := module.(Drainable); ok { + app.logger.Info("Draining module", "module", name) + if err := drainable.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + } + + app.setPhase(PhaseStopping) + + // Phase 2: Stop ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -727,7 +899,6 @@ func (app *StdApplication) Stop() error { module := app.moduleRegistry[name] stoppableModule, ok := module.(Stoppable) if !ok { - app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) continue } app.logger.Info("Stopping module", "module", name) @@ -742,9 +913,19 @@ func (app *StdApplication) Stop() error { app.cancel() } + app.setPhase(PhaseStopped) return lastErr } +// RequestReload enqueues a configuration reload request with the ReloadOrchestrator. +// Returns an error if dynamic reload was not enabled via WithDynamicReload(). +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return ErrDynamicReloadNotEnabled + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} + // Run starts the application and blocks until termination func (app *StdApplication) Run() error { // Initialize @@ -1079,7 +1260,7 @@ func (e EdgeType) String() string { } // resolveDependencies returns modules in initialization order -func (app *StdApplication) resolveDependencies() ([]string, error) { +func (app *StdApplication) resolveDependencies() ([]string, map[string][]string, error) { // Create dependency graph and track dependency edges graph := make(map[string][]string) dependencyEdges := make([]DependencyEdge, 0) @@ -1103,6 +1284,18 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { } } + // Merge config-driven dependency hints (validate both endpoints exist) + for _, hint := range app.dependencyHints { + if _, ok := app.moduleRegistry[hint.From]; !ok { + return nil, nil, fmt.Errorf("dependency hint from %q: %w", hint.From, ErrModuleDependencyMissing) + } + if _, ok := app.moduleRegistry[hint.To]; !ok { + return nil, nil, fmt.Errorf("dependency hint to %q: %w", hint.To, ErrModuleDependencyMissing) + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) + } + // Analyze service dependencies to augment the graph with implicit dependencies serviceEdges := app.addImplicitDependencies(graph) dependencyEdges = append(dependencyEdges, serviceEdges...) @@ -1182,7 +1375,7 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { for _, node := range nodes { if !visited[node] { if err := visit(node); err != nil { - return nil, err + return nil, nil, err } } } @@ -1190,7 +1383,7 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { // log result app.logger.Debug("Module initialization order", "order", result) - return result, nil + return result, graph, nil } // constructCyclePath constructs a detailed cycle path showing the dependency chain @@ -1434,7 +1627,7 @@ func (app *StdApplication) typeImplementsInterface(svcType, interfaceType reflec if svcType.Implements(interfaceType) { return true } - if svcType.Kind() == reflect.Ptr { + if svcType.Kind() == reflect.Pointer { et := svcType.Elem() if et != nil && et.Implements(interfaceType) { return true @@ -1496,10 +1689,8 @@ func (app *StdApplication) addNameBasedDependency( } // Check if dependency already exists - for _, existingDep := range graph[consumerName] { - if existingDep == providerModule { - return nil // Already exists - } + if slices.Contains(graph[consumerName], providerModule) { + return nil // Already exists } // Add the dependency @@ -1549,10 +1740,8 @@ func (app *StdApplication) addInterfaceBasedDependencyWithTypeInfo(match Interfa app.logger.Debug("Adding required self interface dependency to expose unsatisfiable self-requirement", "module", match.Consumer, "interface", match.InterfaceType.Name(), "service", match.ServiceName) } // Check if this dependency already exists - for _, existingDep := range graph[match.Consumer] { - if existingDep == match.Provider { - return nil - } + if slices.Contains(graph[match.Consumer], match.Provider) { + return nil } // Add the dependency (including self-dependencies for cycle detection) @@ -1644,12 +1833,29 @@ func (app *StdApplication) GetModule(name string) Module { // Returns a copy to prevent external modification of the module registry. func (app *StdApplication) GetAllModules() map[string]Module { result := make(map[string]Module, len(app.moduleRegistry)) - for k, v := range app.moduleRegistry { - result[k] = v - } + maps.Copy(result, app.moduleRegistry) return result } +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + // Snapshot module registry under lock to avoid races with parallel init. + app.initMu.Lock() + modules := make([]Module, 0, len(app.moduleRegistry)) + for _, module := range app.moduleRegistry { + modules = append(modules, module) + } + app.initMu.Unlock() + + var results []ModuleMetrics + for _, module := range modules { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} + // OnConfigLoaded registers a callback to run after config loading but before module initialization. // This allows reconfiguring dependencies based on loaded configuration values. // Multiple hooks can be registered and will be executed in registration order. diff --git a/application_lifecycle_bdd_test.go b/application_lifecycle_bdd_test.go index 213ad8ba..80fbed44 100644 --- a/application_lifecycle_bdd_test.go +++ b/application_lifecycle_bdd_test.go @@ -37,7 +37,7 @@ type BDDTestContext struct { startError error stopError error moduleStates map[string]bool - servicesFound map[string]interface{} + servicesFound map[string]any } // Test modules for BDD scenarios @@ -87,7 +87,7 @@ type MockTestService struct{} type ConsumerTestModule struct { SimpleTestModule - receivedService interface{} + receivedService any } func (m *ConsumerTestModule) Init(app Application) error { @@ -121,7 +121,7 @@ func (ctx *BDDTestContext) resetContext() { ctx.startError = nil ctx.stopError = nil ctx.moduleStates = make(map[string]bool) - ctx.servicesFound = make(map[string]interface{}) + ctx.servicesFound = make(map[string]any) } func (ctx *BDDTestContext) iHaveANewModularApplication() error { @@ -386,16 +386,16 @@ func (ctx *BDDTestContext) theErrorShouldIndicateCircularDependency() error { // BDDTestLogger for BDD tests type BDDTestLogger struct{} -func (l *BDDTestLogger) Debug(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Info(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Warn(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Error(msg string, fields ...interface{}) {} +func (l *BDDTestLogger) Debug(msg string, fields ...any) {} +func (l *BDDTestLogger) Info(msg string, fields ...any) {} +func (l *BDDTestLogger) Warn(msg string, fields ...any) {} +func (l *BDDTestLogger) Error(msg string, fields ...any) {} // InitializeScenario initializes the BDD test scenario func InitializeScenario(ctx *godog.ScenarioContext) { testCtx := &BDDTestContext{ moduleStates: make(map[string]bool), - servicesFound: make(map[string]interface{}), + servicesFound: make(map[string]any), } // Reset context before each scenario diff --git a/application_logger_test.go b/application_logger_test.go index 9e2fb81a..3de60068 100644 --- a/application_logger_test.go +++ b/application_logger_test.go @@ -76,7 +76,7 @@ func Test_ApplicationSetLoggerRuntimeUsage(t *testing.T) { // Create a new mock logger to switch to newMockLogger := &MockLogger{} // Set up a simple expectation that might be called later - newMockLogger.On("Debug", "Test message", []interface{}{"key", "value"}).Return().Maybe() + newMockLogger.On("Debug", "Test message", []any{"key", "value"}).Return().Maybe() // Switch to the new logger app.SetLogger(newMockLogger) @@ -120,9 +120,9 @@ func TestSetVerboseConfig(t *testing.T) { // Set up expectations for debug messages if tt.enabled { - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() } else { - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() } // Create application with mock logger @@ -165,14 +165,14 @@ func TestIsVerboseConfig(t *testing.T) { } // Test after enabling - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() app.SetVerboseConfig(true) if app.IsVerboseConfig() != true { t.Error("Expected IsVerboseConfig to return true after enabling") } // Test after disabling - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() app.SetVerboseConfig(false) if app.IsVerboseConfig() != false { t.Error("Expected IsVerboseConfig to return false after disabling") diff --git a/application_module_mgmt_test.go b/application_module_mgmt_test.go index 774c14c7..44dd897e 100644 --- a/application_module_mgmt_test.go +++ b/application_module_mgmt_test.go @@ -74,7 +74,7 @@ func Test_ResolveDependencies(t *testing.T) { } // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if (err != nil) != tt.wantErr { t.Errorf("resolveDependencies() error = %v, wantErr %v", err, tt.wantErr) diff --git a/application_observer.go b/application_observer.go index 0e492269..019282d7 100644 --- a/application_observer.go +++ b/application_observer.go @@ -29,10 +29,19 @@ type ObservableApplication struct { // all existing functionality. func NewObservableApplication(cp ConfigProvider, logger Logger) *ObservableApplication { stdApp := NewStdApplication(cp, logger).(*StdApplication) - return &ObservableApplication{ + obsApp := &ObservableApplication{ StdApplication: stdApp, observers: make(map[string]*observerRegistration), } + // Wire phase change hook to emit CloudEvents. + stdApp.phaseChangeHook = func(old, new AppPhase) { + evt := NewCloudEvent(EventTypeAppPhaseChanged, "application", map[string]any{ + "old_phase": old.String(), + "new_phase": new.String(), + }, nil) + obsApp.emitEvent(context.Background(), evt) + } + return obsApp } // RegisterObserver adds an observer to receive notifications from the application. @@ -93,7 +102,6 @@ func (app *ObservableApplication) NotifyObservers(ctx context.Context, event clo // Otherwise, notify observers in goroutines to avoid blocking. synchronous := IsSynchronousNotification(ctx) for _, registration := range app.observers { - registration := registration // capture for goroutine // Check if observer is interested in this event type if len(registration.eventTypes) > 0 && !registration.eventTypes[event.Type()] { @@ -163,7 +171,7 @@ func (app *ObservableApplication) RegisterModule(module Module) { // Emit synchronously so tests observing immediate module registration are reliable. ctx := WithSynchronousNotification(context.Background()) - evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]interface{}{ + evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]any{ "moduleType": getTypeName(module), }) app.emitEvent(ctx, evt) @@ -176,7 +184,7 @@ func (app *ObservableApplication) RegisterService(name string, service any) erro return err } - evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]interface{}{ + evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]any{ "serviceName": name, "serviceType": getTypeName(service), }, nil) @@ -199,7 +207,7 @@ func (app *ObservableApplication) Init() error { // Historically the framework emitted config loaded/validated events during initialization. // Even though structured lifecycle events now exist, tests (and possibly external observers) // still expect these generic configuration events to appear. - cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]interface{}{"phase": "init"}, nil) + cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]any{"phase": "init"}, nil) app.emitEvent(ctx, cfgLoaded) // Register observers for any ObservableModule instances BEFORE calling module Init() @@ -219,17 +227,17 @@ func (app *ObservableApplication) Init() error { app.logger.Debug("ObservableApplication initializing modules with observable application instance") err := app.InitWithApp(app) if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "init", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "init", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } // Backward compatibility: emit legacy config.validated event after successful initialization. - cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]interface{}{"phase": "init_complete"}, nil) + cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]any{"phase": "init_complete"}, nil) app.emitEvent(ctx, cfgValidated) // Emit initialization complete - evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]interface{}{"phase": "init_complete"}) + evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]any{"phase": "init_complete"}) app.emitEvent(ctx, evtInitComplete) return nil @@ -241,7 +249,7 @@ func (app *ObservableApplication) Start() error { err := app.StdApplication.Start() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "start", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "start", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -259,7 +267,7 @@ func (app *ObservableApplication) Stop() error { err := app.StdApplication.Stop() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "stop", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "stop", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -272,7 +280,7 @@ func (app *ObservableApplication) Stop() error { } // getTypeName returns the type name of an interface{} value -func getTypeName(v interface{}) string { +func getTypeName(v any) string { if v == nil { return "nil" } diff --git a/application_observer_test.go b/application_observer_test.go index 808062bb..541a4f06 100644 --- a/application_observer_test.go +++ b/application_observer_test.go @@ -337,28 +337,28 @@ type TestObserverLogger struct { type LogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *TestObserverLogger) Info(msg string, args ...interface{}) { +func (l *TestObserverLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *TestObserverLogger) Error(msg string, args ...interface{}) { +func (l *TestObserverLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *TestObserverLogger) Debug(msg string, args ...interface{}) { +func (l *TestObserverLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *TestObserverLogger) Warn(msg string, args ...interface{}) { +func (l *TestObserverLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "WARN", Message: msg, Args: args}) diff --git a/application_service_registry_test.go b/application_service_registry_test.go index b8b5ab7b..8e3070de 100644 --- a/application_service_registry_test.go +++ b/application_service_registry_test.go @@ -50,7 +50,7 @@ func Test_GetService(t *testing.T) { tests := []struct { name string serviceName string - target interface{} + target any wantErr bool errCheck func(error) bool }{ diff --git a/benchmark_test.go b/benchmark_test.go index 2ab9bca9..555cee5a 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -62,11 +62,10 @@ func BenchmarkBootstrap(b *testing.B) { func BenchmarkServiceLookup(b *testing.B) { registry := NewEnhancedServiceRegistry() _, _ = registry.RegisterService("bench-service", &struct{ Value int }{42}) - svcReg := registry.AsServiceRegistry() b.ResetTimer() for b.Loop() { - _ = svcReg["bench-service"] + _, _ = registry.GetService("bench-service") } } @@ -75,7 +74,7 @@ func BenchmarkReload(b *testing.B) { log := &benchLogger{} orchestrator := NewReloadOrchestrator(log, nil) - for i := 0; i < 5; i++ { + for i := range 5 { mod := &benchReloadable{name: fmt.Sprintf("reload-mod-%d", i)} orchestrator.RegisterReloadable(mod.name, mod) } @@ -110,7 +109,7 @@ func BenchmarkReload(b *testing.B) { func BenchmarkHealthAggregation(b *testing.B) { svc := NewAggregateHealthService(WithCacheTTL(0)) - for i := 0; i < 10; i++ { + for i := range 10 { name := fmt.Sprintf("provider-%d", i) provider := NewSimpleHealthProvider(name, "main", func(_ context.Context) (HealthStatus, string, error) { return StatusHealthy, "ok", nil diff --git a/builder.go b/builder.go index baf77b19..743617c4 100644 --- a/builder.go +++ b/builder.go @@ -3,6 +3,7 @@ package modular import ( "context" "fmt" + "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -24,6 +25,11 @@ type ApplicationBuilder struct { configLoadedHooks []func(Application) error // Hooks to run after config loading tenantGuard *StandardTenantGuard tenantGuardConfig *TenantGuardConfig + dependencyHints []DependencyEdge + drainTimeout time.Duration + parallelInit bool + dynamicReload bool + plugins []Plugin } // ObserverFunc is a functional observer that can be registered with the application @@ -110,6 +116,73 @@ func (b *ApplicationBuilder) Build() (Application, error) { } } + // Unwrap decorators to find the underlying StdApplication. + baseApp := app + for { + if dec, ok := baseApp.(ApplicationDecorator); ok { + if inner := dec.GetInnerApplication(); inner != nil { + baseApp = inner + continue + } + } + break + } + + // Propagate config-driven dependency hints + if len(b.dependencyHints) > 0 { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } + } + + // Propagate drain timeout + if b.drainTimeout > 0 { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } + } + + // Propagate dynamic reload + if b.dynamicReload { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } + } + + // Propagate parallel init + if b.parallelInit { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.parallelInit = true + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.parallelInit = true + } + } + + // Process plugins + for _, plugin := range b.plugins { + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } + } + // Register modules for _, module := range b.modules { app.RegisterModule(module) @@ -155,6 +228,53 @@ func WithModules(modules ...Module) Option { } } +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} + +// WithDrainTimeout sets the timeout for the pre-stop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} + +// WithParallelInit enables concurrent module initialization at the same topological depth. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} + +// WithDynamicReload enables the ReloadOrchestrator, which coordinates +// configuration reloading across all registered Reloadable modules. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} + +// WithPlugins adds plugins to the application. Each plugin's modules, services, +// and init hooks are registered during Build(). +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} + // WithConfigDecorators adds configuration decorators func WithConfigDecorators(decorators ...ConfigDecorator) Option { return func(b *ApplicationBuilder) error { diff --git a/builder_dependency_test.go b/builder_dependency_test.go new file mode 100644 index 00000000..29086e05 --- /dev/null +++ b/builder_dependency_test.go @@ -0,0 +1,62 @@ +package modular + +import ( + "testing" +) + +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod index 9095fb4b..9f7589aa 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod @@ -1,6 +1,6 @@ module example.com/goldenmodule -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.6.0 diff --git a/cmd/modcli/go.mod b/cmd/modcli/go.mod index baa2ed13..fd270419 100644 --- a/cmd/modcli/go.mod +++ b/cmd/modcli/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/cmd/modcli -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 diff --git a/cmd/modcli/internal/contract/differ.go b/cmd/modcli/internal/contract/differ.go index c482d4c1..0302c20c 100644 --- a/cmd/modcli/internal/contract/differ.go +++ b/cmd/modcli/internal/contract/differ.go @@ -294,7 +294,7 @@ func (d *Differ) compareStructFields(old, new TypeContract, diff *ContractDiff) // Check for modified fields (breaking change) for fieldName, newField := range newFields { if oldField, exists := oldFields[fieldName]; exists { - if oldField.Type != newField.Type { + if normalizeType(oldField.Type) != normalizeType(newField.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_field_type", Item: fmt.Sprintf("%s.%s", new.Name, fieldName), @@ -455,7 +455,7 @@ func (d *Differ) compareVariables(old, new []VariableContract, diff *ContractDif // Check for modified variable types (breaking change) for name, newVar := range newMap { if oldVar, exists := oldMap[name]; exists { - if oldVar.Type != newVar.Type { + if normalizeType(oldVar.Type) != normalizeType(newVar.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_variable_type", Item: name, @@ -506,7 +506,7 @@ func (d *Differ) compareConstants(old, new []ConstantContract, diff *ContractDif // Check for modified constants for name, newConst := range newMap { if oldConst, exists := oldMap[name]; exists { - if oldConst.Type != newConst.Type { + if normalizeType(oldConst.Type) != normalizeType(newConst.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_constant_type", Item: name, @@ -570,7 +570,7 @@ func (d *Differ) receiversEqual(old, new *ReceiverInfo) bool { if old == nil || new == nil { return false } - return old.Type == new.Type && old.Pointer == new.Pointer + return normalizeType(old.Type) == normalizeType(new.Type) && old.Pointer == new.Pointer } func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { @@ -580,7 +580,7 @@ func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { for i, oldParam := range old { newParam := new[i] - if oldParam.Type != newParam.Type { + if normalizeType(oldParam.Type) != normalizeType(newParam.Type) { return false } // Note: Parameter names can change without breaking compatibility @@ -589,6 +589,13 @@ func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { return true } +// normalizeType canonicalizes type strings so that aliases +// (e.g. "interface{}" vs "any") are treated as identical. +func normalizeType(t string) string { + t = strings.ReplaceAll(t, "interface{}", "any") + return t +} + // Signature formatting methods func (d *Differ) interfaceSignature(iface InterfaceContract) string { diff --git a/complex_dependencies_test.go b/complex_dependencies_test.go index a3fc413a..faa6ed3c 100644 --- a/complex_dependencies_test.go +++ b/complex_dependencies_test.go @@ -36,7 +36,7 @@ func TestComplexDependencies(t *testing.T) { app.RegisterModule(databaseModule) // No dependencies // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -291,13 +291,13 @@ func (m *APIModule) RequiresServices() []ServiceDependency { Name: "cache", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*CacheService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[CacheService](), }, { Name: "database", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*DatabaseService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[DatabaseService](), }, } } @@ -392,7 +392,7 @@ func (m *AuthModule) RequiresServices() []ServiceDependency { Name: "logger-service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*LoggingService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[LoggingService](), }, } } diff --git a/config_feeders.go b/config_feeders.go index bb5bd529..5afce7fd 100644 --- a/config_feeders.go +++ b/config_feeders.go @@ -7,7 +7,7 @@ import ( // Feeder defines the interface for configuration feeders that provide configuration data. type Feeder interface { // Feed gets a struct and feeds it using configuration data. - Feed(structure interface{}) error + Feed(structure any) error } // ConfigFeeders provides a default set of configuration feeders for common use cases @@ -18,14 +18,14 @@ var ConfigFeeders = []Feeder{ // ComplexFeeder extends the basic Feeder interface with additional functionality for complex configuration scenarios type ComplexFeeder interface { Feeder - FeedKey(string, interface{}) error + FeedKey(string, any) error } // InstanceAwareFeeder provides functionality for feeding multiple instances of the same configuration type type InstanceAwareFeeder interface { ComplexFeeder // FeedInstances feeds multiple instances from a map[string]ConfigType - FeedInstances(instances interface{}) error + FeedInstances(instances any) error } // VerboseAwareFeeder provides functionality for verbose debug logging during configuration feeding @@ -47,7 +47,7 @@ type ModuleAwareFeeder interface { // FeedWithModuleContext feeds configuration with module context information. // The moduleName parameter provides the name of the module whose configuration // is being processed, allowing the feeder to customize its behavior accordingly. - FeedWithModuleContext(structure interface{}, moduleName string) error + FeedWithModuleContext(structure any, moduleName string) error } // PrioritizedFeeder extends the Feeder interface with priority control. diff --git a/config_field_tracking.go b/config_field_tracking.go index 1eef7e2f..be6ca6c6 100644 --- a/config_field_tracking.go +++ b/config_field_tracking.go @@ -17,16 +17,16 @@ type FieldTracker interface { // FieldPopulation represents a single field population event type FieldPopulation struct { - FieldPath string // Full path to the field (e.g., "Connections.primary.DSN") - FieldName string // Name of the field - FieldType string // Type of the field - FeederType string // Type of feeder that populated it - SourceType string // Type of source (env, yaml, etc.) - SourceKey string // Source key that was used (e.g., "DB_PRIMARY_DSN") - Value interface{} // Value that was set - InstanceKey string // Instance key for instance-aware fields - SearchKeys []string // All keys that were searched for this field - FoundKey string // The key that was actually found + FieldPath string // Full path to the field (e.g., "Connections.primary.DSN") + FieldName string // Name of the field + FieldType string // Type of the field + FeederType string // Type of feeder that populated it + SourceType string // Type of source (env, yaml, etc.) + SourceKey string // Source key that was used (e.g., "DB_PRIMARY_DSN") + Value any // Value that was set + InstanceKey string // Instance key for instance-aware fields + SearchKeys []string // All keys that were searched for this field + FoundKey string // The key that was actually found } // FieldTrackingFeeder interface allows feeders to support field tracking @@ -132,8 +132,8 @@ func (t *DefaultFieldTracker) GetPopulationsBySource(sourceType string) []FieldP // StructStateDiffer captures before/after states to determine field changes type StructStateDiffer struct { - beforeState map[string]interface{} - afterState map[string]interface{} + beforeState map[string]any + afterState map[string]any tracker FieldTracker logger Logger } @@ -141,15 +141,15 @@ type StructStateDiffer struct { // NewStructStateDiffer creates a new struct state differ func NewStructStateDiffer(tracker FieldTracker, logger Logger) *StructStateDiffer { return &StructStateDiffer{ - beforeState: make(map[string]interface{}), - afterState: make(map[string]interface{}), + beforeState: make(map[string]any), + afterState: make(map[string]any), tracker: tracker, logger: logger, } } // CaptureBeforeState captures the state before feeder processing -func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix string) { +func (d *StructStateDiffer) CaptureBeforeState(structure any, prefix string) { d.captureState(structure, prefix, d.beforeState) if d.logger != nil { d.logger.Debug("Captured before state", "prefix", prefix, "fieldCount", len(d.beforeState)) @@ -157,7 +157,7 @@ func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix str } // CaptureAfterStateAndDiff captures the state after feeder processing and computes diffs -func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, prefix string, feederType, sourceType string) { +func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure any, prefix string, feederType, sourceType string) { d.captureState(structure, prefix, d.afterState) if d.logger != nil { d.logger.Debug("Captured after state", "prefix", prefix, "fieldCount", len(d.afterState)) @@ -168,9 +168,9 @@ func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, pref } // captureState recursively captures all field values in a structure -func (d *StructStateDiffer) captureState(structure interface{}, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureState(structure any, prefix string, state map[string]any) { rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { if rv.IsNil() { return } @@ -185,7 +185,7 @@ func (d *StructStateDiffer) captureState(structure interface{}, prefix string, s } // captureStructFields recursively captures all field values in a struct -func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -204,7 +204,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, switch field.Kind() { case reflect.Struct: d.captureStructFields(field, fieldPath, state) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { d.captureStructFields(field.Elem(), fieldPath, state) } else if !field.IsNil() { @@ -217,7 +217,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, mapFieldPath := fieldPath + "." + key.String() if mapValue.Kind() == reflect.Struct { d.captureStructFields(mapValue, mapFieldPath, state) - } else if mapValue.Kind() == reflect.Ptr && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { + } else if mapValue.Kind() == reflect.Pointer && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { d.captureStructFields(mapValue.Elem(), mapFieldPath, state) } else { state[mapFieldPath] = mapValue.Interface() @@ -284,6 +284,6 @@ func (d *StructStateDiffer) computeAndRecordDiffs(feederType, sourceType, instan // Reset clears the captured states for reuse func (d *StructStateDiffer) Reset() { - d.beforeState = make(map[string]interface{}) - d.afterState = make(map[string]interface{}) + d.beforeState = make(map[string]any) + d.afterState = make(map[string]any) } diff --git a/config_field_tracking_test.go b/config_field_tracking_test.go index a3ac579a..8b1e0f7a 100644 --- a/config_field_tracking_test.go +++ b/config_field_tracking_test.go @@ -311,7 +311,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { // Create mock logger to capture verbose output mockLogger := new(MockLogger) - debugLogs := make([][]interface{}, 0) + debugLogs := make([][]any, 0) mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugLogs = append(debugLogs, args) }).Return() @@ -396,7 +396,8 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { var secondaryDriverPop, secondaryDSNPop, secondaryMaxConnsPop *FieldPopulation for _, fp := range tracker.FieldPopulations { - if fp.InstanceKey == "primary" { + switch fp.InstanceKey { + case "primary": switch fp.FieldName { case "Driver": primaryDriverPop = &fp @@ -405,7 +406,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { case "MaxConns": primaryMaxConnsPop = &fp } - } else if fp.InstanceKey == "secondary" { + case "secondary": switch fp.FieldName { case "Driver": secondaryDriverPop = &fp @@ -470,7 +471,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { tests := []struct { name string envVars map[string]string - expectedFieldDiffs map[string]interface{} // field path -> expected new value + expectedFieldDiffs map[string]any // field path -> expected new value }{ { name: "basic field diff tracking", @@ -478,7 +479,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { "APP_NAME": "Test App", "APP_DEBUG": "true", }, - expectedFieldDiffs: map[string]interface{}{ + expectedFieldDiffs: map[string]any{ "AppName": "Test App", "Debug": true, }, @@ -555,7 +556,7 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { mockLogger := new(MockLogger) // Capture all debug log calls - var debugCalls [][]interface{} + var debugCalls [][]any mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugCalls = append(debugCalls, args) }).Return() @@ -598,17 +599,17 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { // StructState represents the state of a struct at a point in time type StructState struct { - Fields map[string]interface{} // field path -> value + Fields map[string]any // field path -> value } // captureStructState captures the current state of all fields in a struct -func captureStructState(structure interface{}) *StructState { +func captureStructState(structure any) *StructState { state := &StructState{ - Fields: make(map[string]interface{}), + Fields: make(map[string]any), } rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { rv = rv.Elem() } @@ -617,7 +618,7 @@ func captureStructState(structure interface{}) *StructState { } // captureStructFields recursively captures all field values -func captureStructFields(rv reflect.Value, prefix string, fields map[string]interface{}) { +func captureStructFields(rv reflect.Value, prefix string, fields map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -632,7 +633,7 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte switch field.Kind() { case reflect.Struct: captureStructFields(field, fieldPath, fields) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { captureStructFields(field.Elem(), fieldPath, fields) } else if !field.IsNil() { @@ -662,8 +663,8 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte } // computeFieldDiffs computes the differences between two struct states -func computeFieldDiffs(before, after *StructState) map[string]interface{} { - diffs := make(map[string]interface{}) +func computeFieldDiffs(before, after *StructState) map[string]any { + diffs := make(map[string]any) // Find fields that changed for fieldPath, afterValue := range after.Fields { diff --git a/config_full_flow_field_tracking_test.go b/config_full_flow_field_tracking_test.go index 8da63f59..04a98996 100644 --- a/config_full_flow_field_tracking_test.go +++ b/config_full_flow_field_tracking_test.go @@ -46,7 +46,7 @@ func createTestConfig() (*Config, FieldTracker, *MockLogger) { } // clearTestEnvironment clears all environment variables that could affect our tests -func clearTestEnvironment(t *testing.T) { +func clearTestEnvironment(_ *testing.T) { // Clear all potential test environment variables testEnvVars := []string{ // Test 1 variables diff --git a/config_provider.go b/config_provider.go index 091577f5..89b329f3 100644 --- a/config_provider.go +++ b/config_provider.go @@ -314,7 +314,7 @@ type Config struct { Feeders []Feeder // StructKeys maps struct identifiers to their configuration objects. // Used internally to track which configuration structures have been processed. - StructKeys map[string]interface{} + StructKeys map[string]any // VerboseDebug enables detailed logging during configuration processing VerboseDebug bool // Logger is used for verbose debug logging @@ -336,7 +336,7 @@ type Config struct { func NewConfig() *Config { return &Config{ Feeders: make([]Feeder, 0), - StructKeys: make(map[string]interface{}), + StructKeys: make(map[string]any), VerboseDebug: false, Logger: nil, FieldTracker: NewDefaultFieldTracker(), @@ -396,7 +396,7 @@ func (c *Config) AddFeeder(feeder Feeder) *Config { } // AddStructKey adds a structure with a key to the configuration -func (c *Config) AddStructKey(key string, target interface{}) *Config { +func (c *Config) AddStructKey(key string, target any) *Config { c.StructKeys[key] = target return c } @@ -420,7 +420,7 @@ func (c *Config) SetFieldTracker(tracker FieldTracker) *Config { // FeedWithModuleContext feeds a single configuration structure with module context information // This allows module-aware feeders to customize their behavior based on the module name -func (c *Config) FeedWithModuleContext(target interface{}, moduleName string) error { +func (c *Config) FeedWithModuleContext(target any, moduleName string) error { if c.VerboseDebug && c.Logger != nil { c.Logger.Debug("Starting module-aware config feed", "targetType", reflect.TypeOf(target), "moduleName", moduleName, "feedersCount", len(c.Feeders)) } @@ -953,7 +953,7 @@ func applyInstanceAwareFeeding(app *StdApplication, tempConfigs map[string]confi // Get the config from the temporary config that was just fed with YAML/ENV data configInfo := tempConfigs[sectionKey] - var tempConfig interface{} + var tempConfig any if configInfo.isPtr { tempConfig = configInfo.tempVal.Interface() } else { @@ -1032,13 +1032,13 @@ type configInfo struct { } // createTempConfig creates a temporary config for feeding values -func createTempConfig(cfg any) (interface{}, configInfo, error) { +func createTempConfig(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1087,13 +1087,13 @@ func DeepCopyConfig(cfg any) (any, error) { // // This is useful when you need to ensure that modifications to the temporary config // during processing will not affect the original configuration. -func createTempConfigDeep(cfg any) (interface{}, configInfo, error) { +func createTempConfigDeep(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1133,7 +1133,7 @@ func deepCopyValue(dst, src reflect.Value) { } switch src.Kind() { - case reflect.Ptr: + case reflect.Pointer: if src.IsNil() { return } diff --git a/config_provider_app_loading_test.go b/config_provider_app_loading_test.go index d5caaa22..92ba8ea9 100644 --- a/config_provider_app_loading_test.go +++ b/config_provider_app_loading_test.go @@ -139,8 +139,8 @@ func Test_loadAppConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "section1"}).Return() + []any(nil)).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "section1"}).Return() mockLogger.On("Debug", "Added main config for loading", mock.Anything).Return() mockLogger.On("Debug", "Added section config for loading", mock.Anything).Return() mockLogger.On("Debug", "Updated main config", mock.Anything).Return() diff --git a/config_provider_basic_test.go b/config_provider_basic_test.go index b205f0c8..b1c2eedd 100644 --- a/config_provider_basic_test.go +++ b/config_provider_basic_test.go @@ -22,7 +22,7 @@ type MockComplexFeeder struct { mock.Mock } -func (m *MockComplexFeeder) Feed(structure interface{}) error { +func (m *MockComplexFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) @@ -30,7 +30,7 @@ func (m *MockComplexFeeder) Feed(structure interface{}) error { return nil } -func (m *MockComplexFeeder) FeedKey(key string, target interface{}) error { +func (m *MockComplexFeeder) FeedKey(key string, target any) error { args := m.Called(key, target) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder key error: %w", err) diff --git a/config_provider_temp_config_test.go b/config_provider_temp_config_test.go index 18c49e63..9ec0e27c 100644 --- a/config_provider_temp_config_test.go +++ b/config_provider_temp_config_test.go @@ -156,7 +156,7 @@ func Test_updateConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() + []any(nil)).Return() app := &StdApplication{ logger: mockLogger, cfgProvider: NewStdConfigProvider(originalCfg), @@ -209,7 +209,7 @@ func Test_updateSectionConfig(t *testing.T) { tempCfgPtr.(*testSectionCfg).Name = "new" mockLogger := new(MockLogger) - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "test"}).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "test"}).Return() app := &StdApplication{ logger: mockLogger, @@ -288,7 +288,7 @@ func TestDeepCopyValue_Maps(t *testing.T) { t.Run("nil map", func(t *testing.T) { var src map[string]string = nil - dst := reflect.New(reflect.TypeOf(map[string]string{})).Elem() + dst := reflect.New(reflect.TypeFor[map[string]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil map, deepCopyValue returns early without modifying dst @@ -304,7 +304,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("simple slice of integers", func(t *testing.T) { src := []int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]int) @@ -318,7 +318,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("slice of strings", func(t *testing.T) { src := []string{"hello", "world"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]string) @@ -349,7 +349,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("nil slice", func(t *testing.T) { var src []string = nil - dst := reflect.New(reflect.TypeOf([]string{})).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil slice, deepCopyValue returns early without modifying dst @@ -366,7 +366,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { str := "original" src := &str - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*string) @@ -385,7 +385,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { src := &TestStruct{Name: "test", Value: 42} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*TestStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*TestStruct) @@ -400,7 +400,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { t.Run("nil pointer", func(t *testing.T) { var src *string = nil - dst := reflect.New(reflect.TypeOf((*string)(nil))).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil pointer, deepCopyValue returns early without modifying dst @@ -421,7 +421,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { src := SimpleStruct{Name: "John", Age: 30} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[SimpleStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(SimpleStruct) @@ -440,7 +440,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Settings: map[string]string{"key1": "value1", "key2": "value2"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ConfigStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ConfigStruct) @@ -463,7 +463,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Items: []string{"a", "b", "c"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ListStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ListStruct) @@ -489,7 +489,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Inner: InnerStruct{Value: 42}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[OuterStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(OuterStruct) @@ -522,7 +522,7 @@ func TestDeepCopyValue_BasicTypes(t *testing.T) { tests := []struct { name string - value interface{} + value any }{ {"int", 42}, {"int64", int64(123456789)}, @@ -567,7 +567,7 @@ func TestDeepCopyValue_ComplexStructures(t *testing.T) { AllowedIPs: []string{"192.168.1.1", "10.0.0.1"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ComplexConfig]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstConfig := dst.Interface().(ComplexConfig) @@ -598,7 +598,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of integers", func(t *testing.T) { src := [5]int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[5]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([5]int) @@ -612,7 +612,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of strings", func(t *testing.T) { src := [3]string{"foo", "bar", "baz"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[3]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([3]string) @@ -626,7 +626,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { str1, str2 := "value1", "value2" src := [2]*string{&str1, &str2} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[2]*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([2]*string) @@ -644,7 +644,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Parallel() t.Run("interface with concrete string", func(t *testing.T) { - var src interface{} = "hello" + var src any = "hello" dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -654,7 +654,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { }) t.Run("interface with concrete map", func(t *testing.T) { - var src interface{} = map[string]int{"a": 1, "b": 2} + var src any = map[string]int{"a": 1, "b": 2} dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -672,7 +672,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -696,7 +696,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with nil interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -718,7 +718,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { Data map[string]string } - var src interface{} = TestStruct{ + var src any = TestStruct{ Value: 42, Data: map[string]string{"key": "value"}, } @@ -746,7 +746,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { src <- 42 src <- 100 - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan int) @@ -759,7 +759,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { t.Run("nil channel", func(t *testing.T) { var src chan string = nil - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan string) @@ -809,7 +809,7 @@ func TestDeepCopyValue_Invalid(t *testing.T) { t.Run("invalid value", func(t *testing.T) { var src reflect.Value // Invalid (zero value) - dst := reflect.New(reflect.TypeOf("")).Elem() + dst := reflect.New(reflect.TypeFor[string]()).Elem() // Should not panic require.NotPanics(t, func() { diff --git a/config_provider_test.go b/config_provider_test.go index fe59b1f4..049ed3a3 100644 --- a/config_provider_test.go +++ b/config_provider_test.go @@ -151,15 +151,13 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -178,26 +176,24 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 50 concurrent readers - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { + for range 50 { + wg.Go(func() { + for range 100 { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- ErrConfigNil return } } - }() + }) } // 10 concurrent updaters - for i := 0; i < 10; i++ { + for i := range 10 { wg.Add(1) go func(id int) { defer wg.Done() - for j := 0; j < 10; j++ { + for j := range 10 { newCfg := &TestConfig{ Host: "example.com", Port: 8080 + id*100 + j, @@ -299,15 +295,13 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -331,7 +325,7 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 50) // 50 concurrent mutable copy requests - for i := 0; i < 50; i++ { + for i := range 50 { wg.Add(1) go func(id int) { defer wg.Done() diff --git a/config_provider_verbose_test.go b/config_provider_verbose_test.go index d9a719df..06b6d9fe 100644 --- a/config_provider_verbose_test.go +++ b/config_provider_verbose_test.go @@ -14,7 +14,7 @@ type MockVerboseAwareFeeder struct { mock.Mock } -func (m *MockVerboseAwareFeeder) Feed(structure interface{}) error { +func (m *MockVerboseAwareFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) diff --git a/config_validation.go b/config_validation.go index ceced11d..5d2d4e09 100644 --- a/config_validation.go +++ b/config_validation.go @@ -18,7 +18,7 @@ const ( tagDefault = "default" tagRequired = "required" tagValidate = "validate" - tagDesc = "desc" // Used for generating sample config and documentation + tagDesc = "desc" ) // ConfigValidator is an interface for configuration validation. @@ -68,13 +68,13 @@ type ConfigValidator interface { // // This function is automatically called by the configuration loading system // before validation, but can also be called manually if needed. -func ProcessConfigDefaults(cfg interface{}) error { +func ProcessConfigDefaults(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -108,7 +108,7 @@ func processStructDefaults(v reflect.Value) error { } // Handle pointers to structs - but only if they're already non-nil - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { // Don't automatically initialize nil struct pointers // (the previous behavior was automatically creating them) if !field.IsNil() { @@ -136,13 +136,13 @@ func processStructDefaults(v reflect.Value) error { // ValidateConfigRequired checks all struct fields with `required:"true"` tag // and verifies they are not zero/empty values -func ValidateConfigRequired(cfg interface{}) error { +func ValidateConfigRequired(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -186,7 +186,7 @@ func validateRequiredFields(v reflect.Value, prefix string, errors *[]string) { } // Handle pointers to structs - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { if !field.IsNil() { validateRequiredFields(field.Elem(), fieldName, errors) } else if isFieldRequired(&fieldType) { @@ -221,7 +221,7 @@ func isZeroValue(v reflect.Value) bool { return v.Uint() == 0 case reflect.Float32, reflect.Float64: return v.Float() == 0 - case reflect.Interface, reflect.Ptr: + case reflect.Interface, reflect.Pointer: return v.IsNil() case reflect.Invalid: return true @@ -239,7 +239,7 @@ func isZeroValue(v reflect.Value) bool { // setDefaultValue sets a default value from a string to the proper field type func setDefaultValue(field reflect.Value, defaultVal string) error { // Special handling for time.Duration type - if field.Type() == reflect.TypeOf(time.Duration(0)) { + if field.Type() == reflect.TypeFor[time.Duration]() { return setDefaultDuration(field, defaultVal) } @@ -262,7 +262,7 @@ func setDefaultValue(field reflect.Value, defaultVal string) error { case reflect.Map: return setDefaultMap(field, defaultVal) case reflect.Invalid, reflect.Complex64, reflect.Complex128, reflect.Array, - reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Struct, + reflect.Chan, reflect.Func, reflect.Interface, reflect.Pointer, reflect.Struct, reflect.UnsafePointer: return handleUnsupportedDefaultType(kind) default: @@ -289,7 +289,7 @@ func handleUnsupportedDefaultType(kind reflect.Kind) error { return fmt.Errorf("%w: functions not supported", ErrUnsupportedTypeForDefault) case reflect.Interface: return fmt.Errorf("%w: interfaces not supported", ErrUnsupportedTypeForDefault) - case reflect.Ptr: + case reflect.Pointer: return fmt.Errorf("%w: pointers not supported", ErrUnsupportedTypeForDefault) case reflect.Struct: return fmt.Errorf("%w: structs not supported", ErrUnsupportedTypeForDefault) @@ -391,7 +391,7 @@ func setDefaultInt(field reflect.Value, i int64) error { case reflect.Invalid, reflect.Bool, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, - reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: + reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) default: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) @@ -408,7 +408,7 @@ func setDefaultUint(field reflect.Value, u uint64) error { return nil case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, - reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, + reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set uint value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -428,7 +428,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, - reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, + reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set float value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -438,7 +438,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { // GenerateSampleConfig generates a sample configuration for a config struct // The format parameter can be "yaml", "json", or "toml" -func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { +func GenerateSampleConfig(cfg any, format string) ([]byte, error) { if cfg == nil { return nil, ErrConfigNil } @@ -476,10 +476,10 @@ func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { } // mapStructFieldsForJSON creates a map with proper JSON field names based on struct tags -func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func mapStructFieldsForJSON(cfg any) map[string]any { + result := make(map[string]any) v := reflect.ValueOf(cfg) - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { v = v.Elem() } t := v.Type() @@ -508,7 +508,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { switch field.Kind() { //nolint:exhaustive // only handling specific cases we care about case reflect.Struct: result[fieldName] = mapStructFieldsForJSON(field.Interface()) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { result[fieldName] = mapStructFieldsForJSON(field.Interface()) } else { @@ -524,7 +524,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { } // SaveSampleConfig generates and saves a sample configuration file -func SaveSampleConfig(cfg interface{}, format, filePath string) error { +func SaveSampleConfig(cfg any, format, filePath string) error { data, err := GenerateSampleConfig(cfg, format) if err != nil { return err @@ -540,7 +540,7 @@ func SaveSampleConfig(cfg interface{}, format, filePath string) error { // 1. Processes default values // 2. Validates required fields // 3. If the config implements ConfigValidator, calls its Validate method -func ValidateConfig(cfg interface{}) error { +func ValidateConfig(cfg any) error { if cfg == nil { return ErrConfigNil } diff --git a/config_validation_test.go b/config_validation_test.go index 348335e2..3dde20bd 100644 --- a/config_validation_test.go +++ b/config_validation_test.go @@ -50,8 +50,8 @@ func (c *ValidationTestConfig) Validate() error { func TestProcessConfigDefaults(t *testing.T) { tests := []struct { name string - cfg interface{} - expected interface{} + cfg any + expected any wantErr bool }{ { @@ -116,7 +116,7 @@ func TestProcessConfigDefaults(t *testing.T) { func TestValidateConfigRequired(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool errorMsg string }{ @@ -182,7 +182,7 @@ func TestValidateConfigRequired(t *testing.T) { func TestValidateConfig(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool }{ { @@ -242,7 +242,7 @@ func TestGenerateSampleConfig(t *testing.T) { // Test JSON generation jsonData, err := GenerateSampleConfig(cfg, "json") require.NoError(t, err) - var jsonCfg map[string]interface{} + var jsonCfg map[string]any err = json.Unmarshal(jsonData, &jsonCfg) require.NoError(t, err) assert.Equal(t, "Default Name", jsonCfg["name"]) diff --git a/configuration_base_bdd_test.go b/configuration_base_bdd_test.go index e393b33c..0a62cf08 100644 --- a/configuration_base_bdd_test.go +++ b/configuration_base_bdd_test.go @@ -44,7 +44,7 @@ type ConfigBDDTestContext struct { jsonFile string environmentVars map[string]string originalEnvVars map[string]string - configData interface{} + configData any isValid bool validationErrors []string fieldTracker *TestFieldTracker diff --git a/contract_verifier.go b/contract_verifier.go index 298ab942..918c0635 100644 --- a/contract_verifier.go +++ b/contract_verifier.go @@ -90,10 +90,8 @@ func (v *StandardContractVerifier) checkCanReloadConcurrency(module Reloadable) mu sync.Mutex ) - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { defer func() { if r := recover(); r != nil { mu.Lock() @@ -102,7 +100,7 @@ func (v *StandardContractVerifier) checkCanReloadConcurrency(module Reloadable) } }() module.CanReload() - }() + }) } wg.Wait() return panicked != 0 diff --git a/cycle_detection_modules_bdd_test.go b/cycle_detection_modules_bdd_test.go index e6a822b7..d55b325f 100644 --- a/cycle_detection_modules_bdd_test.go +++ b/cycle_detection_modules_bdd_test.go @@ -26,7 +26,7 @@ func (m *CycleModuleA) RequiresServices() []ServiceDependency { Name: "serviceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -50,7 +50,7 @@ func (m *CycleModuleB) RequiresServices() []ServiceDependency { Name: "serviceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -82,7 +82,7 @@ func (m *LinearModuleB) RequiresServices() []ServiceDependency { Name: "linearServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -106,7 +106,7 @@ func (m *SelfDependentModule) RequiresServices() []ServiceDependency { Name: "selfService", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -153,7 +153,7 @@ func (m *MixedDependencyModuleB) RequiresServices() []ServiceDependency { Name: "mixedServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -177,7 +177,7 @@ func (m *ComplexCycleModuleA) RequiresServices() []ServiceDependency { Name: "complexServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -201,7 +201,7 @@ func (m *ComplexCycleModuleB) RequiresServices() []ServiceDependency { Name: "complexServiceC", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceC)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceC](), }} } @@ -225,7 +225,7 @@ func (m *ComplexCycleModuleC) RequiresServices() []ServiceDependency { Name: "complexServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -249,7 +249,7 @@ func (m *DisambiguationModuleA) RequiresServices() []ServiceDependency { Name: "disambiguationServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*AnotherEnhancedTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[AnotherEnhancedTestInterface](), }} } @@ -273,6 +273,6 @@ func (m *DisambiguationModuleB) RequiresServices() []ServiceDependency { Name: "disambiguationServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*EnhancedTestInterface)(nil)).Elem(), // Note: different interface + SatisfiesInterface: reflect.TypeFor[EnhancedTestInterface](), // Note: different interface }} } diff --git a/cycle_detection_test.go b/cycle_detection_test.go index 432d6d6e..7ce11033 100644 --- a/cycle_detection_test.go +++ b/cycle_detection_test.go @@ -44,7 +44,7 @@ func (m *CycleTestModuleA) RequiresServices() []ServiceDependency { Name: "testServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } @@ -81,7 +81,7 @@ func (m *CycleTestModuleB) RequiresServices() []ServiceDependency { Name: "testServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } diff --git a/database_interface_matching_test.go b/database_interface_matching_test.go index a0cc5eca..f2474f5c 100644 --- a/database_interface_matching_test.go +++ b/database_interface_matching_test.go @@ -12,9 +12,9 @@ import ( // DatabaseExecutor matches the user's interface from the problem description type DatabaseExecutor interface { - ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) } @@ -25,15 +25,15 @@ var _ DatabaseExecutor = (*sql.DB)(nil) // mockDatabaseExecutor is a mock implementation for testing type mockDatabaseExecutor struct{} -func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { +func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return &mockResult{}, nil } -func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { +func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { return nil, nil } -func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { +func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { return &sql.Row{} } @@ -80,19 +80,19 @@ func TestInterfaceMatchingCore(t *testing.T) { mockService := &mockDatabaseServiceImpl{executor: mockExecutor} // Test 1: Check if mockDatabaseExecutor implements DatabaseExecutor (it should) - expectedType := reflect.TypeOf((*DatabaseExecutor)(nil)).Elem() - mockExecutorType := reflect.TypeOf((*mockDatabaseExecutor)(nil)) + expectedType := reflect.TypeFor[DatabaseExecutor]() + mockExecutorType := reflect.TypeFor[*mockDatabaseExecutor]() assert.True(t, mockExecutorType.Implements(expectedType), "mockDatabaseExecutor should implement DatabaseExecutor interface") // Test 2: Check if mockDatabaseServiceImpl implements DatabaseExecutor (it should NOT) - mockServiceType := reflect.TypeOf((*mockDatabaseServiceImpl)(nil)) + mockServiceType := reflect.TypeFor[*mockDatabaseServiceImpl]() assert.False(t, mockServiceType.Implements(expectedType), "mockDatabaseServiceImpl should NOT implement DatabaseExecutor interface") // Test 3: Check if mockDatabaseServiceImpl implements MockDatabaseService (it should) - mockDBServiceType := reflect.TypeOf((*MockDatabaseService)(nil)).Elem() + mockDBServiceType := reflect.TypeFor[MockDatabaseService]() assert.True(t, mockServiceType.Implements(mockDBServiceType), "mockDatabaseServiceImpl should implement MockDatabaseService interface") diff --git a/debug_module_interfaces.go b/debug_module_interfaces.go index 8d6d53c7..ea12996b 100644 --- a/debug_module_interfaces.go +++ b/debug_module_interfaces.go @@ -23,7 +23,7 @@ func DebugModuleInterfaces(app Application, moduleName string) { fmt.Printf(" Memory address: %p\n", module) // Check all the interfaces - interfaces := map[string]interface{}{ + interfaces := map[string]any{ "Module": (*Module)(nil), "Configurable": (*Configurable)(nil), "DependencyAware": (*DependencyAware)(nil), diff --git a/debug_module_test.go b/debug_module_test.go index d15cae63..35a21705 100644 --- a/debug_module_test.go +++ b/debug_module_test.go @@ -22,7 +22,7 @@ func TestModuleReplacementLosesStartable(t *testing.T) { originalModule := &ProblematicModule{name: "test-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module @@ -81,7 +81,7 @@ func TestProperModuleConstructorPattern(t *testing.T) { originalModule := &CorrectModule{name: "correct-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module diff --git a/decorator_config.go b/decorator_config.go index f0f9609e..a8522630 100644 --- a/decorator_config.go +++ b/decorator_config.go @@ -25,7 +25,7 @@ type instanceAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *instanceAwareConfigProvider) GetConfig() interface{} { +func (p *instanceAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -54,7 +54,7 @@ type tenantAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *tenantAwareConfigProvider) GetConfig() interface{} { +func (p *tenantAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -62,7 +62,7 @@ func (p *tenantAwareConfigProvider) GetConfig() interface{} { var errNoTenantLoaderConfigured = errors.New("no tenant loader configured") // GetTenantConfig retrieves configuration for a specific tenant -func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (interface{}, error) { +func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (any, error) { if p.loader == nil { return nil, errNoTenantLoaderConfigured } diff --git a/decorator_observable.go b/decorator_observable.go index fb8d3759..43c11480 100644 --- a/decorator_observable.go +++ b/decorator_observable.go @@ -45,7 +45,7 @@ func (d *ObservableDecorator) RemoveObserver(observer ObserverFunc) { } // emitEvent emits a CloudEvent to all registered observers -func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data interface{}, metadata map[string]interface{}) { +func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data any, metadata map[string]any) { event := NewCloudEvent(eventType, "application", data, metadata) d.observerMutex.RLock() @@ -55,7 +55,6 @@ func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, d // Notify observers in goroutines to avoid blocking for _, observer := range observers { - observer := observer // capture for goroutine go func() { defer func() { if r := recover(); r != nil { @@ -77,7 +76,7 @@ func (d *ObservableDecorator) Init() error { ctx := context.Background() // Emit before init event - d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]any{ "phase": "before_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -86,9 +85,9 @@ func (d *ObservableDecorator) Init() error { if err != nil { // Emit init failed event - d.emitEvent(ctx, "com.modular.application.init.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.init.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "init_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -96,7 +95,7 @@ func (d *ObservableDecorator) Init() error { } // Emit after init event - d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]any{ "phase": "after_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -109,7 +108,7 @@ func (d *ObservableDecorator) Start() error { ctx := context.Background() // Emit before start event - d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]any{ "phase": "before_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -118,9 +117,9 @@ func (d *ObservableDecorator) Start() error { if err != nil { // Emit start failed event - d.emitEvent(ctx, "com.modular.application.start.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.start.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "start_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -128,7 +127,7 @@ func (d *ObservableDecorator) Start() error { } // Emit after start event - d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]any{ "phase": "after_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -141,7 +140,7 @@ func (d *ObservableDecorator) Stop() error { ctx := context.Background() // Emit before stop event - d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]any{ "phase": "before_stop", "timestamp": time.Now().Format(time.RFC3339), }) @@ -150,9 +149,9 @@ func (d *ObservableDecorator) Stop() error { if err != nil { // Emit stop failed event - d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "stop_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -160,7 +159,7 @@ func (d *ObservableDecorator) Stop() error { } // Emit after stop event - d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]any{ "phase": "after_stop", "timestamp": time.Now().Format(time.RFC3339), }) diff --git a/docs/plans/2026-03-09-modular-v2-enhancements-design.md b/docs/plans/2026-03-09-modular-v2-enhancements-design.md new file mode 100644 index 00000000..f391b4c9 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements-design.md @@ -0,0 +1,236 @@ +# Modular v2 Enhancements Design + +**Goal:** Address 12 gaps identified in the Modular framework audit, making it a more complete foundation for the Workflow engine and other consumers. + +**Delivery:** Single PR on the `feat/reimplementation` branch. + +**Consumer:** GoCodeAlone/workflow engine (primary), other Go services using Modular. + +--- + +## Section 1: Core Lifecycle + +### 1.1 Config-Driven Dependency Hints + +**Gap:** Modules can declare dependencies via `DependencyAware` interface, but there's no way to declare them from the builder/config level without modifying module code. + +**Design:** `WithModuleDependency(from, to string)` builder option injects edges into the dependency graph before resolution. These hints feed into the existing topological sort alongside `DependencyAware` edges. + +```go +app := modular.NewApplicationBuilder(). + WithModuleDependency("api-server", "database"). + WithModuleDependency("api-server", "cache"). + Build() +``` + +Implementation: Store hints in `[]DependencyEdge` on the builder, merge into the graph in `resolveDependencies()` before DFS. + +### 1.2 Drainable Interface (Shutdown Drain Phases) + +**Gap:** `Stoppable` has a single `Stop()` method. No way to drain in-flight work before hard stop. + +**Design:** New `Drainable` interface with `PreStop(ctx)` called before `Stop()`: + +```go +type Drainable interface { + PreStop(ctx context.Context) error +} +``` + +Shutdown sequence: `PreStop` all drainable modules (reverse dependency order) → `Stop` all stoppable modules (reverse dependency order). `PreStop` context has a configurable timeout via `WithDrainTimeout(d)`. + +### 1.3 Application Phase Tracking + +**Gap:** No way to query what lifecycle phase the application is in. + +**Design:** `Phase()` method on Application returning an enum: + +```go +type AppPhase int +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) +``` + +Phase transitions emit CloudEvents (`EventTypeAppPhaseChanged`) if a Subject is configured. + +### 1.4 Parallel Init at Same Topological Depth + +**Gap:** Modules at the same depth in the dependency graph are initialized sequentially. + +**Design:** `WithParallelInit()` builder option. When enabled, modules at the same topological depth are initialized concurrently via `errgroup`. Modules at different depths remain sequential (respecting dependency order). + +Disabled by default for backward compatibility. Errors from any goroutine cancel the group and return the first error. + +--- + +## Section 2: Services & Plugins + +### 2.1 Type-Safe Service Helpers + +**Gap:** `RegisterService`/`GetService` use `interface{}`, requiring type assertions at every call site. + +**Design:** Package-level generic helper functions (not methods, since Go interfaces can't have type parameters): + +```go +func RegisterTypedService[T any](registry ServiceRegistry, name string, svc T) error +func GetTypedService[T any](registry ServiceRegistry, name string) (T, error) +``` + +These wrap the existing `RegisterService`/`GetService` with compile-time type safety. `GetTypedService` returns a typed zero value + error on type mismatch. + +### 2.2 Service Readiness Events + +**Gap:** No notification when a service becomes available, making lazy/async resolution brittle. + +**Design:** `EventTypeServiceRegistered` CloudEvent emitted by `EnhancedServiceRegistry.RegisterService()`. Plus `OnServiceReady(name, callback)` method that fires the callback immediately if already registered, or defers until registration. + +```go +registry.OnServiceReady("database", func(svc interface{}) { + db := svc.(*sql.DB) + // use db +}) +``` + +### 2.3 Plugin Interface + +**Gap:** No standard way to bundle modules, services, and hooks as a distributable unit. + +**Design:** Three interfaces with progressive capability: + +```go +type Plugin interface { + Name() string + Modules() []Module +} + +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +type ServiceDefinition struct { + Name string + Service interface{} +} +``` + +Builder gains `WithPlugins(...Plugin)`: registers all modules, runs hooks during init, registers services before module init. + +--- + +## Section 3: Configuration & Reload + +### 3.1 ReloadOrchestrator Integration + +**Gap:** `ReloadOrchestrator` exists but isn't wired into the Application lifecycle. + +**Design:** `WithDynamicReload()` builder option: +- Creates `ReloadOrchestrator` during `Build()` +- Auto-registers all `Reloadable` modules after init +- Calls `Start()` during app start, `Stop()` during app stop +- Exposes `Application.RequestReload(ctx, trigger, diff)` for consumers + +### 3.2 Config File Watcher + +**Gap:** No built-in file watching for configuration changes. + +**Design:** New `modules/configwatcher` package providing a module that watches config files: + +```go +watcher := configwatcher.New( + configwatcher.WithPaths("config/app.yaml", "config/overrides.yaml"), + configwatcher.WithDebounce(500 * time.Millisecond), + configwatcher.WithDiffFunc(myDiffFunc), +) +``` + +Uses `fsnotify` (single new dependency). On change: debounce → compute diff → call `Application.RequestReload()`. Implements `Startable`/`Stoppable` for lifecycle management. + +### 3.3 Secret Resolution Hooks + +**Gap:** Config values like `${vault:secret/db-password}` have no standard expansion mechanism. + +**Design:** `SecretResolver` interface + utility function: + +```go +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error +``` + +`ExpandSecrets` walks the config map, finds string values matching `${prefix:path}`, dispatches to the first resolver where `CanResolve` returns true, and replaces in-place. Called by consumers before feeding config to modules. + +--- + +## Section 4: Observability + +### 4.1 Slog Adapter + +**Gap:** Framework uses custom `Logger` interface. Go's `slog` is the standard. + +**Design:** Keep `Logger` interface unchanged. Add `SlogAdapter` implementing `Logger` by wrapping `*slog.Logger`: + +```go +type SlogAdapter struct { + logger *slog.Logger +} + +func NewSlogAdapter(l *slog.Logger) *SlogAdapter +func (a *SlogAdapter) With(args ...any) *SlogAdapter +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter +``` + +`With()`/`WithGroup()` return `*SlogAdapter` (not `Logger`) for chaining structured context. Base `Logger` interface methods (`Info`, `Error`, `Warn`, `Debug`) delegate to slog equivalents. + +### 4.2 Module Metrics Hooks + +**Gap:** No standard way for modules to expose operational metrics. + +**Design:** Optional `MetricsProvider` interface: + +```go +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +`Application.CollectAllMetrics(ctx) []ModuleMetrics` iterates modules implementing `MetricsProvider`. No OTEL/Prometheus dependency — returns raw values for consumers to map to their telemetry system. + +--- + +## Gap Matrix Summary + +| # | Gap | Section | Key Types | +|---|-----|---------|-----------| +| 1 | Config-driven dependency hints | 1.1 | `WithModuleDependency` | +| 2 | Shutdown drain phases | 1.2 | `Drainable`, `PreStop` | +| 3 | Application phase tracking | 1.3 | `AppPhase`, `Phase()` | +| 4 | Parallel init | 1.4 | `WithParallelInit` | +| 5 | Type-safe services | 2.1 | `RegisterTypedService[T]` | +| 6 | Service readiness events | 2.2 | `OnServiceReady` | +| 7 | Plugin interface | 2.3 | `Plugin`, `WithPlugins` | +| 8 | Reload orchestrator integration | 3.1 | `WithDynamicReload` | +| 9 | Config file watcher | 3.2 | `configwatcher` module | +| 10 | Secret resolution hooks | 3.3 | `SecretResolver` | +| 11 | Slog adapter | 4.1 | `SlogAdapter` | +| 12 | Module metrics hooks | 4.2 | `MetricsProvider` | diff --git a/docs/plans/2026-03-09-modular-v2-enhancements.md b/docs/plans/2026-03-09-modular-v2-enhancements.md new file mode 100644 index 00000000..eeeb8294 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements.md @@ -0,0 +1,2254 @@ +# Modular v2 Enhancements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement 12 framework enhancements to the GoCodeAlone/modular framework covering lifecycle, services, plugins, configuration, reload, and observability. + +**Architecture:** All changes are in the root `modular` package except the config file watcher (new `modules/configwatcher` subpackage). The existing `Application` interface, `StdApplication` struct, and `ApplicationBuilder` are extended. New interfaces (`Drainable`, `Plugin`, `MetricsProvider`, `SecretResolver`) follow the existing optional-interface pattern. Generic service helpers use Go 1.26 type parameters. + +**Tech Stack:** Go 1.26, CloudEvents SDK, fsnotify (new dependency for configwatcher) + +--- + +### Task 1: Config-Driven Dependency Hints (`WithModuleDependency`) + +**Files:** +- Modify: `builder.go` — add `dependencyHints` field, `WithModuleDependency` option +- Modify: `application.go` — merge hints into `resolveDependencies()` +- Create: `builder_dependency_test.go` — tests +- Modify: `errors.go` — add sentinel if needed + +**Step 1: Write the failing test** + +Create `builder_dependency_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +// testDepModule is a minimal module for dependency hint testing. +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + // Without dependency hints, alpha inits before beta (alphabetical DFS). + // With WithModuleDependency("alpha", "beta"), beta must init first. + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: FAIL — `WithModuleDependency` undefined + +**Step 3: Implement** + +In `builder.go`, add to `ApplicationBuilder`: +```go +dependencyHints []DependencyEdge +``` + +Add option function: +```go +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} +``` + +In `Build()`, after creating the app and before registering modules, store hints on the StdApplication. Add a new field to `StdApplication`: +```go +dependencyHints []DependencyEdge +``` + +In `Build()`, after `app` is created, set hints: +```go +if len(b.dependencyHints) > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } +} +``` + +In `resolveDependencies()` in `application.go`, after building the graph from `DependencyAware` modules (around line 1104), add: +```go +// Merge config-driven dependency hints +for _, hint := range app.dependencyHints { + if graph[hint.From] == nil { + graph[hint.From] = nil + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go builder_dependency_test.go +git commit -m "feat: add WithModuleDependency for config-driven dependency hints" +``` + +--- + +### Task 2: Drainable Interface (Shutdown Drain Phases) + +**Files:** +- Create: `drainable.go` — interface + drain timeout option +- Modify: `application.go` — call PreStop before Stop in `Stop()` +- Modify: `builder.go` — add `WithDrainTimeout` option +- Create: `drainable_test.go` — tests + +**Step 1: Write the failing test** + +Create `drainable_test.go`: + +```go +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp, ok := app.(*StdApplication) + if !ok { + t.Skip("not a StdApplication") + } + + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: FAIL — `Drainable` undefined, `WithDrainTimeout` undefined + +**Step 3: Implement** + +Create `drainable.go`: +```go +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. This allows modules to stop accepting +// new work and drain in-flight requests before the hard stop. +type Drainable interface { + // PreStop initiates graceful drain before stop. The context carries the drain timeout. + PreStop(ctx context.Context) error +} + +// defaultDrainTimeout is the default timeout for the PreStop drain phase. +const defaultDrainTimeout = 15 * time.Second +``` + +Add `drainTimeout` field to `StdApplication` in `application.go`: +```go +drainTimeout time.Duration +``` + +Add `WithDrainTimeout` option in `builder.go`: +```go +// WithDrainTimeout sets the timeout for the PreStop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} +``` + +Add `drainTimeout time.Duration` to `ApplicationBuilder`. + +In `Build()`, propagate to StdApplication: +```go +if b.drainTimeout > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } +} +``` + +Modify `Stop()` in `application.go` to call PreStop first: +```go +func (app *StdApplication) Stop() error { + modules, err := app.resolveDependencies() + if err != nil { + return err + } + slices.Reverse(modules) + + // Phase 1: Drain — call PreStop on all Drainable modules + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + drainableModule, ok := module.(Drainable) + if !ok { + continue + } + app.logger.Info("Draining module", "module", name) + if err := drainableModule.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + + // Phase 2: Stop — call Stop on all Stoppable modules + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var lastErr error + for _, name := range modules { + module := app.moduleRegistry[name] + stoppableModule, ok := module.(Stoppable) + if !ok { + app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) + continue + } + app.logger.Info("Stopping module", "module", name) + if err = stoppableModule.Stop(ctx); err != nil { + app.logger.Error("Error stopping module", "module", name, "error", err) + lastErr = err + } + } + + if app.cancel != nil { + app.cancel() + } + return lastErr +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add drainable.go drainable_test.go application.go builder.go +git commit -m "feat: add Drainable interface with PreStop drain phase" +``` + +--- + +### Task 3: Application Phase Tracking + +**Files:** +- Create: `phase.go` — AppPhase type, constants, String() +- Modify: `application.go` — add `phase` field, `Phase()` method, phase transitions +- Modify: `observer.go` — add `EventTypeAppPhaseChanged` constant +- Create: `phase_test.go` — tests + +**Step 1: Write the failing test** + +Create `phase_test.go`: + +```go +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + // After Init, phase should be past initializing (at least initialized) + phase := stdApp.Phase() + if phase != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", phase) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestAppPhase -count=1 -v && go test -run TestPhaseTracking -count=1 -v` +Expected: FAIL — `AppPhase` undefined + +**Step 3: Implement** + +Create `phase.go`: +```go +package modular + +import "sync/atomic" + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} +``` + +Add `phase atomic.Int32` field to `StdApplication`. Add `Phase()` method: +```go +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + app.phase.Store(int32(p)) +} +``` + +Add `EventTypeAppPhaseChanged` to `observer.go`: +```go +EventTypeAppPhaseChanged = "com.modular.application.phase.changed" +``` + +In `InitWithApp()`, wrap with phase transitions: +```go +app.setPhase(PhaseInitializing) +// ... existing init logic ... +app.setPhase(PhaseInitialized) +``` + +In `Start()`: +```go +app.setPhase(PhaseStarting) +// ... existing start logic ... +app.setPhase(PhaseRunning) +``` + +In `Stop()`: +```go +app.setPhase(PhaseDraining) +// ... PreStop phase ... +app.setPhase(PhaseStopping) +// ... Stop phase ... +app.setPhase(PhaseStopped) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestAppPhase|TestPhaseTracking" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add phase.go phase_test.go application.go observer.go +git commit -m "feat: add application phase tracking with lifecycle transitions" +``` + +--- + +### Task 4: Parallel Init at Same Topological Depth + +**Files:** +- Modify: `builder.go` — add `WithParallelInit` option +- Modify: `application.go` — parallel init logic using `errgroup` +- Create: `parallel_init_test.go` — tests + +**Step 1: Write the failing test** + +Create `parallel_init_test.go`: + +```go +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + + // Track max concurrency + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + + // Three independent modules (no deps) — should init concurrently + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + // Should complete faster than 3 * 50ms sequential + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + // dep → a, dep → b (a and b can be parallel, dep must be first) + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: FAIL — `WithParallelInit` undefined + +**Step 3: Implement** + +Add `parallelInit bool` field to `ApplicationBuilder` and `StdApplication`. + +Add builder option: +```go +// WithParallelInit enables concurrent initialization of modules at the same +// topological depth in the dependency graph. Disabled by default. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} +``` + +Propagate in `Build()` similar to other fields. + +In `application.go`, add a method to compute topological depth levels: +```go +// computeDepthLevels groups modules by their topological depth. +// Level 0 has no dependencies, level 1 depends only on level 0, etc. +func (app *StdApplication) computeDepthLevels(order []string) [][]string { + depth := make(map[string]int) + graph := make(map[string][]string) + + // Rebuild graph for depth calculation + for _, name := range order { + module := app.moduleRegistry[name] + if depAware, ok := module.(DependencyAware); ok { + graph[name] = depAware.Dependencies() + } + // Include config-driven hints + for _, hint := range app.dependencyHints { + if hint.From == name { + graph[name] = append(graph[name], hint.To) + } + } + } + + // Compute depths + var computeDepth func(string) int + computeDepth = func(name string) int { + if d, ok := depth[name]; ok { + return d + } + maxDep := 0 + for _, dep := range graph[name] { + if d := computeDepth(dep) + 1; d > maxDep { + maxDep = d + } + } + depth[name] = maxDep + return maxDep + } + + for _, name := range order { + computeDepth(name) + } + + // Group by depth + maxDepth := 0 + for _, d := range depth { + if d > maxDepth { + maxDepth = d + } + } + + levels := make([][]string, maxDepth+1) + for _, name := range order { + d := depth[name] + levels[d] = append(levels[d], name) + } + return levels +} +``` + +Modify `InitWithApp` to use parallel init when enabled. Replace the sequential init loop with: +```go +if app.parallelInit { + levels := app.computeDepthLevels(moduleOrder) + for _, level := range levels { + if len(level) == 1 { + // Single module — init sequentially (no goroutine overhead) + if err := app.initModule(appToPass, level[0]); err != nil { + errs = append(errs, err) + } + } else { + // Multiple modules at same depth — init concurrently + var levelErrs []error + var mu sync.Mutex + var wg sync.WaitGroup + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if err := app.initModule(appToPass, name); err != nil { + mu.Lock() + levelErrs = append(levelErrs, err) + mu.Unlock() + } + }(moduleName) + } + wg.Wait() + errs = append(errs, levelErrs...) + } + } +} else { + // Sequential init (existing behavior) + for _, moduleName := range moduleOrder { + if err := app.initModule(appToPass, moduleName); err != nil { + errs = append(errs, err) + } + } +} +``` + +Extract the per-module init logic into a helper: +```go +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + var err error + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.SetCurrentModule(module) + } + + if err = module.Init(appToPass); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + if svcAware, ok := module.(ServiceAware); ok { + for _, svc := range svcAware.ProvidesServices() { + if err = app.RegisterService(svc.Name, svc.Instance); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) + return nil +} +``` + +**Note:** When parallel init is enabled, `SetCurrentModule`/`ClearCurrentModule` need mutex protection. Add a mutex to the init path or guard the enhanced registry calls. + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All existing tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go parallel_init_test.go +git commit -m "feat: add WithParallelInit for concurrent module initialization" +``` + +--- + +### Task 5: Type-Safe Service Helpers (Generics) + +**Files:** +- Create: `service_typed.go` — generic helper functions +- Create: `service_typed_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_typed_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testService struct { + Value string +} + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + svc := &testService{Value: "hello"} + if err := RegisterTypedService[*testService](app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + + got, err := GetTypedService[*testService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService[string](app, "str.svc", "hello") + + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestRegisterTypedService -count=1 -v && go test -run TestGetTypedService -count=1 -v` +Expected: FAIL — `RegisterTypedService` undefined + +**Step 3: Implement** + +Create `service_typed.go`: + +```go +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +// This is a package-level helper that wraps Application.RegisterService. +func RegisterTypedService[T any](app Application, name string, svc T) error { + return app.RegisterService(name, svc) +} + +// GetTypedService retrieves a service with compile-time type safety. +// Returns the zero value of T and an error if the service is not found +// or cannot be cast to the expected type. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestRegisterTypedService|TestGetTypedService" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service_typed.go service_typed_test.go +git commit -m "feat: add RegisterTypedService/GetTypedService generic helpers" +``` + +--- + +### Task 6: Service Readiness Events & OnServiceReady + +**Files:** +- Modify: `service.go` — add `OnServiceReady` method to `EnhancedServiceRegistry` +- Create: `service_readiness_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_readiness_test.go`: + +```go +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + + if called.Load() { + t.Error("callback should not have been called yet") + } + + registry.RegisterService("db", "postgres-conn") + + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + + registry.RegisterService("cache", "redis") + + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: FAIL — `OnServiceReady` undefined + +**Step 3: Implement** + +Add to `EnhancedServiceRegistry`: +```go +// readyCallbacks maps service names to pending callbacks. +readyCallbacks map[string][]func(any) +``` + +Initialize in `NewEnhancedServiceRegistry`: +```go +readyCallbacks: make(map[string][]func(any)), +``` + +Add the method: +```go +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + if entry, exists := r.services[name]; exists { + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) +} +``` + +Modify `RegisterService` to fire pending callbacks after registration: +```go +// After r.services[actualName] = entry, add: +// Fire readiness callbacks for the original name and the actual name. +for _, cbName := range []string{originalName, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + for _, cb := range callbacks { + cb(service) + } + delete(r.readyCallbacks, cbName) + } +} +``` + +Note: Use `originalName` as the variable name for the first parameter to `RegisterService` (it's called `name` in the current code — rename to `originalName` for clarity, or just use `name`). + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service.go service_readiness_test.go +git commit -m "feat: add OnServiceReady callback for service readiness events" +``` + +--- + +### Task 7: Plugin Interface & WithPlugins + +**Files:** +- Create: `plugin.go` — Plugin, PluginWithHooks, PluginWithServices interfaces + ServiceDefinition +- Modify: `builder.go` — add `WithPlugins` option +- Create: `plugin_test.go` — tests + +**Step 1: Write the failing test** + +Create `plugin_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginModule struct { + name string + initialized bool +} + +func (m *pluginModule) Name() string { return m.name } +func (m *pluginModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +// Test a simple plugin (no hooks, no services) +type simplePlugin struct { + modules []Module +} + +func (p *simplePlugin) Name() string { return "simple" } +func (p *simplePlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginModule{name: "simple-mod"} + plugin := &simplePlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: FAIL — `Plugin` undefined + +**Step 3: Implement** + +Create `plugin.go`: +```go +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} +``` + +Add `plugins []Plugin` to `ApplicationBuilder`. Add option: +```go +// WithPlugins registers plugins with the application. Each plugin's modules +// are registered, hooks are added as config-loaded hooks, and services are +// registered before module init. +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} +``` + +In `Build()`, after creating the app, process plugins: +```go +for _, plugin := range b.plugins { + // Register plugin modules + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + + // Register plugin services + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + + // Register plugin hooks + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add plugin.go plugin_test.go builder.go +git commit -m "feat: add Plugin interface with WithPlugins builder option" +``` + +--- + +### Task 8: ReloadOrchestrator Integration (`WithDynamicReload`) + +**Files:** +- Modify: `builder.go` — add `WithDynamicReload` option +- Modify: `application.go` — wire orchestrator into Start/Stop, expose `RequestReload` +- Create: `reload_integration_test.go` — tests + +**Step 1: Write the failing test** + +Create `reload_integration_test.go`: + +```go +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + + // Request a reload + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + err = stdApp.RequestReload(context.Background(), ReloadManual, diff) + if err != nil { + t.Fatalf("RequestReload: %v", err) + } + + // Wait for reload to process + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: FAIL — `WithDynamicReload` undefined + +**Step 3: Implement** + +Add `dynamicReload bool` to `ApplicationBuilder`. +Add `reloadOrchestrator *ReloadOrchestrator` to `StdApplication`. + +Builder option: +```go +// WithDynamicReload enables the ReloadOrchestrator, wiring it into the +// application lifecycle. Reloadable modules are auto-registered after Init, +// and the orchestrator starts/stops with the application. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} +``` + +In `Build()`, propagate: +```go +if b.dynamicReload { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } +} +``` + +Add `dynamicReload bool` field to `StdApplication`. + +In `InitWithApp`, after all modules are initialized (before marking initialized), register reloadables: +```go +if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } +} +``` + +In `Start()`, after starting all modules: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) +} +``` + +In `Stop()`, before draining: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() +} +``` + +Add `RequestReload` method: +```go +// RequestReload enqueues a reload request. Only available when WithDynamicReload is enabled. +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return fmt.Errorf("dynamic reload not enabled") + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go reload_integration_test.go +git commit -m "feat: add WithDynamicReload to wire ReloadOrchestrator into app lifecycle" +``` + +--- + +### Task 9: Secret Resolution Hooks + +**Files:** +- Create: `secret_resolver.go` — SecretResolver interface + ExpandSecrets utility +- Create: `secret_resolver_test.go` — tests + +**Step 1: Write the failing test** + +Create `secret_resolver_test.go`: + +```go +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockResolver struct { + prefix string + values map[string]string +} + +func (r *mockResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockResolver{ + prefix: "vault", + values: map[string]string{ + "secret/db-pass": "s3cret", + }, + } + + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{ + "key": "${vault:secret/db-pass}", + }, + } + + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{ + "host": "localhost", + "port": 5432, + } + + err := ExpandSecrets(context.Background(), config) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{ + "password": "${aws:secret/key}", + } + + resolver := &mockResolver{prefix: "vault", values: map[string]string{}} + + // No matching resolver — value should remain unchanged + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: FAIL — `SecretResolver` undefined + +**Step 3: Implement** + +Create `secret_resolver.go`: + +```go +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +// Implementations connect to secret stores (Vault, AWS Secrets Manager, etc.) +type SecretResolver interface { + // ResolveSecret resolves a secret reference string to its actual value. + ResolveSecret(ctx context.Context, ref string) (string, error) + + // CanResolve reports whether this resolver handles the given reference. + CanResolve(ref string) bool +} + +// secretRefPattern matches ${prefix:path} patterns in config values. +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. It recurses into nested +// maps. Values that don't match or have no matching resolver are left unchanged. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + return r.ResolveSecret(ctx, ref) + } + } + // No matching resolver — return unchanged + return val, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add secret_resolver.go secret_resolver_test.go +git commit -m "feat: add SecretResolver interface and ExpandSecrets utility" +``` + +--- + +### Task 10: Config File Watcher Module + +**Files:** +- Create: `modules/configwatcher/configwatcher.go` — module implementation +- Create: `modules/configwatcher/configwatcher_test.go` — tests +- Modify: `go.mod` — add `github.com/fsnotify/fsnotify` dependency + +**Step 1: Add fsnotify dependency** + +Run: `cd /tmp/gca-modular && go get github.com/fsnotify/fsnotify` + +**Step 2: Write the test** + +Create `modules/configwatcher/configwatcher_test.go`: + +```go +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + // Modify the file + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for debounce + processing + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("v1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + // Rapid-fire writes + for i := 0; i < 5; i++ { + os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644) + time.Sleep(20 * time.Millisecond) + } + + // Wait for debounce + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} +``` + +**Step 3: Implement** + +Create `modules/configwatcher/configwatcher.go`: + +```go +// Package configwatcher provides a module that watches configuration files +// for changes and triggers reload via a callback. +package configwatcher + +import ( + "context" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +// WithPaths sets the file paths to watch. +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { + w.paths = append(w.paths, paths...) + } +} + +// WithDebounce sets the debounce duration for file change events. +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { + w.debounce = d + } +} + +// WithOnChange sets the callback invoked when watched files change. +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { + w.onChange = fn + } +} + +// New creates a new ConfigWatcher with the given options. +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +// Name returns the module name. +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init is a no-op for the config watcher module. +func (w *ConfigWatcher) Init(_ interface{ Logger() interface{ Info(string, ...any) } }) error { + return nil +} + +// Start begins watching the configured paths. +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +// Stop stops the file watcher. +func (w *ConfigWatcher) Stop(_ context.Context) error { + w.stopWatching() + return nil +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + w.watcher = watcher + + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return err + } + } + + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() { + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + w.watcher.Close() + } + }) +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + if w.onChange != nil { + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + changedPaths = make(map[string]struct{}) + w.onChange(paths) + } + }) + } + case _, ok := <-w.watcher.Errors: + if !ok { + return + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test ./modules/configwatcher/... -count=1 -v` +Expected: PASS + +**Step 5: Run `go mod tidy`** + +Run: `cd /tmp/gca-modular && go mod tidy` + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add modules/configwatcher/ go.mod go.sum +git commit -m "feat: add configwatcher module with fsnotify file watching" +``` + +--- + +### Task 11: Slog Adapter + +**Files:** +- Create: `slog_adapter.go` — SlogAdapter implementation +- Create: `slog_adapter_test.go` — tests + +**Step 1: Write the failing test** + +Create `slog_adapter_test.go`: + +```go +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) // compile-time check +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger) + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + if !strings.Contains(output, "test info") { + t.Error("expected info message in output") + } + if !strings.Contains(output, "test error") { + t.Error("expected error message in output") + } + if !strings.Contains(output, "test warn") { + t.Error("expected warn message in output") + } + if !strings.Contains(output, "test debug") { + t.Error("expected debug message in output") + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).With("module", "test") + adapter.Info("with test") + + output := buf.String() + if !strings.Contains(output, "module=test") { + t.Errorf("expected module=test in output, got: %s", output) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + + output := buf.String() + if !strings.Contains(output, "mygroup") { + t.Errorf("expected mygroup in output, got: %s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: FAIL — `SlogAdapter` undefined + +**Step 3: Implement** + +Create `slog_adapter.go`: + +```go +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +// This allows using Go's standard structured logger with the modular framework. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +// Info logs at info level. +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } + +// Error logs at error level. +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } + +// Warn logs at warn level. +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } + +// Debug logs at debug level. +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add slog_adapter.go slog_adapter_test.go +git commit -m "feat: add SlogAdapter wrapping *slog.Logger for Logger interface" +``` + +--- + +### Task 12: Module Metrics Hooks + +**Files:** +- Create: `metrics.go` — MetricsProvider interface, ModuleMetrics type, CollectAllMetrics +- Modify: `application.go` — add `CollectAllMetrics` method +- Create: `metrics_test.go` — tests + +**Step 1: Write the failing test** + +Create `metrics_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +type metricsModule struct { + name string +} + +func (m *metricsModule) Name() string { return m.name } +func (m *metricsModule) Init(app Application) error { return nil } +func (m *metricsModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{ + "requests_total": 100, + "error_rate": 0.02, + }, + } +} + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsModule{name: "api"} + modB := &pluginModule{name: "no-metrics"} // doesn't implement MetricsProvider + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: FAIL — `MetricsProvider` undefined + +**Step 3: Implement** + +Create `metrics.go`: + +```go +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +// The framework collects metrics from all implementing modules on demand. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +Add method to `application.go`: + +```go +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + var results []ModuleMetrics + for _, module := range app.moduleRegistry { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add metrics.go metrics_test.go application.go +git commit -m "feat: add MetricsProvider interface and CollectAllMetrics" +``` + +--- + +## Post-Implementation + +After all 12 tasks are complete: + +1. Run full test suite: `cd /tmp/gca-modular && go test ./... -count=1 -race` +2. Run linter: `cd /tmp/gca-modular && golangci-lint run` +3. Run vet: `cd /tmp/gca-modular && go vet ./...` +4. Fix any issues found + +All work is on the `feat/reimplementation` branch. Create a PR against `main` when complete. diff --git a/drainable.go b/drainable.go new file mode 100644 index 00000000..d6794e23 --- /dev/null +++ b/drainable.go @@ -0,0 +1,15 @@ +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. +type Drainable interface { + PreStop(ctx context.Context) error +} + +const defaultDrainTimeout = 15 * time.Second diff --git a/drainable_test.go b/drainable_test.go new file mode 100644 index 00000000..ac8d3586 --- /dev/null +++ b/drainable_test.go @@ -0,0 +1,71 @@ +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} diff --git a/enhanced_service_registry_test.go b/enhanced_service_registry_test.go index 6dbf0b50..70d04cfd 100644 --- a/enhanced_service_registry_test.go +++ b/enhanced_service_registry_test.go @@ -128,7 +128,7 @@ func TestEnhancedServiceRegistry_InterfaceDiscovery(t *testing.T) { registry.RegisterService("nonInterface", nonInterfaceService) // Discover by interface - interfaceType := reflect.TypeOf((*ServiceRegistryTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[ServiceRegistryTestInterface]() entries := registry.GetServicesByInterface(interfaceType) require.Len(t, entries, 2) diff --git a/errors.go b/errors.go index fb008a59..0b2c2528 100644 --- a/errors.go +++ b/errors.go @@ -92,6 +92,7 @@ var ( ErrReloadInProgress = errors.New("reload already in progress") ErrReloadStopped = errors.New("reload orchestrator is stopped") ErrReloadTimeout = errors.New("reload timed out waiting for module") + ErrDynamicReloadNotEnabled = errors.New("dynamic reload not enabled") // Observer/Event emission errors ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") diff --git a/event_emission_fix_test.go b/event_emission_fix_test.go index 58948e09..1feceb9c 100644 --- a/event_emission_fix_test.go +++ b/event_emission_fix_test.go @@ -62,7 +62,7 @@ func TestModuleEventEmissionWithoutSubject(t *testing.T) { } // testModuleNilSubjectHandling is a helper function that tests nil subject handling for a specific module -func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { +func testModuleNilSubjectHandling(t *testing.T, _, moduleName string) { // Create a mock application for testing app := &mockApplicationForNilSubjectTest{} @@ -85,7 +85,7 @@ func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { // Test the emitEvent helper pattern - this should not panic and should handle nil subject gracefully // We can't call the actual module's emitEvent helper directly since it's private, // but we can verify the pattern works by testing that no panic occurs - testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]interface{}{ + testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]any{ "test_key": "test_value", }) } @@ -135,7 +135,7 @@ func (t *testObservableModuleForNilSubject) EmitEvent(ctx context.Context, event } // testEmitEventHelper simulates the pattern used by modules' emitEvent helper methods -func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]interface{}) { +func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]any) { // This simulates the pattern used in modules - check for nil subject first if t.subject == nil { return // Should return silently without error @@ -162,13 +162,13 @@ type mockTestLogger struct { lastDebugMessage string } -func (l *mockTestLogger) Debug(msg string, args ...interface{}) { +func (l *mockTestLogger) Debug(msg string, args ...any) { l.lastDebugMessage = msg } -func (l *mockTestLogger) Info(msg string, args ...interface{}) {} -func (l *mockTestLogger) Warn(msg string, args ...interface{}) {} -func (l *mockTestLogger) Error(msg string, args ...interface{}) {} +func (l *mockTestLogger) Info(msg string, args ...any) {} +func (l *mockTestLogger) Warn(msg string, args ...any) {} +func (l *mockTestLogger) Error(msg string, args ...any) {} type mockApplicationForNilSubjectTest struct{} diff --git a/examples/advanced-logging/go.mod b/examples/advanced-logging/go.mod index f22c281e..19ef912f 100644 --- a/examples/advanced-logging/go.mod +++ b/examples/advanced-logging/go.mod @@ -1,8 +1,8 @@ module advanced-logging -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/base-config-example/go.mod b/examples/base-config-example/go.mod index 8e7510ef..174de6a5 100644 --- a/examples/base-config-example/go.mod +++ b/examples/base-config-example/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/examples/base-config-example -go 1.25 +go 1.26 require github.com/GoCodeAlone/modular v1.11.9 diff --git a/examples/basic-app/go.mod b/examples/basic-app/go.mod index 0b9d5fac..9544bdf3 100644 --- a/examples/basic-app/go.mod +++ b/examples/basic-app/go.mod @@ -1,6 +1,6 @@ module basic-app -go 1.25 +go 1.26 replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/feature-flag-proxy/go.mod b/examples/feature-flag-proxy/go.mod index caf4081f..508143ff 100644 --- a/examples/feature-flag-proxy/go.mod +++ b/examples/feature-flag-proxy/go.mod @@ -1,8 +1,8 @@ module feature-flag-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/health-aware-reverse-proxy/go.mod b/examples/health-aware-reverse-proxy/go.mod index 8137abec..7425035d 100644 --- a/examples/health-aware-reverse-proxy/go.mod +++ b/examples/health-aware-reverse-proxy/go.mod @@ -1,8 +1,8 @@ module health-aware-reverse-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/http-client/go.mod b/examples/http-client/go.mod index d44a01d4..9788c6e8 100644 --- a/examples/http-client/go.mod +++ b/examples/http-client/go.mod @@ -1,8 +1,8 @@ module http-client -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/instance-aware-db/go.mod b/examples/instance-aware-db/go.mod index 4d94e548..08b39a57 100644 --- a/examples/instance-aware-db/go.mod +++ b/examples/instance-aware-db/go.mod @@ -1,21 +1,20 @@ module instance-aware-db -go 1.25 +go 1.26 replace github.com/GoCodeAlone/modular => ../.. -replace github.com/GoCodeAlone/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database/v2 => ../../modules/database require ( - github.com/GoCodeAlone/modular v1.11.11 - github.com/GoCodeAlone/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 github.com/mattn/go-sqlite3 v1.14.32 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect diff --git a/examples/instance-aware-db/go.sum b/examples/instance-aware-db/go.sum index 68f4e106..ac3f21fa 100644 --- a/examples/instance-aware-db/go.sum +++ b/examples/instance-aware-db/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/instance-aware-db/main.go b/examples/instance-aware-db/main.go index 1fad6112..ae403aab 100644 --- a/examples/instance-aware-db/main.go +++ b/examples/instance-aware-db/main.go @@ -8,7 +8,7 @@ import ( "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/modular/feeders" - "github.com/GoCodeAlone/modular/modules/database" + "github.com/GoCodeAlone/modular/modules/database/v2" // Import SQLite driver _ "github.com/mattn/go-sqlite3" diff --git a/examples/logger-reconfiguration/go.mod b/examples/logger-reconfiguration/go.mod index f1764e34..80c81cac 100644 --- a/examples/logger-reconfiguration/go.mod +++ b/examples/logger-reconfiguration/go.mod @@ -1,6 +1,6 @@ module logger-reconfiguration -go 1.25 +go 1.26 replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/logmasker-example/go.mod b/examples/logmasker-example/go.mod index 9cfeecc8..1b736f14 100644 --- a/examples/logmasker-example/go.mod +++ b/examples/logmasker-example/go.mod @@ -1,6 +1,6 @@ module logmasker-example -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/multi-engine-eventbus/go.mod b/examples/multi-engine-eventbus/go.mod index fb8dad1b..a719a96f 100644 --- a/examples/multi-engine-eventbus/go.mod +++ b/examples/multi-engine-eventbus/go.mod @@ -1,8 +1,8 @@ module multi-engine-eventbus -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/multi-tenant-app/go.mod b/examples/multi-tenant-app/go.mod index e03d7f80..e2e78871 100644 --- a/examples/multi-tenant-app/go.mod +++ b/examples/multi-tenant-app/go.mod @@ -1,6 +1,6 @@ module multi-tenant-app -go 1.25 +go 1.26 replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/nats-eventbus/go.mod b/examples/nats-eventbus/go.mod index 0816901f..b7219f7a 100644 --- a/examples/nats-eventbus/go.mod +++ b/examples/nats-eventbus/go.mod @@ -1,8 +1,8 @@ module nats-eventbus -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/observer-demo/go.mod b/examples/observer-demo/go.mod index ba497069..c5e631ac 100644 --- a/examples/observer-demo/go.mod +++ b/examples/observer-demo/go.mod @@ -1,8 +1,8 @@ module observer-demo -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 replace github.com/GoCodeAlone/modular => ../.. diff --git a/examples/observer-pattern/go.mod b/examples/observer-pattern/go.mod index e6b1b958..4a9bbe68 100644 --- a/examples/observer-pattern/go.mod +++ b/examples/observer-pattern/go.mod @@ -1,8 +1,8 @@ module observer-pattern -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/reverse-proxy/go.mod b/examples/reverse-proxy/go.mod index 4fa492c0..9803cefe 100644 --- a/examples/reverse-proxy/go.mod +++ b/examples/reverse-proxy/go.mod @@ -1,8 +1,8 @@ module reverse-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/testing-scenarios/go.mod b/examples/testing-scenarios/go.mod index e65877e7..2f0f76a0 100644 --- a/examples/testing-scenarios/go.mod +++ b/examples/testing-scenarios/go.mod @@ -1,8 +1,8 @@ module testing-scenarios -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/examples/verbose-debug/go.mod b/examples/verbose-debug/go.mod index e9c7299c..7ce8aaec 100644 --- a/examples/verbose-debug/go.mod +++ b/examples/verbose-debug/go.mod @@ -1,19 +1,18 @@ module verbose-debug -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/GoCodeAlone/modular v1.11.11 - github.com/GoCodeAlone/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 modernc.org/sqlite v1.38.0 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect @@ -69,4 +68,4 @@ require ( replace github.com/GoCodeAlone/modular => ../.. // Use local database module for development -replace github.com/GoCodeAlone/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database/v2 => ../../modules/database diff --git a/examples/verbose-debug/go.sum b/examples/verbose-debug/go.sum index adab12eb..17caa3ee 100644 --- a/examples/verbose-debug/go.sum +++ b/examples/verbose-debug/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/verbose-debug/main.go b/examples/verbose-debug/main.go index 84870103..e49a4462 100644 --- a/examples/verbose-debug/main.go +++ b/examples/verbose-debug/main.go @@ -8,7 +8,7 @@ import ( "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/modular/feeders" - "github.com/GoCodeAlone/modular/modules/database" + "github.com/GoCodeAlone/modular/modules/database/v2" // Import SQLite driver for database connections _ "modernc.org/sqlite" diff --git a/feeder_priority_test.go b/feeder_priority_test.go index 5dff6c8b..dfb47991 100644 --- a/feeder_priority_test.go +++ b/feeder_priority_test.go @@ -344,7 +344,7 @@ func TestAffixedEnvFeederPriority(t *testing.T) { feeder := feeders.NewAffixedEnvFeeder("PREFIX_", "").WithPriority(100) // Verify priority was set - prioritized, ok := interface{}(feeder).(PrioritizedFeeder) + prioritized, ok := any(feeder).(PrioritizedFeeder) if !ok { t.Fatal("AffixedEnvFeeder does not implement PrioritizedFeeder interface") } @@ -413,7 +413,7 @@ func TestTenantAffixedEnvFeederPriority(t *testing.T) { tenantFeeder.SetPrefixFunc("tenant1") // Test priority was set correctly - prioritized, ok := interface{}(tenantFeeder).(PrioritizedFeeder) + prioritized, ok := any(tenantFeeder).(PrioritizedFeeder) if !ok { t.Fatal("TenantAffixedEnvFeeder does not implement PrioritizedFeeder interface") } diff --git a/feeders/yaml.go b/feeders/yaml.go index 85e2280a..1e42e11e 100644 --- a/feeders/yaml.go +++ b/feeders/yaml.go @@ -11,6 +11,16 @@ import ( "gopkg.in/yaml.v3" ) +// wrapDebugLogger returns a func(string) that calls the logger's Debug method. +// This indirection avoids go vet false positives about non-constant format strings +// passed to Debug(msg string, args ...any) interface methods. +// +//go:noinline +func wrapDebugLogger(logger interface{ Debug(msg string, args ...any) }) func(string) { + debug := logger.Debug + return func(msg string) { debug(msg) } +} + // parseYAMLTag parses a YAML struct tag and returns the field name and options func parseYAMLTag(tag string) (fieldName string, options []string) { if tag == "" { @@ -46,9 +56,7 @@ func getFieldNameFromTag(fieldType *reflect.StructField) (string, bool) { type YamlFeeder struct { Path string verboseDebug bool - logger interface { - Debug(msg string, args ...any) - } + debugFn func(string) fieldTracker FieldTracker priority int } @@ -58,7 +66,7 @@ func NewYamlFeeder(filePath string) *YamlFeeder { return &YamlFeeder{ Path: filePath, verboseDebug: false, - logger: nil, + debugFn: nil, fieldTracker: nil, priority: 0, // Default priority } @@ -80,9 +88,13 @@ func (y *YamlFeeder) Priority() int { // SetVerboseDebug enables or disables verbose debug logging func (y *YamlFeeder) SetVerboseDebug(enabled bool, logger interface{ Debug(msg string, args ...any) }) { y.verboseDebug = enabled - y.logger = logger + if logger != nil { + y.debugFn = wrapDebugLogger(logger) + } else { + y.debugFn = nil + } if enabled && logger != nil { - y.logger.Debug("Verbose YAML feeder debugging enabled") + logger.Debug("Verbose YAML feeder debugging enabled") } } @@ -91,21 +103,39 @@ func (y *YamlFeeder) SetFieldTracker(tracker FieldTracker) { y.fieldTracker = tracker } +// debugLog logs a debug message with key-value pairs when verbose debugging is enabled. +// Key-value pairs are formatted into the message string to avoid go vet printf false positives +// on the Debug(msg string, args ...any) interface method signature. +func (y *YamlFeeder) debugLog(msg string, keysAndValues ...any) { + if !y.verboseDebug || y.debugFn == nil { + return + } + if len(keysAndValues) == 0 { + y.debugFn(msg) + return + } + var b strings.Builder + b.WriteString(msg) + for i := 0; i+1 < len(keysAndValues); i += 2 { + fmt.Fprintf(&b, " %v=%v", keysAndValues[i], keysAndValues[i+1]) + } + if len(keysAndValues)%2 != 0 { + fmt.Fprintf(&b, " %v", keysAndValues[len(keysAndValues)-1]) + } + y.debugFn(b.String()) +} + // Feed reads the YAML file and populates the provided structure func (y *YamlFeeder) Feed(structure interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting feed process", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Starting feed process", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) // Always use custom parsing logic for consistency err := y.feedWithTracking(structure) - if y.verboseDebug && y.logger != nil { - if err != nil { - y.logger.Debug("YamlFeeder: Feed completed with error", "filePath", y.Path, "error", err) - } else { - y.logger.Debug("YamlFeeder: Feed completed successfully", "filePath", y.Path) - } + if err != nil { + y.debugLog("YamlFeeder: Feed completed with error", "filePath", y.Path, "error", err) + } else { + y.debugLog("YamlFeeder: Feed completed successfully", "filePath", y.Path) } if err != nil { return fmt.Errorf("yaml feed error: %w", err) @@ -115,68 +145,50 @@ func (y *YamlFeeder) Feed(structure interface{}) error { // FeedKey reads a YAML file and extracts a specific key func (y *YamlFeeder) FeedKey(key string, target interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting FeedKey process", "filePath", y.Path, "key", key, "targetType", reflect.TypeOf(target)) - } + y.debugLog("YamlFeeder: Starting FeedKey process", "filePath", y.Path, "key", key, "targetType", reflect.TypeOf(target)) // Create a temporary map to hold all YAML data var allData map[interface{}]interface{} // Use the embedded Yaml feeder to read the file if err := y.Feed(&allData); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) return fmt.Errorf("failed to read YAML: %w", err) } // Look for the specific key value, exists := allData[key] if !exists { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Key not found in YAML file", "filePath", y.Path, "key", key) - } + y.debugLog("YamlFeeder: Key not found in YAML file", "filePath", y.Path, "key", key) return nil } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found key in YAML file", "filePath", y.Path, "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Found key in YAML file", "filePath", y.Path, "key", key, "valueType", reflect.TypeOf(value)) // Remarshal and unmarshal to handle type conversions valueBytes, err := yaml.Marshal(value) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to marshal value", "filePath", y.Path, "key", key, "error", err) - } + y.debugLog("YamlFeeder: Failed to marshal value", "filePath", y.Path, "key", key, "error", err) return fmt.Errorf("failed to marshal value: %w", err) } if err = yaml.Unmarshal(valueBytes, target); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to unmarshal value to target", "filePath", y.Path, "key", key, "error", err) - } + y.debugLog("YamlFeeder: Failed to unmarshal value to target", "filePath", y.Path, "key", key, "error", err) return fmt.Errorf("failed to unmarshal value to target: %w", err) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: FeedKey completed successfully", "filePath", y.Path, "key", key) - } + y.debugLog("YamlFeeder: FeedKey completed successfully", "filePath", y.Path, "key", key) return nil } // feedWithTracking processes YAML data with field tracking support func (y *YamlFeeder) feedWithTracking(structure interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting feedWithTracking", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Starting feedWithTracking", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) // Read YAML file content, err := os.ReadFile(y.Path) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) return fmt.Errorf("failed to read YAML file: %w", err) } @@ -184,9 +196,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { structValue := reflect.ValueOf(structure) if structValue.Kind() != reflect.Ptr || structValue.Elem().Kind() != reflect.Struct { // Not a struct pointer, fall back to standard YAML unmarshaling - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Not a struct pointer, using standard YAML unmarshaling", "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Not a struct pointer, using standard YAML unmarshaling", "structureType", reflect.TypeOf(structure)) if err := yaml.Unmarshal(content, structure); err != nil { return fmt.Errorf("failed to unmarshal YAML data: %w", err) } @@ -196,9 +206,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { // Parse YAML content data := make(map[string]interface{}) if err := yaml.Unmarshal(content, &data); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to parse YAML content", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to parse YAML content", "filePath", y.Path, "error", err) return fmt.Errorf("failed to parse YAML content: %w", err) } @@ -210,9 +218,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { func (y *YamlFeeder) processStructFields(rv reflect.Value, data map[string]interface{}, parentPath string) error { structType := rv.Type() - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing struct fields", "structType", structType, "numFields", rv.NumField(), "parentPath", parentPath) - } + y.debugLog("YamlFeeder: Processing struct fields", "structType", structType, "numFields", rv.NumField(), "parentPath", parentPath) for i := 0; i < rv.NumField(); i++ { field := rv.Field(i) @@ -224,14 +230,10 @@ func (y *YamlFeeder) processStructFields(rv reflect.Value, data map[string]inter fieldPath = parentPath + "." + fieldType.Name } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing field", "fieldName", fieldType.Name, "fieldType", fieldType.Type, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing field", "fieldName", fieldType.Name, "fieldType", fieldType.Type, "fieldPath", fieldPath) if err := y.processField(field, &fieldType, data, fieldPath); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Field processing failed", "fieldName", fieldType.Name, "error", err) - } + y.debugLog("YamlFeeder: Field processing failed", "fieldName", fieldType.Name, "error", err) return fmt.Errorf("error in field '%s': %w", fieldType.Name, err) } } @@ -260,9 +262,7 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct return y.setArrayFromYAML(field, fieldName, data, fieldType.Name, fieldPath) } case reflect.Map: - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing map field", "fieldName", fieldType.Name, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing map field", "fieldName", fieldType.Name, "fieldPath", fieldPath) if hasYAMLTag { // Look for map data using the parsed field name @@ -270,20 +270,14 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct if mapDataTyped, ok := mapData.(map[string]interface{}); ok { return y.setMapFromYaml(field, mapDataTyped, fieldType.Name, fieldPath) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map YAML data is not a map[string]interface{}", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(mapData)) - } + y.debugLog("YamlFeeder: Map YAML data is not a map[string]interface{}", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(mapData)) } } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) - } + y.debugLog("YamlFeeder: Map YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) } } case reflect.Struct: - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing nested struct", "fieldName", fieldType.Name, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing nested struct", "fieldName", fieldType.Name, "fieldPath", fieldPath) if hasYAMLTag { // Look for nested data using the parsed field name @@ -291,14 +285,10 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct if nestedMap, ok := nestedData.(map[string]interface{}); ok { return y.processStructFields(field, nestedMap, fieldPath) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Nested YAML data is not a map", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(nestedData)) - } + y.debugLog("YamlFeeder: Nested YAML data is not a map", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(nestedData)) } } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Nested YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) - } + y.debugLog("YamlFeeder: Nested YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) } } else { // No yaml tag, use the same data map @@ -310,23 +300,17 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct reflect.Chan, reflect.Func, reflect.Interface, reflect.String, reflect.UnsafePointer: // Check for yaml tag for primitive types and other non-struct types if hasYAMLTag { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) return y.setFieldFromYaml(field, fieldName, data, fieldType.Name, fieldPath) - } else if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } + y.debugLog("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) default: // Check for yaml tag for primitive types and other non-struct types if hasYAMLTag { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) return y.setFieldFromYaml(field, fieldName, data, fieldType.Name, fieldPath) - } else if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } + y.debugLog("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } return nil @@ -602,18 +586,14 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data if value, exists := data[yamlTag]; exists { foundValue = value foundKey = yamlTag - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found YAML value", "fieldName", fieldName, "yamlKey", yamlTag, "value", value, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found YAML value", "fieldName", fieldName, "yamlKey", yamlTag, "value", value, "fieldPath", fieldPath) } if foundValue != nil { // Set the field value err := y.setFieldValue(field, foundValue) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "error", err, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Failed to set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "error", err, "fieldPath", fieldPath) return err } @@ -634,9 +614,7 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Successfully set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "fieldPath", fieldPath) } else { // Record that we searched but didn't find if y.fieldTracker != nil { @@ -655,9 +633,7 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: YAML value not found", "fieldName", fieldName, "yamlKey", yamlTag, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: YAML value not found", "fieldName", fieldName, "yamlKey", yamlTag, "fieldPath", fieldPath) } return nil @@ -673,9 +649,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyType := mapType.Key() valueType := mapType.Elem() - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Setting map from YAML", "fieldName", fieldName, "mapType", mapType, "keyType", keyType, "valueType", valueType) - } + y.debugLog("YamlFeeder: Setting map from YAML", "fieldName", fieldName, "mapType", mapType, "keyType", keyType, "valueType", valueType) // Create a new map newMap := reflect.MakeMap(mapType) @@ -698,9 +672,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyValue := reflect.ValueOf(key) newMap.SetMapIndex(keyValue, structValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) } } case reflect.Ptr: @@ -723,13 +695,9 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyValue := reflect.ValueOf(key) newMap.SetMapIndex(keyValue, ptrValue) - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully processed pointer to struct map entry", "key", key, "structType", elemType) - } + y.debugLog("YamlFeeder: Successfully processed pointer to struct map entry", "key", key, "structType", elemType) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) } } } else { @@ -746,9 +714,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int ptrValue.Elem().Set(convertedValue) newMap.SetMapIndex(keyValue, ptrValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value for pointer type", "key", key, "valueType", valueReflect.Type(), "targetType", elemType) - } + y.debugLog("YamlFeeder: Cannot convert map value for pointer type", "key", key, "valueType", valueReflect.Type(), "targetType", elemType) } } } @@ -766,9 +732,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int convertedValue := valueReflect.Convert(valueType) newMap.SetMapIndex(keyValue, convertedValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) - } + y.debugLog("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) } } default: @@ -781,9 +745,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int convertedValue := valueReflect.Convert(valueType) newMap.SetMapIndex(keyValue, convertedValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) - } + y.debugLog("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) } } } @@ -808,9 +770,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully set map field", "fieldName", fieldName, "mapSize", newMap.Len()) - } + y.debugLog("YamlFeeder: Successfully set map field", "fieldName", fieldName, "mapSize", newMap.Len()) return nil } diff --git a/feeders/yaml_basic_test.go b/feeders/yaml_basic_test.go index f8cbb1b3..30ee0110 100644 --- a/feeders/yaml_basic_test.go +++ b/feeders/yaml_basic_test.go @@ -132,8 +132,8 @@ func TestYamlFeeder_NewYamlFeeder(t *testing.T) { if feeder.verboseDebug { t.Error("Expected verboseDebug to be false by default") } - if feeder.logger != nil { - t.Error("Expected logger to be nil by default") + if feeder.debugFn != nil { + t.Error("Expected debugFn to be nil by default") } if feeder.fieldTracker != nil { t.Error("Expected fieldTracker to be nil by default") @@ -149,8 +149,8 @@ func TestYamlFeeder_SetVerboseDebug(t *testing.T) { if !feeder.verboseDebug { t.Error("Expected verboseDebug to be true") } - if feeder.logger != logger { - t.Error("Expected logger to be set") + if feeder.debugFn == nil { + t.Error("Expected debugFn to be set") } // Check that debug message was logged diff --git a/go.mod b/go.mod index dae46b2b..a53c44f6 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ module github.com/GoCodeAlone/modular -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.1 require ( github.com/BurntSushi/toml v1.6.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 + github.com/fsnotify/fsnotify v1.9.0 github.com/golobby/cast v1.3.3 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 @@ -32,4 +33,5 @@ require ( github.com/stretchr/objx v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/sys v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 6d98ce0d..bef74aad 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -84,6 +86,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/health_service.go b/health_service.go index e23ef08a..afbd4570 100644 --- a/health_service.go +++ b/health_service.go @@ -3,6 +3,7 @@ package modular import ( "context" "fmt" + "maps" "sync" "time" ) @@ -107,9 +108,7 @@ func (s *AggregateHealthService) Check(ctx context.Context) (*AggregatedHealth, // Snapshot providers under read lock s.mu.RLock() providers := make(map[string]HealthProvider, len(s.providers)) - for k, v := range s.providers { - providers[k] = v - } + maps.Copy(providers, s.providers) s.mu.RUnlock() // Fan-out to all providers @@ -222,9 +221,7 @@ func (s *AggregateHealthService) deepCopyAggregated(src *AggregatedHealth) *Aggr dst.Reports[i] = r if r.Details != nil { dst.Reports[i].Details = make(map[string]any, len(r.Details)) - for k, v := range r.Details { - dst.Reports[i].Details[k] = v - } + maps.Copy(dst.Reports[i].Details, r.Details) } } return dst diff --git a/health_test.go b/health_test.go index 5dec4945..96e47683 100644 --- a/health_test.go +++ b/health_test.go @@ -413,9 +413,7 @@ func TestAggregateHealthService_ConcurrentChecks(t *testing.T) { errs := make(chan error, goroutines) for range goroutines { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { result, err := svc.Check(context.Background()) if err != nil { errs <- err @@ -425,7 +423,7 @@ func TestAggregateHealthService_ConcurrentChecks(t *testing.T) { errs <- errors.New("nil result") return } - }() + }) } wg.Wait() diff --git a/implicit_dependency_bug_test.go b/implicit_dependency_bug_test.go index cc2451bd..50e8d6ee 100644 --- a/implicit_dependency_bug_test.go +++ b/implicit_dependency_bug_test.go @@ -88,7 +88,7 @@ func TestImplicitDependencyDeterministicFix(t *testing.T) { // This test will pass once we fix the dependency resolution to be deterministic attempts := 20 - for i := 0; i < attempts; i++ { + for i := range attempts { err := runSingleImplicitDependencyTestWithFix() if err != nil { t.Fatalf("Attempt %d failed after fix: %v", i+1, err) @@ -164,7 +164,7 @@ func TestNamingGameAttempt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 5; attempt++ { + for attempt := range 5 { err := runNamingGameTest(tt.providerName, tt.consumerName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -253,7 +253,7 @@ func TestServiceNamingGameAttempt(t *testing.T) { } // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 3; attempt++ { + for attempt := range 3 { err := runServiceNamingGameTest(tt.serviceName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -353,7 +353,7 @@ func (m *FlakyServerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } @@ -476,7 +476,7 @@ func (m *CustomServiceConsumerModule) RequiresServices() []ServiceDependency { Name: m.serviceName, Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } diff --git a/instance_aware_comprehensive_regression_test.go b/instance_aware_comprehensive_regression_test.go index 113f3c42..6df997e9 100644 --- a/instance_aware_comprehensive_regression_test.go +++ b/instance_aware_comprehensive_regression_test.go @@ -347,8 +347,8 @@ func testRegressionDetectionCopyVsOriginal(t *testing.T) { // Create a "broken" version of GetInstanceConfigs that returns copies // This simulates what would happen if someone reverted the fix - brokenGetInstanceConfigs := func() map[string]interface{} { - instances := make(map[string]interface{}) + brokenGetInstanceConfigs := func() map[string]any { + instances := make(map[string]any) for name, connection := range config.Connections { // BUG: Creating a copy instead of returning pointer to original connectionCopy := *connection diff --git a/instance_aware_config.go b/instance_aware_config.go index 930236a9..36056af1 100644 --- a/instance_aware_config.go +++ b/instance_aware_config.go @@ -27,5 +27,5 @@ func (p *InstanceAwareConfigProvider) GetInstancePrefixFunc() InstancePrefixFunc // InstanceAwareConfigSupport indicates that a configuration supports instance-aware feeding type InstanceAwareConfigSupport interface { // GetInstanceConfigs returns a map of instance configurations that should be fed with instance-aware feeders - GetInstanceConfigs() map[string]interface{} + GetInstanceConfigs() map[string]any } diff --git a/instance_aware_feeding_test.go b/instance_aware_feeding_test.go index 8a028a0c..5dd50f71 100644 --- a/instance_aware_feeding_test.go +++ b/instance_aware_feeding_test.go @@ -280,8 +280,8 @@ func (c *TestDatabaseConfig) Validate() error { return nil } -func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, connection := range c.Connections { instances[name] = connection } @@ -302,8 +302,8 @@ func (c *TestWebappConfig) Validate() error { return nil } -func (c *TestWebappConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestWebappConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, instance := range c.Instances { instances[name] = instance } @@ -433,7 +433,7 @@ func testWebappInstanceAwareFeedingResults(t *testing.T, provider ConfigProvider func splitKey(key string) []string { parts := make([]string, 0, 2) - for i := 0; i < 2; i++ { + for i := range 2 { if dotIndex := findDotIndex(key); dotIndex != -1 { if i == 0 { parts = append(parts, key[:dotIndex]) @@ -561,8 +561,8 @@ func (c *TestInstanceConfig) Validate() error { return nil } -func (c *TestInstanceConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestInstanceConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, item := range c.Items { instances[name] = item } diff --git a/interface_dependencies_test.go b/interface_dependencies_test.go index 5bf9cdc8..efbdca88 100644 --- a/interface_dependencies_test.go +++ b/interface_dependencies_test.go @@ -31,7 +31,7 @@ func TestInterfaceDependencies(t *testing.T) { app.RegisterModule(serviceProviderModule) // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -147,7 +147,7 @@ func (m *RouterConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*Router)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[Router](), }, } } diff --git a/interface_matching_test.go b/interface_matching_test.go index 295e4edb..8660639d 100644 --- a/interface_matching_test.go +++ b/interface_matching_test.go @@ -30,7 +30,7 @@ func TestInterfaceMatching(t *testing.T) { app.RegisterModule(providerModule) // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -258,7 +258,7 @@ func TestDependencyOrderWithInterfaceMatching(t *testing.T) { // With the improved dependency resolution, the provider should come before consumer // even though we registered them in the opposite order - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -353,7 +353,7 @@ func (m *InterfaceConsumerModule) RequiresServices() []ServiceDependency { Name: "router.service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } @@ -436,7 +436,7 @@ func (m *CustomNameConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } diff --git a/logger_decorator_assertions_bdd_test.go b/logger_decorator_assertions_bdd_test.go index 9d3b4d1b..a511d9fc 100644 --- a/logger_decorator_assertions_bdd_test.go +++ b/logger_decorator_assertions_bdd_test.go @@ -174,7 +174,7 @@ func (ctx *LoggerDecoratorBDDTestContext) theMessagesShouldHaveLevels(expectedLe func InitializeLoggerDecoratorScenario(ctx *godog.ScenarioContext) { testCtx := &LoggerDecoratorBDDTestContext{ expectedArgs: make(map[string]string), - filterCriteria: make(map[string]interface{}), + filterCriteria: make(map[string]any), levelMappings: make(map[string]string), } diff --git a/logger_decorator_base_bdd_test.go b/logger_decorator_base_bdd_test.go index ad6d90cd..acd93702 100644 --- a/logger_decorator_base_bdd_test.go +++ b/logger_decorator_base_bdd_test.go @@ -13,12 +13,8 @@ var ( errPrimaryLoggerNotSet = errors.New("primary logger not set") errSecondaryLoggerNotSet = errors.New("secondary logger not set") errDecoratedLoggerNotSet = errors.New("decorated logger not set") - errNoMessagesLogged = errors.New("no messages logged") - errUnexpectedMessageCount = errors.New("unexpected message count") - errMessageNotFound = errors.New("message not found") - errArgNotFound = errors.New("argument not found") - errUnexpectedLogLevel = errors.New("unexpected log level") - errServiceLoggerMismatch = errors.New("service logger mismatch") + errNoMessagesLogged = errors.New("no messages logged") + errServiceLoggerMismatch = errors.New("service logger mismatch") ) // LoggerDecoratorBDDTestContext holds the test context for logger decorator BDD scenarios @@ -33,7 +29,7 @@ type LoggerDecoratorBDDTestContext struct { currentLogger Logger expectedMessages []string expectedArgs map[string]string - filterCriteria map[string]interface{} + filterCriteria map[string]any levelMappings map[string]string messageCount int expectedLevels []string diff --git a/logger_test.go b/logger_test.go index ec5cf56b..34055461 100644 --- a/logger_test.go +++ b/logger_test.go @@ -9,18 +9,18 @@ type MockLogger struct { mock.Mock } -func (m *MockLogger) Debug(msg string, args ...interface{}) { +func (m *MockLogger) Debug(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Info(msg string, args ...interface{}) { +func (m *MockLogger) Info(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Warn(msg string, args ...interface{}) { +func (m *MockLogger) Warn(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Error(msg string, args ...interface{}) { +func (m *MockLogger) Error(msg string, args ...any) { m.Called(msg, args) } diff --git a/metrics.go b/metrics.go new file mode 100644 index 00000000..d5e9fd07 --- /dev/null +++ b/metrics.go @@ -0,0 +1,14 @@ +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 00000000..561de7d0 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,74 @@ +package modular + +import ( + "context" + "testing" +) + +type metricsTestModule struct { + name string +} + +func (m *metricsTestModule) Name() string { return m.name } +func (m *metricsTestModule) Init(app Application) error { return nil } +func (m *metricsTestModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{"requests_total": 100, "error_rate": 0.02}, + } +} + +type nonMetricsModule struct { + name string +} + +func (m *nonMetricsModule) Name() string { return m.name } +func (m *nonMetricsModule) Init(app Application) error { return nil } + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsTestModule{name: "api"} + modB := &nonMetricsModule{name: "no-metrics"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} + +func TestCollectAllMetrics_NoProviders(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(&nonMetricsModule{name: "plain"}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + if len(metrics) != 0 { + t.Errorf("expected 0 metrics, got %d", len(metrics)) + } +} diff --git a/module_aware_env_config_test.go b/module_aware_env_config_test.go index 8b748e06..e4096d07 100644 --- a/module_aware_env_config_test.go +++ b/module_aware_env_config_test.go @@ -291,7 +291,7 @@ func TestModuleAwareEnvironmentVariableSearching(t *testing.T) { // mockModuleAwareConfigModule is a mock module for testing module-aware configuration type mockModuleAwareConfigModule struct { name string - config interface{} + config any } func (m *mockModuleAwareConfigModule) Name() string { @@ -314,7 +314,7 @@ func (m *mockModuleAwareConfigModule) Init(app Application) error { } // createTestApplication creates a basic application for testing -func createTestApplication(t *testing.T) *StdApplication { +func createTestApplication(_ *testing.T) *StdApplication { logger := &simpleTestLogger{} app := NewStdApplication(nil, logger) return app.(*StdApplication) diff --git a/modules/auth/go.mod b/modules/auth/go.mod index 9d6d81ef..1de5bb82 100644 --- a/modules/auth/go.mod +++ b/modules/auth/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/auth -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/cache/go.mod b/modules/cache/go.mod index a50ad008..e9954020 100644 --- a/modules/cache/go.mod +++ b/modules/cache/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/modules/cache -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/chimux/go.mod b/modules/chimux/go.mod index a97a8340..fc374bfc 100644 --- a/modules/chimux/go.mod +++ b/modules/chimux/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/chimux -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/configwatcher/configwatcher.go b/modules/configwatcher/configwatcher.go new file mode 100644 index 00000000..1c597c84 --- /dev/null +++ b/modules/configwatcher/configwatcher.go @@ -0,0 +1,164 @@ +package configwatcher + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once + logger modular.Logger +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { w.paths = append(w.paths, paths...) } +} + +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { w.debounce = d } +} + +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { w.onChange = fn } +} + +func WithLogger(l modular.Logger) Option { + return func(w *ConfigWatcher) { w.logger = l } +} + +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init satisfies the modular.Module interface. Captures the application logger +// if one was not provided via WithLogger. +func (w *ConfigWatcher) Init(app modular.Application) error { + if w.logger == nil { + w.logger = app.Logger() + } + return nil +} + +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + _ = w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +func (w *ConfigWatcher) Stop(_ context.Context) error { + return w.stopWatching() +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("creating file watcher: %w", err) + } + w.watcher = watcher + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return fmt.Errorf("watching path %q: %w", path, err) + } + } + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() error { + var closeErr error + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + if err := w.watcher.Close(); err != nil { + closeErr = fmt.Errorf("closing file watcher: %w", err) + } + } + }) + return closeErr +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + var mu sync.Mutex + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + mu.Lock() + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + // Check stopCh before invoking callback to avoid + // firing onChange after shutdown. + select { + case <-w.stopCh: + return + default: + } + if w.onChange != nil { + mu.Lock() + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + clear(changedPaths) + mu.Unlock() + w.onChange(paths) + } + }) + mu.Unlock() + } + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + if w.logger != nil { + w.logger.Error("file watcher error", "error", err) + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} diff --git a/modules/configwatcher/configwatcher_test.go b/modules/configwatcher/configwatcher_test.go new file mode 100644 index 00000000..d27d01e7 --- /dev/null +++ b/modules/configwatcher/configwatcher_test.go @@ -0,0 +1,77 @@ +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("v1"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + for i := range 5 { + if err := os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} diff --git a/modules/configwatcher/go.mod b/modules/configwatcher/go.mod new file mode 100644 index 00000000..45fa8004 --- /dev/null +++ b/modules/configwatcher/go.mod @@ -0,0 +1,22 @@ +module github.com/GoCodeAlone/modular/modules/configwatcher + +go 1.26 + +require ( + github.com/GoCodeAlone/modular v1.12.0 + github.com/fsnotify/fsnotify v1.9.0 +) + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect + github.com/golobby/cast v1.3.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/modules/configwatcher/go.sum b/modules/configwatcher/go.sum new file mode 100644 index 00000000..82f80dac --- /dev/null +++ b/modules/configwatcher/go.sum @@ -0,0 +1,86 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= +github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/database/go.mod b/modules/database/go.mod index 3bd4fb6d..fc0213b2 100644 --- a/modules/database/go.mod +++ b/modules/database/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/database/v2 -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/eventbus/custom_memory.go b/modules/eventbus/custom_memory.go index 22bf3b70..cdedc107 100644 --- a/modules/eventbus/custom_memory.go +++ b/modules/eventbus/custom_memory.go @@ -193,7 +193,7 @@ func (c *CustomMemoryEventBus) Start(ctx context.Context) error { return nil } - c.ctx, c.cancel = context.WithCancel(ctx) + c.ctx, c.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in c.cancel and called in Stop() // Start metrics collection if enabled if c.config.EnableMetrics { diff --git a/modules/eventbus/durable_memory.go b/modules/eventbus/durable_memory.go index 00cf7db9..f03b70b1 100644 --- a/modules/eventbus/durable_memory.go +++ b/modules/eventbus/durable_memory.go @@ -216,7 +216,7 @@ func (d *DurableMemoryEventBus) Start(ctx context.Context) error { if d.isStarted { return nil } - d.ctx, d.cancel = context.WithCancel(ctx) + d.ctx, d.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in d.cancel and called in Stop() d.isStarted = true return nil } diff --git a/modules/eventbus/go.mod b/modules/eventbus/go.mod index d48bcba9..1c664065 100644 --- a/modules/eventbus/go.mod +++ b/modules/eventbus/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/modules/eventbus/v2 -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/DataDog/datadog-go/v5 v5.4.0 diff --git a/modules/eventbus/kafka.go b/modules/eventbus/kafka.go index 0439fb94..c9fa3626 100644 --- a/modules/eventbus/kafka.go +++ b/modules/eventbus/kafka.go @@ -235,7 +235,7 @@ func (k *KafkaEventBus) Start(ctx context.Context) error { return nil } - k.ctx, k.cancel = context.WithCancel(ctx) + k.ctx, k.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in k.cancel and called in Stop() k.isStarted = true return nil } diff --git a/modules/eventbus/kinesis.go b/modules/eventbus/kinesis.go index ba7a1445..2c4ff6db 100644 --- a/modules/eventbus/kinesis.go +++ b/modules/eventbus/kinesis.go @@ -210,7 +210,7 @@ func (k *KinesisEventBus) Start(ctx context.Context) error { if k.config.PollInterval <= 0 { k.config.PollInterval = DefaultKinesisPollInterval } - k.ctx, k.cancel = context.WithCancel(ctx) + k.ctx, k.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in k.cancel and called in Stop() k.isStarted = true return nil } diff --git a/modules/eventbus/memory.go b/modules/eventbus/memory.go index 34d344f2..3d1f2272 100644 --- a/modules/eventbus/memory.go +++ b/modules/eventbus/memory.go @@ -113,7 +113,7 @@ func (m *MemoryEventBus) Start(ctx context.Context) error { return nil } - m.ctx, m.cancel = context.WithCancel(ctx) + m.ctx, m.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in m.cancel and called in Stop() // Initialize worker pool for async event handling. // Buffer size is MaxEventQueueSize so the task queue can absorb bursts diff --git a/modules/eventbus/nats.go b/modules/eventbus/nats.go index 95ef8639..c667c84f 100644 --- a/modules/eventbus/nats.go +++ b/modules/eventbus/nats.go @@ -177,7 +177,7 @@ func (n *NatsEventBus) Start(ctx context.Context) error { return ErrNATSConnectionNotEstablished } - n.ctx, n.cancel = context.WithCancel(ctx) + n.ctx, n.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in n.cancel and called in Stop() n.isStarted = true return nil } diff --git a/modules/eventbus/redis.go b/modules/eventbus/redis.go index 6e0a62e2..7d181d62 100644 --- a/modules/eventbus/redis.go +++ b/modules/eventbus/redis.go @@ -140,7 +140,7 @@ func (r *RedisEventBus) Start(ctx context.Context) error { return fmt.Errorf("failed to connect to Redis: %w", err) } - r.ctx, r.cancel = context.WithCancel(ctx) + r.ctx, r.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in r.cancel and called in Stop() r.isStarted = true return nil } diff --git a/modules/eventlogger/go.mod b/modules/eventlogger/go.mod index 1cf6ea18..bed18200 100644 --- a/modules/eventlogger/go.mod +++ b/modules/eventlogger/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/modules/eventlogger -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/httpclient/go.mod b/modules/httpclient/go.mod index d26dec02..281f9379 100644 --- a/modules/httpclient/go.mod +++ b/modules/httpclient/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/httpclient -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/httpserver/go.mod b/modules/httpserver/go.mod index 23ff3c87..e1f9fb2a 100644 --- a/modules/httpserver/go.mod +++ b/modules/httpserver/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/httpserver -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/jsonschema/go.mod b/modules/jsonschema/go.mod index a469e3ae..80c67035 100644 --- a/modules/jsonschema/go.mod +++ b/modules/jsonschema/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/modules/jsonschema -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/letsencrypt/go.mod b/modules/letsencrypt/go.mod index 29f36c3b..ac369fe2 100644 --- a/modules/letsencrypt/go.mod +++ b/modules/letsencrypt/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/letsencrypt -go 1.25 +go 1.26 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/logmasker/go.mod b/modules/logmasker/go.mod index c32f3371..21cb2002 100644 --- a/modules/logmasker/go.mod +++ b/modules/logmasker/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/logmasker -go 1.25 +go 1.26 require github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/reverseproxy/go.mod b/modules/reverseproxy/go.mod index 2037a71b..3110148d 100644 --- a/modules/reverseproxy/go.mod +++ b/modules/reverseproxy/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/modular/modules/reverseproxy/v2 -go 1.25 +go 1.26 // retract (from old module path) v1.0.0 diff --git a/modules/reverseproxy/module.go b/modules/reverseproxy/module.go index 437ae7f4..25d1c2ff 100644 --- a/modules/reverseproxy/module.go +++ b/modules/reverseproxy/module.go @@ -1600,7 +1600,7 @@ func (m *ReverseProxyModule) SetEmptyResponsePolicy(pattern string, policy Empty // createReverseProxyForBackend creates a reverse proxy for a specific backend with per-backend configuration. func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, target *url.URL, backendID string, endpoint string) *httputil.ReverseProxy { - proxy := httputil.NewSingleHostReverseProxy(target) + proxy := &httputil.ReverseProxy{} // Emit proxy created event m.emitEvent(ctx, EventTypeProxyCreated, map[string]interface{}{ @@ -1631,13 +1631,16 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t // Store the original target for use in the director function originalTarget := *target - // Create a custom director that handles hostname forwarding and path rewriting - proxy.Director = func(req *http.Request) { + // Create a Rewrite function that handles hostname forwarding and path rewriting. + // This replaces the deprecated Director field (SA1019, deprecated since Go 1.26). + proxy.Rewrite = func(pr *httputil.ProxyRequest) { + req := pr.Out + // Extract tenant ID from the request header if available var tenantIDStr string var hasTenant bool if m.config != nil { - tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, req) + tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, pr.In) } // Get the appropriate configuration (tenant-specific or global) @@ -1654,7 +1657,7 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t } // Apply path rewriting if configured - rewrittenPath := m.applyPathRewritingForBackend(req.URL.Path, config, backendID, endpoint) + rewrittenPath := m.applyPathRewritingForBackend(pr.In.URL.Path, config, backendID, endpoint) // Set up the request URL req.URL.Scheme = originalTarget.Scheme @@ -1670,25 +1673,13 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t // Apply header rewriting m.applyHeaderRewritingForBackend(req, config, backendID, endpoint, &originalTarget) - } - // If a custom director factory is available, use it (this is for advanced use cases) - if m.directorFactory != nil { - // Get the backend ID from the target URL host - backend := originalTarget.Host - originalDirector := proxy.Director + // Preserve X-Forwarded-* headers + pr.SetXForwarded() - // Create a custom director that handles the backend routing - proxy.Director = func(req *http.Request) { - // Apply our standard director first - originalDirector(req) - - // Then apply custom director if available - var tenantIDStr string - var hasTenant bool - if m.config != nil { - tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, req) - } + // If a custom director factory is available, apply it + if m.directorFactory != nil { + backend := originalTarget.Host if hasTenant { tenantID := modular.TenantID(tenantIDStr) @@ -2565,7 +2556,8 @@ func (m *ReverseProxyModule) createBackendProxyHandler(backend string) http.Hand // Create a copy of the proxy with the timeout transport proxyCopy := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: timeoutTransport, FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, @@ -2761,7 +2753,8 @@ func (m *ReverseProxyModule) createBackendProxyHandler(backend string) http.Hand // No circuit breaker, use the proxy directly but capture status and apply timeout // Create a request-specific proxy to avoid race conditions on shared Transport field proxyForRequest := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: proxy.Transport, // Start with the original transport FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, @@ -3019,7 +3012,8 @@ func (m *ReverseProxyModule) createBackendProxyHandlerForTenant(tenantID modular // Create a copy of the proxy with the original transport proxyCopy := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: originalTransport, FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, diff --git a/modules/scheduler/go.mod b/modules/scheduler/go.mod index 7987652b..a1430b47 100644 --- a/modules/scheduler/go.mod +++ b/modules/scheduler/go.mod @@ -1,8 +1,8 @@ module github.com/GoCodeAlone/modular/modules/scheduler -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/GoCodeAlone/modular v1.12.0 diff --git a/modules/scheduler/scheduler.go b/modules/scheduler/scheduler.go index 1551bbf0..21b69c98 100644 --- a/modules/scheduler/scheduler.go +++ b/modules/scheduler/scheduler.go @@ -194,13 +194,13 @@ func (s *Scheduler) Start(ctx context.Context) error { s.logger.Info("Starting scheduler", "workers", s.workerCount, "queueSize", s.queueSize) } - s.ctx, s.cancel = context.WithCancel(ctx) + s.ctx, s.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in s.cancel and called in Stop() s.jobQueue = make(chan Job, s.queueSize) // Start worker goroutines for i := 0; i < s.workerCount; i++ { s.wg.Add(1) - //nolint:contextcheck // Context is passed through s.ctx field + //nolint:contextcheck,gosec // Context is passed through s.ctx field; workers use s.ctx go s.worker(i) // Emit worker started event diff --git a/nil_interface_panic_test.go b/nil_interface_panic_test.go index 51d84e70..00e07325 100644 --- a/nil_interface_panic_test.go +++ b/nil_interface_panic_test.go @@ -35,14 +35,14 @@ func TestTypeImplementsInterfaceWithNil(t *testing.T) { app := &StdApplication{} // Test with nil svcType (should not panic) - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() result := app.typeImplementsInterface(nil, interfaceType) if result { t.Error("Expected false when svcType is nil") } // Test with nil interfaceType (should not panic) - svcType := reflect.TypeOf("") + svcType := reflect.TypeFor[string]() result = app.typeImplementsInterface(svcType, nil) if result { t.Error("Expected false when interfaceType is nil") @@ -68,7 +68,7 @@ func TestGetServicesByInterfaceWithNilService(t *testing.T) { } // This should not panic - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() results := app.GetServicesByInterface(interfaceType) // Should return empty results, not panic @@ -117,7 +117,7 @@ func (m *interfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testService", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*NilTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[NilTestInterface](), Required: false, // Make it optional to avoid required service errors }} } diff --git a/observer.go b/observer.go index 9bb202a9..a8739b66 100644 --- a/observer.go +++ b/observer.go @@ -103,6 +103,9 @@ const ( // Health events EventTypeHealthEvaluated = "com.modular.health.evaluated" EventTypeHealthStatusChanged = "com.modular.health.status.changed" + + // Phase events + EventTypeAppPhaseChanged = "com.modular.application.phase.changed" ) // ObservableModule is an optional interface that modules can implement diff --git a/observer_cloudevents.go b/observer_cloudevents.go index 08dd5c30..67115d24 100644 --- a/observer_cloudevents.go +++ b/observer_cloudevents.go @@ -17,7 +17,7 @@ type CloudEvent = cloudevents.Event // NewCloudEvent creates a new CloudEvent with the specified parameters. // This is a convenience function for creating properly formatted CloudEvents. -func NewCloudEvent(eventType, source string, data interface{}, metadata map[string]interface{}) cloudevents.Event { +func NewCloudEvent(eventType, source string, data any, metadata map[string]any) cloudevents.Event { event := cloudevents.NewEvent() // Set required attributes @@ -58,12 +58,12 @@ type ModuleLifecyclePayload struct { // Timestamp is when the lifecycle action occurred (RFC3339 in JSON output). Timestamp time.Time `json:"timestamp"` // Additional arbitrary metadata (kept minimal; prefer evolving the struct if fields become first-class). - Metadata map[string]interface{} `json:"metadata,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } // NewModuleLifecycleEvent builds a CloudEvent for a module/application lifecycle using the structured payload. // It sets payload_schema and module_action extensions for lightweight routing without full payload decode. -func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]interface{}) cloudevents.Event { +func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]any) cloudevents.Event { payload := ModuleLifecyclePayload{ Subject: subject, Name: name, diff --git a/observer_cloudevents_test.go b/observer_cloudevents_test.go index 7c39321c..4a2218d9 100644 --- a/observer_cloudevents_test.go +++ b/observer_cloudevents_test.go @@ -13,14 +13,14 @@ import ( // Mock types for testing type mockConfigProvider struct { - config interface{} + config any } -func (m *mockConfigProvider) GetConfig() interface{} { +func (m *mockConfigProvider) GetConfig() any { return m.config } -func (m *mockConfigProvider) GetDefaultConfig() interface{} { +func (m *mockConfigProvider) GetDefaultConfig() any { return m.config } @@ -32,28 +32,28 @@ type mockLogger struct { type mockLogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *mockLogger) Info(msg string, args ...interface{}) { +func (l *mockLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *mockLogger) Error(msg string, args ...interface{}) { +func (l *mockLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *mockLogger) Debug(msg string, args ...interface{}) { +func (l *mockLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *mockLogger) Warn(msg string, args ...interface{}) { +func (l *mockLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "WARN", Message: msg, Args: args}) @@ -72,8 +72,8 @@ func (m *mockModule) Init(app Application) error { } func TestNewCloudEvent(t *testing.T) { - data := map[string]interface{}{"test": "data"} - metadata := map[string]interface{}{"key": "value"} + data := map[string]any{"test": "data"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent("test.event", "test.source", data, metadata) @@ -84,7 +84,7 @@ func TestNewCloudEvent(t *testing.T) { assert.False(t, event.Time().IsZero()) // Check data - var eventData map[string]interface{} + var eventData map[string]any err := event.DataAs(&eventData) require.NoError(t, err) assert.Equal(t, "data", eventData["test"]) diff --git a/observer_test.go b/observer_test.go index 5061ae69..53be5000 100644 --- a/observer_test.go +++ b/observer_test.go @@ -1,6 +1,7 @@ package modular import ( + "slices" "context" "errors" "testing" @@ -11,7 +12,7 @@ import ( func TestCloudEvent(t *testing.T) { t.Parallel() - metadata := map[string]interface{}{"key": "value"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent( "test.event", "test.source", @@ -195,11 +196,8 @@ func (m *mockSubject) NotifyObservers(ctx context.Context, event cloudevents.Eve _ = registration.observer.OnEvent(ctx, event) } else { // Check if event type matches observer's interests - for _, eventType := range registration.eventTypes { - if eventType == event.Type() { - _ = registration.observer.OnEvent(ctx, event) - break - } + if slices.Contains(registration.eventTypes, event.Type()) { + _ = registration.observer.OnEvent(ctx, event) } } } diff --git a/parallel_init_test.go b/parallel_init_test.go new file mode 100644 index 00000000..35fce24a --- /dev/null +++ b/parallel_init_test.go @@ -0,0 +1,112 @@ +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} diff --git a/phase.go b/phase.go new file mode 100644 index 00000000..9ddddeb9 --- /dev/null +++ b/phase.go @@ -0,0 +1,38 @@ +package modular + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} diff --git a/phase_test.go b/phase_test.go new file mode 100644 index 00000000..145f9eee --- /dev/null +++ b/phase_test.go @@ -0,0 +1,60 @@ +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseInitialized, "initialized"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if stdApp.Phase() != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", stdApp.Phase()) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 00000000..0976dc85 --- /dev/null +++ b/plugin.go @@ -0,0 +1,25 @@ +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 00000000..acd07798 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,89 @@ +package modular + +import "testing" + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginTestModule struct { + name string + initialized bool +} + +func (m *pluginTestModule) Name() string { return m.name } +func (m *pluginTestModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginTestModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +type simpleTestPlugin struct { + modules []Module +} + +func (p *simpleTestPlugin) Name() string { return "simple" } +func (p *simpleTestPlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginTestModule{name: "simple-mod"} + plugin := &simpleTestPlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} diff --git a/reload_contract_bdd_test.go b/reload_contract_bdd_test.go index 5bdc3615..c506c208 100644 --- a/reload_contract_bdd_test.go +++ b/reload_contract_bdd_test.go @@ -1,6 +1,7 @@ package modular import ( + "slices" "context" "errors" "fmt" @@ -96,10 +97,8 @@ func (l *reloadBDDLogger) Debug(_ string, _ ...any) {} func bddWaitForEvent(subject *reloadBDDSubject, eventType string, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - for _, et := range subject.eventTypes() { - if et == eventType { - return true - } + if slices.Contains(subject.eventTypes(), eventType) { + return true } time.Sleep(5 * time.Millisecond) } @@ -206,10 +205,8 @@ func (rc *ReloadBDDContext) allNModulesShouldReceiveTheChanges(n int) error { } func (rc *ReloadBDDContext) aReloadCompletedEventShouldBeEmitted() error { - for _, et := range rc.subject.eventTypes() { - if et == EventTypeConfigReloadCompleted { - return nil - } + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadCompleted) { + return nil } return errExpectedCompletedEvent } @@ -305,10 +302,8 @@ func (rc *ReloadBDDContext) theFirstModuleShouldBeRolledBack() error { } func (rc *ReloadBDDContext) aReloadFailedEventShouldBeEmitted() error { - for _, et := range rc.subject.eventTypes() { - if et == EventTypeConfigReloadFailed { - return nil - } + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadFailed) { + return nil } return errExpectedFailedEvent } @@ -376,10 +371,8 @@ func (rc *ReloadBDDContext) aReloadIsRequestedWithNoChanges() error { } func (rc *ReloadBDDContext) aReloadNoopEventShouldBeEmitted() error { - for _, et := range rc.subject.eventTypes() { - if et == EventTypeConfigReloadNoop { - return nil - } + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadNoop) { + return nil } return errExpectedNoopEvent } @@ -397,11 +390,9 @@ func (rc *ReloadBDDContext) tenReloadRequestsAreSubmittedConcurrently() error { diff := rc.newDiff() var wg sync.WaitGroup for range 10 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { _ = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) - }() + }) } wg.Wait() bddWaitForCalls(rc.modules, 1, 2*time.Second) diff --git a/reload_integration_test.go b/reload_integration_test.go new file mode 100644 index 00000000..8dc6a47a --- /dev/null +++ b/reload_integration_test.go @@ -0,0 +1,78 @@ +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + if err := stdApp.RequestReload(context.Background(), ReloadManual, diff); err != nil { + t.Fatalf("RequestReload: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +func TestRequestReload_WithoutDynamicReload(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + stdApp := app.(*StdApplication) + err = stdApp.RequestReload(context.Background(), ReloadManual, ConfigDiff{}) + if err == nil { + t.Error("expected error when dynamic reload not enabled") + } +} diff --git a/reload_orchestrator.go b/reload_orchestrator.go index ea34005d..3846b1c5 100644 --- a/reload_orchestrator.go +++ b/reload_orchestrator.go @@ -184,7 +184,7 @@ func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadReques // Noop if no changes — emit noop without a misleading "started" event. if !req.Diff.HasChanges() { - o.emitEvent(ctx, EventTypeConfigReloadNoop, map[string]interface{}{ + o.emitEvent(ctx, EventTypeConfigReloadNoop, map[string]any{ "trigger": req.Trigger.String(), "diffId": req.Diff.DiffID, }) @@ -192,7 +192,7 @@ func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadReques } // Emit started event only when there are actual changes to apply. - o.emitEvent(ctx, EventTypeConfigReloadStarted, map[string]interface{}{ + o.emitEvent(ctx, EventTypeConfigReloadStarted, map[string]any{ "trigger": req.Trigger.String(), "diffId": req.Diff.DiffID, "summary": req.Diff.ChangeSummary(), @@ -240,7 +240,7 @@ func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadReques o.rollback(ctx, applied, changes) o.recordFailure() - o.emitEvent(ctx, EventTypeConfigReloadFailed, map[string]interface{}{ + o.emitEvent(ctx, EventTypeConfigReloadFailed, map[string]any{ "trigger": req.Trigger.String(), "diffId": req.Diff.DiffID, "failedModule": t.name, @@ -253,7 +253,7 @@ func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadReques } o.recordSuccess() - o.emitEvent(ctx, EventTypeConfigReloadCompleted, map[string]interface{}{ + o.emitEvent(ctx, EventTypeConfigReloadCompleted, map[string]any{ "trigger": req.Trigger.String(), "diffId": req.Diff.DiffID, "modulesLoaded": len(applied), @@ -325,7 +325,7 @@ func (o *ReloadOrchestrator) rollback(ctx context.Context, applied []reloadEntry } // emitEvent sends a CloudEvent via the configured subject. -func (o *ReloadOrchestrator) emitEvent(ctx context.Context, eventType string, data map[string]interface{}) { +func (o *ReloadOrchestrator) emitEvent(ctx context.Context, eventType string, data map[string]any) { if o.subject == nil { return } diff --git a/reload_test.go b/reload_test.go index a5f77d71..24bdef0f 100644 --- a/reload_test.go +++ b/reload_test.go @@ -1,6 +1,7 @@ package modular import ( + "slices" "context" "errors" "fmt" @@ -269,8 +270,7 @@ func TestReloadOrchestrator_SuccessfulReload(t *testing.T) { mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} orch.RegisterReloadable("testmod", mod) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) diff := newTestDiff() @@ -305,8 +305,7 @@ func TestReloadOrchestrator_PartialFailure_Rollback(t *testing.T) { orch.RegisterReloadable("aaa_first", mod1) orch.RegisterReloadable("zzz_second", mod2) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) diff := newTestDiff() @@ -354,14 +353,13 @@ func TestReloadOrchestrator_CircuitBreaker(t *testing.T) { failMod := &mockReloadable{canReload: true, timeout: 5 * time.Second, reloadErr: errors.New("fail")} orch.RegisterReloadable("failing", failMod) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) diff := newTestDiff() // Trigger enough failures to open the circuit breaker. - for i := 0; i < circuitBreakerThreshold; i++ { + for i := range circuitBreakerThreshold { if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { t.Fatalf("RequestReload %d failed: %v", i, err) } @@ -389,8 +387,7 @@ func TestReloadOrchestrator_CanReloadFalse_Skipped(t *testing.T) { mod := &mockReloadable{canReload: false, timeout: 5 * time.Second} orch.RegisterReloadable("disabled", mod) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) diff := newTestDiff() @@ -399,12 +396,7 @@ func TestReloadOrchestrator_CanReloadFalse_Skipped(t *testing.T) { } if !waitFor(t, 2*time.Second, func() bool { - for _, et := range subject.eventTypes() { - if et == EventTypeConfigReloadCompleted { - return true - } - } - return false + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadCompleted) }) { t.Fatal("timed out waiting for ConfigReloadCompleted event") } @@ -422,19 +414,16 @@ func TestReloadOrchestrator_ConcurrentRequests(t *testing.T) { mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} orch.RegisterReloadable("concurrent", mod) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) diff := newTestDiff() var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 10 { + wg.Go(func() { _ = orch.RequestReload(ctx, ReloadManual, diff) - }() + }) } wg.Wait() @@ -455,8 +444,7 @@ func TestReloadOrchestrator_NoopOnEmptyDiff(t *testing.T) { mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} orch.RegisterReloadable("mod", mod) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() orch.Start(ctx) emptyDiff := ConfigDiff{ @@ -470,12 +458,7 @@ func TestReloadOrchestrator_NoopOnEmptyDiff(t *testing.T) { } if !waitFor(t, 2*time.Second, func() bool { - for _, et := range subject.eventTypes() { - if et == EventTypeConfigReloadNoop { - return true - } - } - return false + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadNoop) }) { t.Fatal("timed out waiting for ConfigReloadNoop event") } diff --git a/secret_resolver.go b/secret_resolver.go new file mode 100644 index 00000000..8a361df1 --- /dev/null +++ b/secret_resolver.go @@ -0,0 +1,79 @@ +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. Recurses into nested maps. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + } + } + return nil +} + +func expandSecretsSlice(ctx context.Context, slice []any, resolvers []SecretResolver) error { + for i, elem := range slice { + switch v := elem.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return err + } + slice[i] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + resolved, err := r.ResolveSecret(ctx, ref) + if err != nil { + return "", fmt.Errorf("resolving secret %q: %w", ref, err) + } + return resolved, nil + } + } + return val, nil +} diff --git a/secret_resolver_test.go b/secret_resolver_test.go new file mode 100644 index 00000000..72e7cb81 --- /dev/null +++ b/secret_resolver_test.go @@ -0,0 +1,67 @@ +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockSecretResolver struct { + prefix string + values map[string]string +} + +func (r *mockSecretResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockSecretResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockSecretResolver{ + prefix: "vault", + values: map[string]string{"secret/db-pass": "s3cret"}, + } + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{"key": "${vault:secret/db-pass}"}, + } + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{"host": "localhost", "port": 5432} + if err := ExpandSecrets(context.Background(), config); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{"password": "${aws:secret/key}"} + resolver := &mockSecretResolver{prefix: "vault", values: map[string]string{}} + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} diff --git a/service.go b/service.go index eb81a156..1239249d 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,7 @@ package modular import ( "fmt" "reflect" + "sync" ) // ServiceRegistry allows registration and retrieval of services by name. @@ -35,6 +36,8 @@ type ServiceRegistryEntry struct { // EnhancedServiceRegistry provides enhanced service registry functionality // that tracks module associations and handles automatic conflict resolution. type EnhancedServiceRegistry struct { + mu sync.RWMutex + // services maps service names to their registry entries services map[string]*ServiceRegistryEntry @@ -46,6 +49,9 @@ type EnhancedServiceRegistry struct { // currentModule tracks the module currently being initialized currentModule Module + + // readyCallbacks stores callbacks waiting for a service to be registered + readyCallbacks map[string][]func(any) } // NewEnhancedServiceRegistry creates a new enhanced service registry. @@ -54,31 +60,71 @@ func NewEnhancedServiceRegistry() *EnhancedServiceRegistry { services: make(map[string]*ServiceRegistryEntry), moduleServices: make(map[string][]string), nameCounters: make(map[string]int), + readyCallbacks: make(map[string][]func(any)), } } // SetCurrentModule sets the module that is currently being initialized. // This is used to track which module is registering services. func (r *EnhancedServiceRegistry) SetCurrentModule(module Module) { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = module } // ClearCurrentModule clears the current module context. func (r *EnhancedServiceRegistry) ClearCurrentModule() { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = nil } +// RegisterServiceForModule registers a service with explicit module association, +// bypassing the shared currentModule field. This is safe for concurrent use +// during parallel module initialization. +func (r *EnhancedServiceRegistry) RegisterServiceForModule(name string, service any, module Module) (string, error) { + var moduleName string + var moduleType reflect.Type + if module != nil { + moduleName = module.Name() + moduleType = reflect.TypeOf(module) + } + return r.registerAndNotify(name, service, moduleName, moduleType) +} + // RegisterService registers a service with automatic conflict resolution. // If a service name conflicts, it will automatically append module information. func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (string, error) { var moduleName string var moduleType reflect.Type + r.mu.Lock() if r.currentModule != nil { moduleName = r.currentModule.Name() moduleType = reflect.TypeOf(r.currentModule) } + r.mu.Unlock() + + return r.registerAndNotify(name, service, moduleName, moduleType) +} + +// registerAndNotify performs service registration under the lock, +// then fires readiness callbacks outside the lock to avoid deadlocks. +func (r *EnhancedServiceRegistry) registerAndNotify(name string, service any, moduleName string, moduleType reflect.Type) (string, error) { + r.mu.Lock() + callbacksToFire, actualName := r.registerServiceInner(name, service, moduleName, moduleType) + r.mu.Unlock() + + for _, cb := range callbacksToFire { + cb(service) + } + return actualName, nil +} + +// registerServiceInner does the actual registration work under the lock. +// Returns callbacks to fire and the actual service name. +func (r *EnhancedServiceRegistry) registerServiceInner(name string, service any, moduleName string, moduleType reflect.Type) ([]func(any), string) { // Generate unique name handling conflicts actualName := r.generateUniqueName(name, moduleName, moduleType) @@ -94,16 +140,27 @@ func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (str // Register the service r.services[actualName] = entry + // Collect callbacks to fire outside the lock + var callbacksToFire []func(any) + for _, cbName := range []string{name, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + callbacksToFire = append(callbacksToFire, callbacks...) + delete(r.readyCallbacks, cbName) + } + } + // Track module associations if moduleName != "" { r.moduleServices[moduleName] = append(r.moduleServices[moduleName], actualName) } - return actualName, nil + return callbacksToFire, actualName } // GetService retrieves a service by name. func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] if !exists { return nil, false @@ -113,17 +170,44 @@ func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { // GetServiceEntry retrieves the full service registry entry. func (r *EnhancedServiceRegistry) GetServiceEntry(name string) (*ServiceRegistryEntry, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] return entry, exists } // GetServicesByModule returns all services provided by a specific module. +// Returns a copy of the internal slice for thread safety. func (r *EnhancedServiceRegistry) GetServicesByModule(moduleName string) []string { - return r.moduleServices[moduleName] + r.mu.RLock() + defer r.mu.RUnlock() + src := r.moduleServices[moduleName] + if src == nil { + return nil + } + dst := make([]string, len(src)) + copy(dst, src) + return dst +} + +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + r.mu.Lock() + entry, exists := r.services[name] + if exists { + r.mu.Unlock() + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) + r.mu.Unlock() } // GetServicesByInterface returns all services that implement the given interface. func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.Type) []*ServiceRegistryEntry { + r.mu.RLock() + defer r.mu.RUnlock() var results []*ServiceRegistryEntry for _, entry := range r.services { @@ -141,6 +225,8 @@ func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.T // AsServiceRegistry returns a backwards-compatible ServiceRegistry view. func (r *EnhancedServiceRegistry) AsServiceRegistry() ServiceRegistry { + r.mu.RLock() + defer r.mu.RUnlock() registry := make(ServiceRegistry) for name, entry := range r.services { registry[name] = entry.Service diff --git a/service_readiness_test.go b/service_readiness_test.go new file mode 100644 index 00000000..c630fdf6 --- /dev/null +++ b/service_readiness_test.go @@ -0,0 +1,52 @@ +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + if called.Load() { + t.Error("callback should not have been called yet") + } + registry.RegisterService("db", "postgres-conn") + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.RegisterService("cache", "redis") + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} diff --git a/service_registration_timing_test.go b/service_registration_timing_test.go index d4e903fb..13ac632e 100644 --- a/service_registration_timing_test.go +++ b/service_registration_timing_test.go @@ -124,7 +124,7 @@ type serviceConsumerModule struct { requiredService string dependencies []string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerModule) Name() string { @@ -137,7 +137,7 @@ func (m *serviceConsumerModule) Dependencies() []string { func (m *serviceConsumerModule) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err @@ -270,7 +270,7 @@ type serviceConsumerWithRequires struct { requiredServices []ServiceDependency dependencies []string servicesInjected bool - injectedService interface{} + injectedService any } func (m *serviceConsumerWithRequires) Name() string { @@ -315,7 +315,7 @@ type serviceConsumerWithDeclaredRequires struct { requiredServices []ServiceDependency requiredService string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerWithDeclaredRequires) Name() string { @@ -324,7 +324,7 @@ func (m *serviceConsumerWithDeclaredRequires) Name() string { func (m *serviceConsumerWithDeclaredRequires) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err diff --git a/service_registry_scenarios_bdd_test.go b/service_registry_scenarios_bdd_test.go index b9f0f30e..13caa348 100644 --- a/service_registry_scenarios_bdd_test.go +++ b/service_registry_scenarios_bdd_test.go @@ -83,7 +83,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) iQueryForServicesByInterfaceType() } // Query for services implementing TestServiceInterface - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() ctx.retrievedServices = ctx.app.GetServicesByInterface(interfaceType) return nil } @@ -173,7 +173,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) eachServiceShouldGetAUniqueNameThr } func (ctx *EnhancedServiceRegistryBDDContext) allServicesShouldBeDiscoverableByInterface() error { - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() services := ctx.app.GetServicesByInterface(interfaceType) if len(services) != 3 { diff --git a/service_typed.go b/service_typed.go new file mode 100644 index 00000000..c2e8b0d8 --- /dev/null +++ b/service_typed.go @@ -0,0 +1,28 @@ +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +func RegisterTypedService[T any](app Application, name string, svc T) error { + if err := app.RegisterService(name, svc); err != nil { + return fmt.Errorf("registering typed service %q: %w", name, err) + } + return nil +} + +// GetTypedService retrieves a service with compile-time type safety. +// Note: This uses SvcRegistry() which copies the map. For hot paths, +// consider using app.GetService() with a concrete target type instead. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} diff --git a/service_typed_test.go b/service_typed_test.go new file mode 100644 index 00000000..57f55871 --- /dev/null +++ b/service_typed_test.go @@ -0,0 +1,37 @@ +package modular + +import "testing" + +type testTypedService struct{ Value string } + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + svc := &testTypedService{Value: "hello"} + if err := RegisterTypedService(app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + got, err := GetTypedService[*testTypedService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService(app, "str.svc", "hello") + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} diff --git a/slog_adapter.go b/slog_adapter.go new file mode 100644 index 00000000..d703583e --- /dev/null +++ b/slog_adapter.go @@ -0,0 +1,28 @@ +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} diff --git a/slog_adapter_test.go b/slog_adapter_test.go new file mode 100644 index 00000000..a3b774fa --- /dev/null +++ b/slog_adapter_test.go @@ -0,0 +1,51 @@ +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + adapter := NewSlogAdapter(logger) + + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + for _, msg := range []string{"test info", "test error", "test warn", "test debug"} { + if !strings.Contains(output, msg) { + t.Errorf("expected %q in output", msg) + } + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).With("module", "test") + adapter.Info("with test") + if !strings.Contains(buf.String(), "module=test") { + t.Errorf("expected module=test in output, got: %s", buf.String()) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + if !strings.Contains(buf.String(), "mygroup") { + t.Errorf("expected mygroup in output, got: %s", buf.String()) + } +} diff --git a/tenant_config_file_loader.go b/tenant_config_file_loader.go index db9117b3..0601a16e 100644 --- a/tenant_config_file_loader.go +++ b/tenant_config_file_loader.go @@ -482,13 +482,13 @@ func getSectionNames(sections map[string]ConfigProvider) []string { // cloneConfigWithValues creates a new instance of the originalConfig type // and copies values from loadedConfig into it -func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{}, error) { +func cloneConfigWithValues(originalConfig, loadedConfig any) (any, error) { if originalConfig == nil || loadedConfig == nil { return nil, ErrOriginalOrLoadedNil } origType := reflect.TypeOf(originalConfig) - if origType.Kind() == reflect.Ptr { + if origType.Kind() == reflect.Pointer { origType = origType.Elem() } @@ -504,21 +504,21 @@ func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{ } // copyStructFields copies field values from src to dst -func copyStructFields(dst, src interface{}) error { +func copyStructFields(dst, src any) error { dstVal := reflect.ValueOf(dst) srcVal := reflect.ValueOf(src) // Ensure we're working with pointers - if dstVal.Kind() != reflect.Ptr { + if dstVal.Kind() != reflect.Pointer { return ErrDestinationNotPointer } // Dereference pointers to get the underlying values - if dstVal.Kind() == reflect.Ptr { + if dstVal.Kind() == reflect.Pointer { dstVal = dstVal.Elem() } - if srcVal.Kind() == reflect.Ptr { + if srcVal.Kind() == reflect.Pointer { srcVal = srcVal.Elem() } diff --git a/tenant_config_loader_test.go b/tenant_config_loader_test.go index f9462133..e4ac8ace 100644 --- a/tenant_config_loader_test.go +++ b/tenant_config_loader_test.go @@ -1,6 +1,7 @@ package modular import ( + "maps" "log/slog" "os" "path/filepath" @@ -43,9 +44,7 @@ func (m *MockTenantService) RegisterTenant(tenantID TenantID, configs map[string m.tenants[tenantID] = make(map[string]ConfigProvider) } - for section, provider := range configs { - m.tenants[tenantID][section] = provider - } + maps.Copy(m.tenants[tenantID], configs) return nil } diff --git a/tenant_config_provider.go b/tenant_config_provider.go index cb552047..4a905196 100644 --- a/tenant_config_provider.go +++ b/tenant_config_provider.go @@ -65,7 +65,7 @@ func (tcp *TenantConfigProvider) SetTenantConfig(tenantID TenantID, section stri // Ensure the config is a valid, non-zero value cfgValue := reflect.ValueOf(cfg) - if cfgValue.Kind() == reflect.Ptr && cfgValue.IsNil() { + if cfgValue.Kind() == reflect.Pointer && cfgValue.IsNil() { return } diff --git a/tenant_config_test.go b/tenant_config_test.go index f549e545..60b37c03 100644 --- a/tenant_config_test.go +++ b/tenant_config_test.go @@ -248,7 +248,7 @@ func TestLoadTenantConfigsNonexistentDirectory(t *testing.T) { ConfigDir: nonExistentDir, } - log.On("Error", "Tenant config directory does not exist", []interface{}{"directory", nonExistentDir}).Return(nil) + log.On("Error", "Tenant config directory does not exist", []any{"directory", nonExistentDir}).Return(nil) err := LoadTenantConfigs(app, tenantService, params) if err == nil || !strings.Contains(err.Error(), "tenant config directory does not exist") { t.Errorf("Expected error for nonexistent directory, got: %v", err) @@ -323,7 +323,7 @@ func TestTenantConfigProviderSetAndGet(t *testing.T) { } // Test nil config - nilProviderStruct := &struct{ Config interface{} }{nil} + nilProviderStruct := &struct{ Config any }{nil} nilProvider := NewStdConfigProvider(nilProviderStruct.Config) tcp.SetTenantConfig(tenant1ID, "NilConfigSection", nilProvider) if tcp.HasTenantConfig(tenant1ID, "NilConfigSection") { @@ -403,7 +403,7 @@ func TestCopyStructFields(t *testing.T) { } // Test copying map to struct - srcMap := map[string]interface{}{ + srcMap := map[string]any{ "Name": "MapSource", "Environment": "prod", "Features": map[string]bool{"feature2": true}, diff --git a/tenant_guard_test.go b/tenant_guard_test.go index 60d8b3fd..21316c3b 100644 --- a/tenant_guard_test.go +++ b/tenant_guard_test.go @@ -205,7 +205,7 @@ func TestStandardTenantGuard_RingBuffer(t *testing.T) { guard := NewStandardTenantGuard(config) // Add 8 violations to a buffer of size 5 - for i := 0; i < 8; i++ { + for i := range 8 { _ = guard.ValidateAccess(context.Background(), TenantViolation{ Type: CrossTenant, Severity: SeverityLow, @@ -262,7 +262,7 @@ func TestStandardTenantGuard_ConcurrentAccess(t *testing.T) { guard := NewStandardTenantGuard(config) var wg sync.WaitGroup - for i := 0; i < 100; i++ { + for i := range 100 { wg.Add(1) go func(idx int) { defer wg.Done() diff --git a/tenant_service.go b/tenant_service.go index 80953420..ab42ed98 100644 --- a/tenant_service.go +++ b/tenant_service.go @@ -4,6 +4,7 @@ package modular import ( "fmt" + "slices" "sync" ) @@ -165,12 +166,10 @@ func (ts *StandardTenantService) RegisterTenantAwareModule(module TenantAwareMod defer ts.mutex.Unlock() // Check if the module is already registered to avoid duplicates - for _, existingModule := range ts.tenantAwareModules { - if existingModule == module { - ts.logger.Debug("Module already registered as tenant-aware", - "module", fmt.Sprintf("%T", module), "name", module.Name()) - return nil - } + if slices.Contains(ts.tenantAwareModules, module) { + ts.logger.Debug("Module already registered as tenant-aware", + "module", fmt.Sprintf("%T", module), "name", module.Name()) + return nil } ts.tenantAwareModules = append(ts.tenantAwareModules, module) diff --git a/user_scenario_integration_test.go b/user_scenario_integration_test.go index f596126e..4df7c9f2 100644 --- a/user_scenario_integration_test.go +++ b/user_scenario_integration_test.go @@ -34,7 +34,7 @@ func TestUserScenarioReproduction(t *testing.T) { t.Log("Service entry not found (expected for nil service)") } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) t.Logf("Services implementing interface: %d", len(interfaceServices)) @@ -57,7 +57,7 @@ func TestBackwardsCompatibilityCheck(t *testing.T) { t.Errorf("Expected no entry for nonexistent service, got %v, %v", entry, found) } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) if len(interfaceServices) != 0 { t.Errorf("Expected no interface services, got %v", interfaceServices) @@ -92,7 +92,7 @@ func (m *testInterfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testInterface", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestUserInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestUserInterface](), Required: false, // Optional to avoid initialization failures }} }