diff --git a/examples/logger-reconfiguration/main.go b/examples/logger-reconfiguration/main.go index 43c1b421..999c655d 100644 --- a/examples/logger-reconfiguration/main.go +++ b/examples/logger-reconfiguration/main.go @@ -30,7 +30,7 @@ func main() { // Create a new logger based on configuration settings var handler slog.Handler - + // Configure handler based on log format switch cfg.LogFormat { case "json": @@ -49,10 +49,10 @@ func main() { // Create new logger with configuration-based settings newLogger := slog.New(handler) - + // Replace the logger before modules initialize app.SetLogger(newLogger) - + newLogger.Info("Logger reconfigured from configuration", "format", cfg.LogFormat, "level", cfg.LogLevel) @@ -154,11 +154,11 @@ func (m *ServiceModule) Init(app modular.Application) error { // This module also caches the logger m.logger = app.Logger() m.logger.Info("ServiceModule initialized", "module", m.Name(), "status", "ready") - + // Demonstrate that the logger has the correct configuration - m.logger.Debug("Service module debug information", + m.logger.Debug("Service module debug information", "feature", "logger_reconfiguration", "working", true) - + return nil } diff --git a/modules/auth/go.mod b/modules/auth/go.mod index 1de5bb82..991e8f51 100644 --- a/modules/auth/go.mod +++ b/modules/auth/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/auth go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/modules/auth/go.sum b/modules/auth/go.sum index f71de52b..0a1df8d3 100644 --- a/modules/auth/go.sum +++ b/modules/auth/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/cache/engine.go b/modules/cache/engine.go index 6cd55f7e..484a494d 100644 --- a/modules/cache/engine.go +++ b/modules/cache/engine.go @@ -97,4 +97,8 @@ type CacheEngine interface { // // The context can be used for operation timeouts. DeleteMulti(ctx context.Context, keys []string) error + + // Stats returns engine-specific metrics as key-value pairs. + // Used by the MetricsProvider interface to collect operational metrics. + Stats(ctx context.Context) map[string]float64 } diff --git a/modules/cache/go.mod b/modules/cache/go.mod index e9954020..015d5e11 100644 --- a/modules/cache/go.mod +++ b/modules/cache/go.mod @@ -5,7 +5,7 @@ go 1.26 toolchain go1.26.0 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/alicebob/miniredis/v2 v2.35.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/cache/go.sum b/modules/cache/go.sum index 80111e84..66d116d6 100644 --- a/modules/cache/go.sum +++ b/modules/cache/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/modules/cache/memory.go b/modules/cache/memory.go index b5456f72..66a65820 100644 --- a/modules/cache/memory.go +++ b/modules/cache/memory.go @@ -201,6 +201,18 @@ func (c *MemoryCache) DeleteMulti(ctx context.Context, keys []string) error { return nil } +// Stats returns memory cache metrics. +func (c *MemoryCache) Stats(_ context.Context) map[string]float64 { + c.mutex.RLock() + count := float64(len(c.items)) + maxItems := float64(c.config.MaxItems) + c.mutex.RUnlock() + return map[string]float64{ + "item_count": count, + "max_items": maxItems, + } +} + // startCleanupTimer starts the cleanup timer for expired items func (c *MemoryCache) startCleanupTimer(ctx context.Context) { // Run cleanup immediately on start diff --git a/modules/cache/module.go b/modules/cache/module.go index 6c2d240a..a37b6c37 100644 --- a/modules/cache/module.go +++ b/modules/cache/module.go @@ -102,6 +102,7 @@ const ServiceName = "cache.provider" type CacheModule struct { name string config *CacheConfig + configMu sync.RWMutex // protects config field reads/writes during reload logger modular.Logger cacheEngine CacheEngine // subject is the observable subject used for event emission. It can be written @@ -377,7 +378,9 @@ func (m *CacheModule) Get(ctx context.Context, key string) (interface{}, bool) { // err := cache.Set(ctx, "session:abc", sessionData, time.Hour) func (m *CacheModule) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { if ttl == 0 { + m.configMu.RLock() ttl = m.config.DefaultTTL + m.configMu.RUnlock() } if err := m.cacheEngine.Set(ctx, key, value, ttl); err != nil { @@ -511,7 +514,9 @@ func (m *CacheModule) GetMulti(ctx context.Context, keys []string) (map[string]i // err := cache.SetMulti(ctx, items, time.Minute*30) func (m *CacheModule) SetMulti(ctx context.Context, items map[string]interface{}, ttl time.Duration) error { if ttl == 0 { + m.configMu.RLock() ttl = m.config.DefaultTTL + m.configMu.RUnlock() } if err := m.cacheEngine.SetMulti(ctx, items, ttl); err != nil { return fmt.Errorf("failed to set multiple cache items: %w", err) diff --git a/modules/cache/redis.go b/modules/cache/redis.go index 8f359574..4b890d2c 100644 --- a/modules/cache/redis.go +++ b/modules/cache/redis.go @@ -190,3 +190,19 @@ func (c *RedisCache) DeleteMulti(ctx context.Context, keys []string) error { } return nil } + +// Stats returns redis cache metrics using pool statistics (no network round-trip). +func (c *RedisCache) Stats(_ context.Context) map[string]float64 { + if c.client == nil { + return map[string]float64{"connected": 0} + } + ps := c.client.PoolStats() + return map[string]float64{ + "connected": 1, + "pool_hits": float64(ps.Hits), + "pool_misses": float64(ps.Misses), + "total_conns": float64(ps.TotalConns), + "idle_conns": float64(ps.IdleConns), + "stale_conns": float64(ps.StaleConns), + } +} diff --git a/modules/cache/v2_interfaces.go b/modules/cache/v2_interfaces.go new file mode 100644 index 00000000..5179c08c --- /dev/null +++ b/modules/cache/v2_interfaces.go @@ -0,0 +1,71 @@ +package cache + +import ( + "context" + "fmt" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface checks. +var ( + _ modular.MetricsProvider = (*CacheModule)(nil) + _ modular.Reloadable = (*CacheModule)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It delegates to the underlying CacheEngine's Stats method. +// Safe to call before Init (returns empty metrics when engine is nil). +func (m *CacheModule) CollectMetrics(ctx context.Context) modular.ModuleMetrics { + if m.cacheEngine == nil { + return modular.ModuleMetrics{Name: m.name, Values: map[string]float64{}} + } + return modular.ModuleMetrics{ + Name: m.name, + Values: m.cacheEngine.Stats(ctx), + } +} + +// CanReload implements modular.Reloadable. +func (m *CacheModule) CanReload() bool { + return true +} + +// ReloadTimeout implements modular.Reloadable. +func (m *CacheModule) ReloadTimeout() time.Duration { + return 5 * time.Second +} + +// Reload implements modular.Reloadable. +// It applies configuration changes for DefaultTTL and MaxItems. +// CleanupInterval is not reloadable since the cleanup ticker is already running. +// Config writes are protected by configMu (and the engine's mutex for MaxItems) +// to avoid data races with concurrent reads. +func (m *CacheModule) Reload(_ context.Context, changes []modular.ConfigChange) error { + for _, ch := range changes { + switch ch.FieldPath { + case "defaultTTL": + if d, err := time.ParseDuration(ch.NewValue); err == nil { + m.configMu.Lock() + m.config.DefaultTTL = d + m.configMu.Unlock() + } + case "maxItems": + var n int + if _, err := fmt.Sscan(ch.NewValue, &n); err == nil && n > 0 { + m.configMu.Lock() + // MemoryCache reads MaxItems under its own mutex, so lock both. + if mc, ok := m.cacheEngine.(*MemoryCache); ok { + mc.mutex.Lock() + m.config.MaxItems = n + mc.mutex.Unlock() + } else { + m.config.MaxItems = n + } + m.configMu.Unlock() + } + } + } + return nil +} diff --git a/modules/cache/v2_interfaces_test.go b/modules/cache/v2_interfaces_test.go new file mode 100644 index 00000000..d0ab9f58 --- /dev/null +++ b/modules/cache/v2_interfaces_test.go @@ -0,0 +1,73 @@ +package cache + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestCacheModule creates a CacheModule initialised with a memory engine for testing. +func newTestCacheModule(t *testing.T) (*CacheModule, context.Context) { + t.Helper() + module := NewModule().(*CacheModule) + app := newMockApp() + + // Pre-register config with explicit values so struct-tag defaults are not needed. + cfg := &CacheConfig{ + Engine: "memory", + DefaultTTL: 300 * time.Second, + CleanupInterval: 60 * time.Second, + MaxItems: 10000, + } + app.RegisterConfigSection(module.Name(), modular.NewStdConfigProvider(cfg)) + + require.NoError(t, module.RegisterConfig(app)) // skips (already registered) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + t.Cleanup(func() { _ = module.Stop(ctx) }) + + return module, ctx +} + +func TestCacheModule_CollectMetrics(t *testing.T) { + t.Parallel() + + module, ctx := newTestCacheModule(t) + + // Add some items + require.NoError(t, module.Set(ctx, "key1", "val1", time.Minute)) + require.NoError(t, module.Set(ctx, "key2", "val2", time.Minute)) + require.NoError(t, module.Set(ctx, "key3", "val3", time.Minute)) + + metrics := module.CollectMetrics(ctx) + assert.Equal(t, "cache", metrics.Name) + assert.Equal(t, 3.0, metrics.Values["item_count"]) + assert.Equal(t, 10000.0, metrics.Values["max_items"]) +} + +func TestCacheModule_Reloadable(t *testing.T) { + t.Parallel() + + module, ctx := newTestCacheModule(t) + + // Verify interface compliance + var reloadable modular.Reloadable = module + assert.True(t, reloadable.CanReload()) + assert.Equal(t, 5*time.Second, reloadable.ReloadTimeout()) + + // Verify reload updates config (cleanupInterval is not reloadable) + changes := []modular.ConfigChange{ + {FieldPath: "defaultTTL", NewValue: "600s"}, + {FieldPath: "maxItems", NewValue: "5000"}, + } + require.NoError(t, reloadable.Reload(ctx, changes)) + + assert.Equal(t, 600*time.Second, module.config.DefaultTTL) + assert.Equal(t, 5000, module.config.MaxItems) +} diff --git a/modules/chimux/go.mod b/modules/chimux/go.mod index fc374bfc..d241142f 100644 --- a/modules/chimux/go.mod +++ b/modules/chimux/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/chimux go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/chimux/go.sum b/modules/chimux/go.sum index 10db6514..7f6c1fd6 100644 --- a/modules/chimux/go.sum +++ b/modules/chimux/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/configwatcher/go.mod b/modules/configwatcher/go.mod index 45fa8004..96890a64 100644 --- a/modules/configwatcher/go.mod +++ b/modules/configwatcher/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/configwatcher go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/fsnotify/fsnotify v1.9.0 ) @@ -17,6 +17,6 @@ require ( 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 + golang.org/x/sys v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/modules/configwatcher/go.sum b/modules/configwatcher/go.sum index 82f80dac..e47cec0d 100644 --- a/modules/configwatcher/go.sum +++ b/modules/configwatcher/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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= @@ -74,8 +74,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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/modules/database/go.mod b/modules/database/go.mod index fc0213b2..6a001016 100644 --- a/modules/database/go.mod +++ b/modules/database/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/database/v2 go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/database/go.sum b/modules/database/go.sum index 09757702..272c9e71 100644 --- a/modules/database/go.sum +++ b/modules/database/go.sum @@ -5,8 +5,8 @@ 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 v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= -github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= +github.com/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/modules/database/module.go b/modules/database/module.go index 16c13724..bb6f1260 100644 --- a/modules/database/module.go +++ b/modules/database/module.go @@ -406,6 +406,7 @@ func (l *lazyDefaultService) SetEventEmitter(emitter EventEmitter) { type Module struct { config *Config connections map[string]*sql.DB + connMu sync.RWMutex // Protects connections map services map[string]DatabaseService subject modular.Subject // For event observation subjectMu sync.RWMutex // Protects subject field from race conditions diff --git a/modules/database/v2_interfaces.go b/modules/database/v2_interfaces.go new file mode 100644 index 00000000..8343d267 --- /dev/null +++ b/modules/database/v2_interfaces.go @@ -0,0 +1,165 @@ +package database + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions. +var ( + _ modular.MetricsProvider = (*Module)(nil) + _ modular.Drainable = (*Module)(nil) + _ modular.Reloadable = (*Module)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It returns pool statistics from sql.DBStats for every connection. +func (m *Module) CollectMetrics(_ context.Context) modular.ModuleMetrics { + values := make(map[string]float64) + + m.connMu.RLock() + multipleConns := len(m.connections) > 1 + + for name, db := range m.connections { + stats := db.Stats() + prefix := "" + if multipleConns { + prefix = name + "." + } + values[prefix+"open_connections"] = float64(stats.OpenConnections) + values[prefix+"in_use"] = float64(stats.InUse) + values[prefix+"idle"] = float64(stats.Idle) + values[prefix+"wait_count"] = float64(stats.WaitCount) + values[prefix+"wait_duration_ms"] = float64(stats.WaitDuration.Milliseconds()) + values[prefix+"max_open"] = float64(stats.MaxOpenConnections) + } + m.connMu.RUnlock() + + return modular.ModuleMetrics{ + Name: Name, + Values: values, + } +} + +// PreStop implements modular.Drainable. +// It caps max open connections to the current in-use count (minimum 1) to +// allow active queries to finish while preventing new connections, then waits +// for active queries to complete. +// Note: SetMaxOpenConns(0) means unlimited in database/sql, so we use +// max(stats.InUse, 1) to actually restrict the pool. +func (m *Module) PreStop(ctx context.Context) error { + m.connMu.RLock() + m.logger.Info("Draining database connections", "count", len(m.connections)) + + for name, db := range m.connections { + stats := db.Stats() + cap := stats.InUse + if cap < 1 { + cap = 1 + } + db.SetMaxOpenConns(cap) + m.logger.Info("Capped max open connections for drain", "connection", name, "cap", cap) + } + m.connMu.RUnlock() + + // Wait for active queries to finish, respecting context deadline. + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + allIdle := true + m.connMu.RLock() + for _, db := range m.connections { + if db.Stats().InUse > 0 { + allIdle = false + break + } + } + m.connMu.RUnlock() + if allIdle { + m.logger.Info("All database connections drained") + return nil + } + + select { + case <-ctx.Done(): + m.logger.Warn("Drain timeout reached, proceeding with active connections") + return nil + case <-ticker.C: + // Check again + } + } +} + +// CanReload implements modular.Reloadable. +func (m *Module) CanReload() bool { + return true +} + +// ReloadTimeout implements modular.Reloadable. +func (m *Module) ReloadTimeout() time.Duration { + return 10 * time.Second +} + +// Reload implements modular.Reloadable. +// It applies pool configuration changes to existing connections without reconnecting. +func (m *Module) Reload(_ context.Context, changes []modular.ConfigChange) error { + for _, change := range changes { + // Match field paths like "MaxOpenConnections" or "connections..MaxOpenConnections" + field := change.FieldPath + parts := strings.Split(field, ".") + + // Determine target field name (last segment) + targetField := parts[len(parts)-1] + + // Determine which connections to apply to + var targetConns []string + if len(parts) >= 3 && parts[0] == "connections" { + // Scoped to a specific connection: connections.. + targetConns = []string{parts[1]} + } else { + // Apply to all connections + for name := range m.connections { + targetConns = append(targetConns, name) + } + } + + for _, connName := range targetConns { + db, ok := m.connections[connName] + if !ok { + continue + } + + switch targetField { + case "MaxOpenConnections": + if v, err := strconv.Atoi(change.NewValue); err == nil { + db.SetMaxOpenConns(v) + m.logger.Info("Reloaded MaxOpenConnections", "connection", connName, "value", v) + } + case "MaxIdleConnections": + if v, err := strconv.Atoi(change.NewValue); err == nil { + db.SetMaxIdleConns(v) + m.logger.Info("Reloaded MaxIdleConnections", "connection", connName, "value", v) + } + case "ConnectionMaxLifetime": + if v, err := time.ParseDuration(change.NewValue); err == nil { + db.SetConnMaxLifetime(v) + m.logger.Info("Reloaded ConnectionMaxLifetime", "connection", connName, "value", v) + } + case "ConnectionMaxIdleTime": + if v, err := time.ParseDuration(change.NewValue); err == nil { + db.SetConnMaxIdleTime(v) + m.logger.Info("Reloaded ConnectionMaxIdleTime", "connection", connName, "value", v) + } + default: + m.logger.Debug("Ignoring unrecognized config change", "field", fmt.Sprintf("%s (target: %s)", field, targetField)) + } + } + } + return nil +} diff --git a/modules/database/v2_interfaces_test.go b/modules/database/v2_interfaces_test.go new file mode 100644 index 00000000..5aa27216 --- /dev/null +++ b/modules/database/v2_interfaces_test.go @@ -0,0 +1,338 @@ +package database + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +func TestModule_CollectMetrics(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + assert.NotNil(t, metrics.Values) + + // With a single connection, keys should not be prefixed + expectedKeys := []string{ + "open_connections", + "in_use", + "idle", + "wait_count", + "wait_duration_ms", + "max_open", + } + for _, key := range expectedKeys { + _, exists := metrics.Values[key] + assert.True(t, exists, "expected metric key %q to exist", key) + } +} + +func TestModule_CollectMetrics_MultipleConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + "secondary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + + // With multiple connections, keys should be prefixed with connection name + for _, prefix := range []string{"primary.", "secondary."} { + _, exists := metrics.Values[prefix+"open_connections"] + assert.True(t, exists, "expected metric key %q to exist", prefix+"open_connections") + } +} + +func TestModule_CollectMetrics_NoConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "default", + Connections: map[string]*ConnectionConfig{}, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + assert.Empty(t, metrics.Values) +} + +func TestModule_PreStop(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = module.PreStop(ctx) + assert.NoError(t, err) +} + +func TestModule_PreStop_NoConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "default", + Connections: map[string]*ConnectionConfig{}, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + + ctx := context.Background() + err = module.PreStop(ctx) + assert.NoError(t, err) +} + +func TestModule_Reloadable(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + assert.True(t, module.CanReload()) + assert.Equal(t, 10*time.Second, module.ReloadTimeout()) +} + +func TestModule_Reload(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Reload with MaxOpenConnections change + changes := []modular.ConfigChange{ + { + FieldPath: "MaxOpenConnections", + NewValue: "20", + }, + { + FieldPath: "MaxIdleConnections", + NewValue: "5", + }, + { + FieldPath: "ConnectionMaxLifetime", + NewValue: "30m", + }, + { + FieldPath: "ConnectionMaxIdleTime", + NewValue: "5m", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) + + // Verify the pool settings were applied + db, exists := module.GetConnection("primary") + require.True(t, exists) + stats := db.Stats() + assert.Equal(t, 20, stats.MaxOpenConnections) +} + +func TestModule_Reload_ScopedConnection(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + "secondary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Reload only the primary connection's MaxOpenConnections + changes := []modular.ConfigChange{ + { + FieldPath: "connections.primary.MaxOpenConnections", + NewValue: "25", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) + + primaryDB, _ := module.GetConnection("primary") + assert.Equal(t, 25, primaryDB.Stats().MaxOpenConnections) + + secondaryDB, _ := module.GetConnection("secondary") + assert.Equal(t, 10, secondaryDB.Stats().MaxOpenConnections) +} + +func TestModule_Reload_UnrecognizedField(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Unrecognized fields should be silently ignored + changes := []modular.ConfigChange{ + { + FieldPath: "SomeUnknownField", + NewValue: "value", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) +} + +func TestModule_InterfaceCompliance(t *testing.T) { + module := NewModule() + assert.Implements(t, (*modular.MetricsProvider)(nil), module) + assert.Implements(t, (*modular.Drainable)(nil), module) + assert.Implements(t, (*modular.Reloadable)(nil), module) +} diff --git a/modules/eventbus/durable_memory_test.go b/modules/eventbus/durable_memory_test.go index a0574834..d190c5e4 100644 --- a/modules/eventbus/durable_memory_test.go +++ b/modules/eventbus/durable_memory_test.go @@ -48,8 +48,8 @@ func publishN(t *testing.T, module *EventBusModule, topic string, n int) int { // durable engine the publisher blocks until the subscriber processes each batch. func TestDurableMemoryNoEventLoss(t *testing.T) { const ( - topic = "durable.no-loss" - total = 200 + topic = "durable.no-loss" + total = 200 queueDepth = 20 // intentionally small to exercise backpressure ) diff --git a/modules/eventbus/go.mod b/modules/eventbus/go.mod index 1c664065..e8623f1c 100644 --- a/modules/eventbus/go.mod +++ b/modules/eventbus/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.0 require ( github.com/DataDog/datadog-go/v5 v5.4.0 - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/IBM/sarama v1.45.2 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/aws/aws-sdk-go-v2/service/kinesis v1.38.0 diff --git a/modules/eventbus/go.sum b/modules/eventbus/go.sum index 7c417cf5..b7cb28ca 100644 --- a/modules/eventbus/go.sum +++ b/modules/eventbus/go.sum @@ -2,8 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= -github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= +github.com/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= diff --git a/modules/eventbus/memory_buffer_test.go b/modules/eventbus/memory_buffer_test.go index a8038a7f..ba01761a 100644 --- a/modules/eventbus/memory_buffer_test.go +++ b/modules/eventbus/memory_buffer_test.go @@ -22,7 +22,7 @@ func TestWorkerPoolBufferSize(t *testing.T) { app := newMockApp() cfg := &EventBusConfig{ Engine: "memory", - WorkerCount: 1, // only one worker — intentionally slow + WorkerCount: 1, // only one worker — intentionally slow DefaultEventBufferSize: 64, MaxEventQueueSize: 50, // queue depth for the worker pool task queue DeliveryMode: "drop", diff --git a/modules/eventbus/v2_interfaces.go b/modules/eventbus/v2_interfaces.go new file mode 100644 index 00000000..d60cf969 --- /dev/null +++ b/modules/eventbus/v2_interfaces.go @@ -0,0 +1,44 @@ +package eventbus + +import ( + "context" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions. +var ( + _ modular.MetricsProvider = (*EventBusModule)(nil) + _ modular.Drainable = (*EventBusModule)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It exposes delivery statistics and topology counts for external monitoring. +func (m *EventBusModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + delivered, dropped := m.Stats() + + topics := m.Topics() + var subscriberTotal int + for _, t := range topics { + subscriberTotal += m.SubscriberCount(t) + } + + return modular.ModuleMetrics{ + Name: m.Name(), + Values: map[string]float64{ + "delivered_count": float64(delivered), + "dropped_count": float64(dropped), + "topic_count": float64(len(topics)), + "subscriber_count": float64(subscriberTotal), + }, + } +} + +// PreStop implements modular.Drainable. +// It logs the drain intent. Actual resource cleanup is handled by Stop(). +func (m *EventBusModule) PreStop(_ context.Context) error { + if m.logger != nil { + m.logger.Info("EventBus drain phase starting — no new publishes will be accepted after Stop") + } + return nil +} diff --git a/modules/eventbus/v2_interfaces_test.go b/modules/eventbus/v2_interfaces_test.go new file mode 100644 index 00000000..c43e786b --- /dev/null +++ b/modules/eventbus/v2_interfaces_test.go @@ -0,0 +1,97 @@ +package eventbus + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helper that creates, inits, and starts an EventBusModule. +func setupModule(t *testing.T) *EventBusModule { + t.Helper() + module := NewModule().(*EventBusModule) + app := newMockApp() + require.NoError(t, module.RegisterConfig(app)) + require.NoError(t, module.Init(app)) + require.NoError(t, module.Start(context.Background())) + return module +} + +func TestEventBusModule_CollectMetrics(t *testing.T) { + module := setupModule(t) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Before any activity the counters should be zero. + metrics := module.CollectMetrics(ctx) + assert.Equal(t, module.Name(), metrics.Name) + assert.Equal(t, float64(0), metrics.Values["delivered_count"]) + assert.Equal(t, float64(0), metrics.Values["dropped_count"]) + assert.Equal(t, float64(0), metrics.Values["topic_count"]) + assert.Equal(t, float64(0), metrics.Values["subscriber_count"]) + + // Subscribe + publish so counters move. + received := make(chan struct{}, 1) + sub, err := module.Subscribe(ctx, "metrics.test", func(_ context.Context, _ Event) error { + received <- struct{}{} + return nil + }) + require.NoError(t, err) + + require.NoError(t, module.Publish(ctx, "metrics.test", map[string]any{"k": "v"})) + + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for event delivery") + } + + metrics = module.CollectMetrics(ctx) + assert.Equal(t, float64(1), metrics.Values["delivered_count"]) + assert.Equal(t, float64(0), metrics.Values["dropped_count"]) + assert.Equal(t, float64(1), metrics.Values["topic_count"]) + assert.Equal(t, float64(1), metrics.Values["subscriber_count"]) + + _ = module.Unsubscribe(ctx, sub) +} + +func TestEventBusModule_CollectMetrics_InterfaceCompliance(t *testing.T) { + var _ modular.MetricsProvider = (*EventBusModule)(nil) +} + +func TestEventBusModule_PreStop(t *testing.T) { + module := setupModule(t) + defer func() { _ = module.Stop(context.Background()) }() + + // PreStop should succeed without error. + err := module.PreStop(context.Background()) + assert.NoError(t, err) + + // Module should still be operational after PreStop (Stop handles actual shutdown). + ctx := context.Background() + received := make(chan struct{}, 1) + sub, err := module.Subscribe(ctx, "prestop.test", func(_ context.Context, _ Event) error { + received <- struct{}{} + return nil + }) + require.NoError(t, err) + + require.NoError(t, module.Publish(ctx, "prestop.test", map[string]any{"a": 1})) + + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("timed out — module should still work after PreStop") + } + + _ = module.Unsubscribe(ctx, sub) +} + +func TestEventBusModule_PreStop_InterfaceCompliance(t *testing.T) { + var _ modular.Drainable = (*EventBusModule)(nil) +} diff --git a/modules/eventlogger/bdd_output_targets_test.go b/modules/eventlogger/bdd_output_targets_test.go index fbf97ff2..8758a93e 100644 --- a/modules/eventlogger/bdd_output_targets_test.go +++ b/modules/eventlogger/bdd_output_targets_test.go @@ -118,8 +118,9 @@ func (ctx *EventLoggerBDDTestContext) iHaveAnEventLoggerWithMetadataInclusionEna return err } - // Create config with metadata inclusion enabled (already enabled in console config) - config := ctx.createConsoleConfig(10) + // Create config with metadata inclusion enabled (already enabled in console config). + // Buffer must be large enough to absorb framework lifecycle events during Init/Start. + config := ctx.createConsoleConfig(50) // Create application with the config err = ctx.createApplicationWithConfig(config) diff --git a/modules/eventlogger/go.mod b/modules/eventlogger/go.mod index bed18200..03bd7532 100644 --- a/modules/eventlogger/go.mod +++ b/modules/eventlogger/go.mod @@ -5,7 +5,7 @@ go 1.26 toolchain go1.26.0 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/eventlogger/go.sum b/modules/eventlogger/go.sum index aac52522..9c46f7b1 100644 --- a/modules/eventlogger/go.sum +++ b/modules/eventlogger/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/eventlogger/module.go b/modules/eventlogger/module.go index b8434926..4721693b 100644 --- a/modules/eventlogger/module.go +++ b/modules/eventlogger/module.go @@ -377,6 +377,28 @@ func (m *EventLoggerModule) emitStartupOperationalEvents(ctx context.Context, sy } } +// PreStop implements the modular.Drainable interface. +// It signals the event logger to flush pending events before the full Stop phase. +func (m *EventLoggerModule) PreStop(ctx context.Context) error { + m.mutex.RLock() + started := m.started + logger := m.logger + m.mutex.RUnlock() + + if !started { + return nil + } + + if logger != nil { + logger.Info("Event logger drain phase starting") + } + + // Flush all output targets to ensure buffered data is written + m.flushOutputs() + + return nil +} + // Stop stops the event logger processing. func (m *EventLoggerModule) Stop(ctx context.Context) error { m.mutex.Lock() diff --git a/modules/eventlogger/regression_test.go b/modules/eventlogger/regression_test.go index 84c15171..3c443469 100644 --- a/modules/eventlogger/regression_test.go +++ b/modules/eventlogger/regression_test.go @@ -155,7 +155,7 @@ func TestEventLogger_EarlyLifecycleEventsDoNotError(t *testing.T) { func TestEventLogger_SynchronousStartupConfigFlag(t *testing.T) { logger := &capturingLogger{} app := modular.NewObservableApplication(modular.NewStdConfigProvider(struct{}{}), logger) - cfg := &EventLoggerConfig{Enabled: true, LogLevel: "INFO", Format: "structured", BufferSize: 5, FlushInterval: 100 * time.Millisecond, StartupSync: true, OutputTargets: []OutputTargetConfig{{Type: "console", Level: "INFO", Format: "structured", Console: &ConsoleTargetConfig{UseColor: false, Timestamps: false}}}} + cfg := &EventLoggerConfig{Enabled: true, LogLevel: "INFO", Format: "structured", BufferSize: 50, FlushInterval: 100 * time.Millisecond, StartupSync: true, OutputTargets: []OutputTargetConfig{{Type: "console", Level: "INFO", Format: "structured", Console: &ConsoleTargetConfig{UseColor: false, Timestamps: false}}}} app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(cfg)) mod := NewModule().(*EventLoggerModule) app.RegisterModule(mod) @@ -165,7 +165,9 @@ func TestEventLogger_SynchronousStartupConfigFlag(t *testing.T) { if err := app.Start(); err != nil { t.Fatalf("start failed: %v", err) } - // Without sleep, attempt to emit a test event and ensure no ErrLoggerNotStarted + // Without sleep, attempt to emit a test event and ensure no ErrLoggerNotStarted. + // BufferSize must be large enough to absorb framework lifecycle events + // (phase changes, service registrations, etc.) that are emitted during Init/Start. evt := modular.NewCloudEvent("sync.startup.test", "test", nil, nil) if err := mod.OnEvent(context.Background(), evt); err != nil { t.Fatalf("OnEvent failed unexpectedly: %v", err) diff --git a/modules/eventlogger/v2_interfaces_test.go b/modules/eventlogger/v2_interfaces_test.go new file mode 100644 index 00000000..48302e8a --- /dev/null +++ b/modules/eventlogger/v2_interfaces_test.go @@ -0,0 +1,38 @@ +package eventlogger + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/modular" +) + +// TestEventLoggerModule_ImplementsDrainable verifies that EventLoggerModule +// satisfies the modular.Drainable interface at compile time. +func TestEventLoggerModule_ImplementsDrainable(t *testing.T) { + var _ modular.Drainable = (*EventLoggerModule)(nil) +} + +// TestEventLoggerModule_PreStop_NotStarted verifies PreStop returns nil +// when the module has not been started. +func TestEventLoggerModule_PreStop_NotStarted(t *testing.T) { + m := &EventLoggerModule{name: ModuleName} + if err := m.PreStop(context.Background()); err != nil { + t.Fatalf("PreStop on unstarted module should return nil, got: %v", err) + } +} + +// TestEventLoggerModule_PreStop_Started verifies PreStop flushes outputs +// and returns nil when the module is running. +func TestEventLoggerModule_PreStop_Started(t *testing.T) { + m := &EventLoggerModule{ + name: ModuleName, + started: true, + logger: &testLogger{}, + outputs: []OutputTarget{}, + } + + if err := m.PreStop(context.Background()); err != nil { + t.Fatalf("PreStop on started module should return nil, got: %v", err) + } +} diff --git a/modules/httpclient/go.mod b/modules/httpclient/go.mod index 281f9379..7c9b2149 100644 --- a/modules/httpclient/go.mod +++ b/modules/httpclient/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/httpclient go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpclient/go.sum b/modules/httpclient/go.sum index aac52522..9c46f7b1 100644 --- a/modules/httpclient/go.sum +++ b/modules/httpclient/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpclient/logger_test.go b/modules/httpclient/logger_test.go index ce3e3cda..563a0146 100644 --- a/modules/httpclient/logger_test.go +++ b/modules/httpclient/logger_test.go @@ -116,7 +116,7 @@ func TestSanitizeForFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := sanitizeForFilename(tt.input) - + // For the long URL test, we need to check length separately if tt.name == "very long URL" { assert.LessOrEqual(t, len(result), 100, "result should be at most 100 characters") diff --git a/modules/httpclient/module_test.go b/modules/httpclient/module_test.go index b95dce15..ba1088bd 100644 --- a/modules/httpclient/module_test.go +++ b/modules/httpclient/module_test.go @@ -76,16 +76,16 @@ func (m *MockApplication) GetService(name string, target any) error { } // Add other required methods to satisfy the interface -func (m *MockApplication) Name() string { return "mock-app" } -func (m *MockApplication) IsInitializing() bool { return false } -func (m *MockApplication) IsStarting() bool { return false } -func (m *MockApplication) IsStopping() bool { return false } -func (m *MockApplication) RegisterModule(module modular.Module) {} -func (m *MockApplication) Run() error { return nil } -func (m *MockApplication) Shutdown(ctx context.Context) error { return nil } -func (m *MockApplication) Init() error { return nil } -func (m *MockApplication) Start() error { return nil } -func (m *MockApplication) Stop() error { return nil } +func (m *MockApplication) Name() string { return "mock-app" } +func (m *MockApplication) IsInitializing() bool { return false } +func (m *MockApplication) IsStarting() bool { return false } +func (m *MockApplication) IsStopping() bool { return false } +func (m *MockApplication) RegisterModule(module modular.Module) {} +func (m *MockApplication) Run() error { return nil } +func (m *MockApplication) Shutdown(ctx context.Context) error { return nil } +func (m *MockApplication) Init() error { return nil } +func (m *MockApplication) Start() error { return nil } +func (m *MockApplication) Stop() error { return nil } // Newly added methods to satisfy updated modular.Application interface func (m *MockApplication) Context() context.Context { return context.Background() } diff --git a/modules/httpserver/go.mod b/modules/httpserver/go.mod index e1f9fb2a..de1c659f 100644 --- a/modules/httpserver/go.mod +++ b/modules/httpserver/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/httpserver go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpserver/go.sum b/modules/httpserver/go.sum index aac52522..9c46f7b1 100644 --- a/modules/httpserver/go.sum +++ b/modules/httpserver/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpserver/module.go b/modules/httpserver/module.go index 12968ae3..03aa5469 100644 --- a/modules/httpserver/module.go +++ b/modules/httpserver/module.go @@ -93,6 +93,7 @@ type HTTPServerModule struct { started bool certificateService CertificateService subject modular.Subject // For event observation (guarded by mu) + draining bool // Set by PreStop to signal drain phase mu sync.RWMutex } diff --git a/modules/httpserver/v2_interfaces.go b/modules/httpserver/v2_interfaces.go new file mode 100644 index 00000000..d2380e88 --- /dev/null +++ b/modules/httpserver/v2_interfaces.go @@ -0,0 +1,121 @@ +package httpserver + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions for v2 enhancement interfaces. +var ( + _ modular.Drainable = (*HTTPServerModule)(nil) + _ modular.Reloadable = (*HTTPServerModule)(nil) + _ modular.MetricsProvider = (*HTTPServerModule)(nil) +) + +// PreStop signals that the server is entering the drain phase. +// The actual graceful shutdown (http.Server.Shutdown) happens in Stop(). +// PreStop sets the draining flag so middleware or health checks can +// report the server as unhealthy during the drain window. +func (m *HTTPServerModule) PreStop(ctx context.Context) error { + m.mu.Lock() + m.draining = true + m.mu.Unlock() + if m.logger != nil { + m.logger.Info("HTTP server entering drain phase") + } + return nil +} + +// CanReload reports whether the module can currently accept a reload. +// Returns true only when the server has been started and is running. +func (m *HTTPServerModule) CanReload() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.started +} + +// ReloadTimeout returns the maximum duration allowed for a reload operation. +func (m *HTTPServerModule) ReloadTimeout() time.Duration { + return 5 * time.Second +} + +// Reload applies configuration changes to the running HTTP server. +// Supported fields: ReadTimeout, WriteTimeout, IdleTimeout. +// Note: http.Server timeout fields are not safe for concurrent mutation on a +// running server, so only the config is updated here. The new values take +// effect if the server is restarted. +func (m *HTTPServerModule) Reload(_ context.Context, changes []modular.ConfigChange) error { + m.mu.RLock() + if !m.started || m.server == nil { + m.mu.RUnlock() + return ErrServerNotStarted + } + m.mu.RUnlock() + + for _, change := range changes { + field := change.FieldPath + // Normalise: accept both dotted paths (e.g. "httpserver.ReadTimeout") + // and bare field names. + if idx := strings.LastIndex(field, "."); idx >= 0 { + field = field[idx+1:] + } + field = strings.ToLower(field) + + switch field { + case "readtimeout", "read_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid ReadTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.ReadTimeout = d + m.mu.Unlock() + + case "writetimeout", "write_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid WriteTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.WriteTimeout = d + m.mu.Unlock() + + case "idletimeout", "idle_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid IdleTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.IdleTimeout = d + m.mu.Unlock() + } + } + + return nil +} + +// CollectMetrics returns operational metrics for the HTTP server module. +func (m *HTTPServerModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + m.mu.RLock() + started := 0.0 + if m.started { + started = 1.0 + } + port := 0.0 + if m.config != nil { + port = float64(m.config.Port) + } + m.mu.RUnlock() + + return modular.ModuleMetrics{ + Name: ModuleName, + Values: map[string]float64{ + "started": started, + "port": port, + }, + } +} diff --git a/modules/httpserver/v2_interfaces_test.go b/modules/httpserver/v2_interfaces_test.go new file mode 100644 index 00000000..e3aa54d6 --- /dev/null +++ b/modules/httpserver/v2_interfaces_test.go @@ -0,0 +1,171 @@ +package httpserver + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newTestModule returns a minimally configured HTTPServerModule for unit tests. +func newTestModule(t *testing.T) *HTTPServerModule { + t.Helper() + logger := &MockLogger{} + // Register logger mocks for varying arg counts (msg + 0, 2, or 4 keyval args). + for _, method := range []string{"Info", "Debug", "Warn", "Error"} { + logger.On(method, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + } + + return &HTTPServerModule{ + config: &HTTPServerConfig{ + Host: "127.0.0.1", + Port: 9999, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + ShutdownTimeout: 5 * time.Second, + }, + logger: logger, + } +} + +// --------------------------------------------------------------------------- +// Drainable +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_Drainable(t *testing.T) { + // Verify interface compliance at compile time. + var _ modular.Drainable = (*HTTPServerModule)(nil) + + m := newTestModule(t) + + // Before PreStop, draining should be false. + assert.False(t, m.draining, "draining flag should be false before PreStop") + + err := m.PreStop(context.Background()) + require.NoError(t, err) + + // After PreStop, draining should be true. + assert.True(t, m.draining, "draining flag should be true after PreStop") +} + +// --------------------------------------------------------------------------- +// Reloadable +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_Reloadable(t *testing.T) { + var _ modular.Reloadable = (*HTTPServerModule)(nil) + + t.Run("CanReload false when not started", func(t *testing.T) { + m := newTestModule(t) + assert.False(t, m.CanReload()) + }) + + t.Run("CanReload true when started", func(t *testing.T) { + m := newTestModule(t) + m.started = true + assert.True(t, m.CanReload()) + }) + + t.Run("ReloadTimeout is 5 seconds", func(t *testing.T) { + m := newTestModule(t) + assert.Equal(t, 5*time.Second, m.ReloadTimeout()) + }) + + t.Run("Reload updates config timeouts", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{ + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + changes := []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "30s"}, + {FieldPath: "WriteTimeout", NewValue: "25s"}, + {FieldPath: "httpserver.IdleTimeout", NewValue: "120s"}, + } + + err := m.Reload(context.Background(), changes) + require.NoError(t, err) + + // Config is updated; server fields are not mutated to avoid data races + // on a running http.Server (new values take effect on restart). + assert.Equal(t, 30*time.Second, m.config.ReadTimeout) + assert.Equal(t, 25*time.Second, m.config.WriteTimeout) + assert.Equal(t, 120*time.Second, m.config.IdleTimeout) + }) + + t.Run("Reload rejects invalid duration", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{} + + changes := []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "not-a-duration"}, + } + + err := m.Reload(context.Background(), changes) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid ReadTimeout") + }) + + t.Run("Reload fails when server not started", func(t *testing.T) { + m := newTestModule(t) + + err := m.Reload(context.Background(), []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "10s"}, + }) + assert.ErrorIs(t, err, ErrServerNotStarted) + }) + + t.Run("Reload ignores unknown fields", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{} + + changes := []modular.ConfigChange{ + {FieldPath: "UnknownField", NewValue: "whatever"}, + } + + err := m.Reload(context.Background(), changes) + require.NoError(t, err) + }) +} + +// --------------------------------------------------------------------------- +// MetricsProvider +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_MetricsProvider(t *testing.T) { + var _ modular.MetricsProvider = (*HTTPServerModule)(nil) + + t.Run("metrics when not started", func(t *testing.T) { + m := newTestModule(t) + + metrics := m.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 0.0, metrics.Values["started"]) + assert.Equal(t, float64(9999), metrics.Values["port"]) + }) + + t.Run("metrics when started", func(t *testing.T) { + m := newTestModule(t) + m.started = true + + metrics := m.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 1.0, metrics.Values["started"]) + assert.Equal(t, float64(9999), metrics.Values["port"]) + }) +} diff --git a/modules/jsonschema/go.mod b/modules/jsonschema/go.mod index 80c67035..55c488e6 100644 --- a/modules/jsonschema/go.mod +++ b/modules/jsonschema/go.mod @@ -5,7 +5,7 @@ go 1.26 toolchain go1.26.0 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 diff --git a/modules/jsonschema/go.sum b/modules/jsonschema/go.sum index 5ce38311..0bfaf208 100644 --- a/modules/jsonschema/go.sum +++ b/modules/jsonschema/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/letsencrypt/go.mod b/modules/letsencrypt/go.mod index ac369fe2..c68b9b62 100644 --- a/modules/letsencrypt/go.mod +++ b/modules/letsencrypt/go.mod @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/modular/modules/letsencrypt go 1.26 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/letsencrypt/go.sum b/modules/letsencrypt/go.sum index 0330822d..9bd73246 100644 --- a/modules/letsencrypt/go.sum +++ b/modules/letsencrypt/go.sum @@ -29,8 +29,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 h1:nVaeiC59OEqMj0jcDZwIUHrba4CdPT9ntcGBAw81iKs= github.com/GoCodeAlone/modular/modules/httpserver v1.12.0/go.mod h1:sVklMEsxKxKihMDz5Zh2RFqnwpgXd/IT9lbAVGlkWEE= github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= diff --git a/modules/logmasker/go.mod b/modules/logmasker/go.mod index 21cb2002..6518d058 100644 --- a/modules/logmasker/go.mod +++ b/modules/logmasker/go.mod @@ -2,7 +2,7 @@ module github.com/GoCodeAlone/modular/modules/logmasker go 1.26 -require github.com/GoCodeAlone/modular v1.12.0 +require github.com/GoCodeAlone/modular v1.12.1 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/modules/logmasker/go.sum b/modules/logmasker/go.sum index baabf235..f18cbeff 100644 --- a/modules/logmasker/go.sum +++ b/modules/logmasker/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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= diff --git a/modules/reverseproxy/go.mod b/modules/reverseproxy/go.mod index 3110148d..d327cf20 100644 --- a/modules/reverseproxy/go.mod +++ b/modules/reverseproxy/go.mod @@ -5,7 +5,7 @@ go 1.26 // retract (from old module path) v1.0.0 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/reverseproxy/go.sum b/modules/reverseproxy/go.sum index 61e7b249..5b8589e6 100644 --- a/modules/reverseproxy/go.sum +++ b/modules/reverseproxy/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/reverseproxy/v2_interfaces.go b/modules/reverseproxy/v2_interfaces.go new file mode 100644 index 00000000..a290c192 --- /dev/null +++ b/modules/reverseproxy/v2_interfaces.go @@ -0,0 +1,58 @@ +package reverseproxy + +import ( + "context" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time assertions for v2 interfaces +var _ modular.MetricsProvider = (*ReverseProxyModule)(nil) +var _ modular.Drainable = (*ReverseProxyModule)(nil) + +// CollectMetrics implements modular.MetricsProvider. +// It aggregates metrics from the internal MetricsCollector into the standard ModuleMetrics format. +func (m *ReverseProxyModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + values := make(map[string]float64) + + // Always report backend count + m.backendProxiesMutex.RLock() + values["backend_count"] = float64(len(m.backendProxies)) + m.backendProxiesMutex.RUnlock() + + // If metrics are enabled and the collector exists, aggregate request/error totals + if m.enableMetrics && m.metrics != nil { + m.metrics.mu.RLock() + var totalRequests, totalErrors int + for _, count := range m.metrics.requestCounts { + totalRequests += count + } + for _, count := range m.metrics.errorCounts { + totalErrors += count + } + m.metrics.mu.RUnlock() + + values["total_requests"] = float64(totalRequests) + values["total_errors"] = float64(totalErrors) + } + + return modular.ModuleMetrics{ + Name: m.Name(), + Values: values, + } +} + +// PreStop implements modular.Drainable. +// It stops the health checker during the drain phase, before the full Stop() is called. +func (m *ReverseProxyModule) PreStop(ctx context.Context) error { + if m.app != nil && m.app.Logger() != nil { + m.app.Logger().Info("PreStop: draining reverseproxy module") + } + + // Stop health checker early so backends aren't flapped during drain + if m.healthChecker != nil { + m.healthChecker.Stop(ctx) + } + + return nil +} diff --git a/modules/reverseproxy/v2_interfaces_test.go b/modules/reverseproxy/v2_interfaces_test.go new file mode 100644 index 00000000..cdc184ee --- /dev/null +++ b/modules/reverseproxy/v2_interfaces_test.go @@ -0,0 +1,103 @@ +package reverseproxy + +import ( + "context" + "io" + "log/slog" + "net/http/httputil" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReverseProxyModule_CollectMetrics(t *testing.T) { + t.Run("metrics enabled with data", func(t *testing.T) { + module := NewModule() + module.enableMetrics = true + module.metrics = NewMetricsCollector() + module.backendProxies = map[string]*httputil.ReverseProxy{ + "api1": {}, + "api2": {}, + "api3": {}, + } + + // Simulate some recorded requests + module.metrics.RecordRequest("api1", time.Now().Add(-10*time.Millisecond), 200, nil) + module.metrics.RecordRequest("api1", time.Now().Add(-5*time.Millisecond), 200, nil) + module.metrics.RecordRequest("api2", time.Now().Add(-8*time.Millisecond), 500, assert.AnError) + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, "reverseproxy", result.Name) + require.NotNil(t, result.Values) + assert.Equal(t, float64(3), result.Values["backend_count"]) + assert.Equal(t, float64(3), result.Values["total_requests"]) + assert.Equal(t, float64(1), result.Values["total_errors"]) + }) + + t.Run("metrics disabled returns only backend_count", func(t *testing.T) { + module := NewModule() + module.enableMetrics = false + module.backendProxies = map[string]*httputil.ReverseProxy{ + "api1": {}, + } + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, "reverseproxy", result.Name) + require.NotNil(t, result.Values) + assert.Equal(t, float64(1), result.Values["backend_count"]) + _, hasRequests := result.Values["total_requests"] + assert.False(t, hasRequests, "should not have total_requests when metrics disabled") + _, hasErrors := result.Values["total_errors"] + assert.False(t, hasErrors, "should not have total_errors when metrics disabled") + }) + + t.Run("no backends returns zero backend_count", func(t *testing.T) { + module := NewModule() + module.enableMetrics = false + module.backendProxies = map[string]*httputil.ReverseProxy{} + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, float64(0), result.Values["backend_count"]) + }) + + t.Run("satisfies MetricsProvider interface", func(t *testing.T) { + var _ modular.MetricsProvider = (*ReverseProxyModule)(nil) + }) +} + +func TestReverseProxyModule_PreStop(t *testing.T) { + t.Run("stops health checker", func(t *testing.T) { + module := NewModule() + // Create a minimal health checker that we can verify gets stopped + hc := &HealthChecker{ + running: true, + stopChan: make(chan struct{}), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + module.healthChecker = hc + + err := module.PreStop(context.Background()) + + require.NoError(t, err) + assert.False(t, hc.running, "health checker should be stopped after PreStop") + }) + + t.Run("nil health checker does not panic", func(t *testing.T) { + module := NewModule() + module.healthChecker = nil + + err := module.PreStop(context.Background()) + + require.NoError(t, err) + }) + + t.Run("satisfies Drainable interface", func(t *testing.T) { + var _ modular.Drainable = (*ReverseProxyModule)(nil) + }) +} diff --git a/modules/scheduler/go.mod b/modules/scheduler/go.mod index a1430b47..04abee28 100644 --- a/modules/scheduler/go.mod +++ b/modules/scheduler/go.mod @@ -5,7 +5,7 @@ go 1.26 toolchain go1.26.0 require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/google/uuid v1.6.0 diff --git a/modules/scheduler/go.sum b/modules/scheduler/go.sum index 5c2673da..be8bbc3d 100644 --- a/modules/scheduler/go.sum +++ b/modules/scheduler/go.sum @@ -1,7 +1,7 @@ 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/GoCodeAlone/modular v1.12.1 h1:FEyAPr7vDp+qrSjgWEZI7sH+YUN4/o6R58XuzpCSXUU= +github.com/GoCodeAlone/modular v1.12.1/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= 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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/scheduler/module.go b/modules/scheduler/module.go index c97e5e33..b75d33b7 100644 --- a/modules/scheduler/module.go +++ b/modules/scheduler/module.go @@ -565,6 +565,74 @@ func (m *SchedulerModule) emitEvent(ctx context.Context, eventType string, data } } +// CollectMetrics implements the modular.MetricsProvider interface. +// It returns operational metrics for the scheduler module including running state, +// worker count, total job count, and pending job count. +func (m *SchedulerModule) CollectMetrics(ctx context.Context) modular.ModuleMetrics { + m.schedulerLock.Lock() + running := m.running + m.schedulerLock.Unlock() + + values := map[string]float64{ + "running": 0.0, + } + if running { + values["running"] = 1.0 + } + + if m.config != nil { + values["worker_count"] = float64(m.config.WorkerCount) + } + + if m.jobStore != nil { + jobs, err := m.jobStore.GetJobs() + if err == nil { + values["job_count"] = float64(len(jobs)) + pending := 0 + for _, j := range jobs { + if j.Status == JobStatusPending { + pending++ + } + } + values["pending_jobs"] = float64(pending) + } + } + + return modular.ModuleMetrics{ + Name: m.name, + Values: values, + } +} + +// PreStop implements the modular.Drainable interface. +// It persists jobs if persistence is enabled and signals the scheduler to stop +// dispatching new jobs. Actual worker shutdown happens in Stop(). +func (m *SchedulerModule) PreStop(ctx context.Context) error { + m.schedulerLock.Lock() + defer m.schedulerLock.Unlock() + + if m.logger != nil { + m.logger.Info("Scheduler drain phase starting") + } + + // Save jobs if persistence is enabled + if m.config != nil && m.config.PersistenceBackend != PersistenceBackendNone { + if err := m.savePersistedJobs(); err != nil { + if m.logger != nil { + m.logger.Warn("PreStop: failed to save jobs", "error", err) + } + } + } + + // Stop dispatching new jobs by cancelling the scheduler's context. + // Workers will finish in-flight jobs; Stop() handles the full shutdown. + if m.scheduler != nil && m.scheduler.cancel != nil { + m.scheduler.cancel() + } + + return nil +} + // GetRegisteredEventTypes implements the ObservableModule interface. // Returns all event types that this scheduler module can emit. func (m *SchedulerModule) GetRegisteredEventTypes() []string { diff --git a/modules/scheduler/v2_interfaces_test.go b/modules/scheduler/v2_interfaces_test.go new file mode 100644 index 00000000..33086366 --- /dev/null +++ b/modules/scheduler/v2_interfaces_test.go @@ -0,0 +1,135 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Compile-time interface checks +var ( + _ modular.MetricsProvider = (*SchedulerModule)(nil) + _ modular.Drainable = (*SchedulerModule)(nil) +) + +func TestSchedulerModule_CollectMetrics(t *testing.T) { + t.Run("not running, no jobs", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + module.RegisterConfig(app) + require.NoError(t, module.Init(app)) + + metrics := module.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 0.0, metrics.Values["running"]) + assert.Equal(t, float64(5), metrics.Values["worker_count"]) // default config + assert.Equal(t, 0.0, metrics.Values["job_count"]) + assert.Equal(t, 0.0, metrics.Values["pending_jobs"]) + }) + + t.Run("running with jobs", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + config := &SchedulerConfig{ + WorkerCount: 3, + QueueSize: 50, + StorageType: "memory", + CheckInterval: 1 * time.Second, + ShutdownTimeout: 5 * time.Second, + RetentionDays: 7, + PersistenceBackend: PersistenceBackendNone, + } + app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(config)) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + defer module.Stop(ctx) //nolint:errcheck + + // Schedule a pending job (far future so it stays pending) + _, err := module.ScheduleJob(Job{ + Name: "metrics-test-pending", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + + // Schedule and cancel a job so we have mixed statuses + cancelID, err := module.ScheduleJob(Job{ + Name: "metrics-test-cancel", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + require.NoError(t, module.CancelJob(cancelID)) + + metrics := module.CollectMetrics(ctx) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 1.0, metrics.Values["running"]) + assert.Equal(t, float64(3), metrics.Values["worker_count"]) + assert.Equal(t, 2.0, metrics.Values["job_count"]) + assert.Equal(t, 1.0, metrics.Values["pending_jobs"]) + }) +} + +func TestSchedulerModule_PreStop(t *testing.T) { + t.Run("persists jobs on drain", func(t *testing.T) { + handler := NewMemoryPersistenceHandler() + module := NewModule().(*SchedulerModule) + app := newMockApp() + config := &SchedulerConfig{ + WorkerCount: 2, + QueueSize: 10, + StorageType: "memory", + CheckInterval: 1 * time.Second, + ShutdownTimeout: 5 * time.Second, + RetentionDays: 7, + PersistenceBackend: PersistenceBackendMemory, + PersistenceHandler: handler, + } + app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(config)) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + + // Schedule a future job + _, err := module.ScheduleJob(Job{ + Name: "prestop-test", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + + // Call PreStop — should save jobs and cancel dispatcher + err = module.PreStop(ctx) + require.NoError(t, err) + + // Verify persistence handler has data + data := handler.GetStoredData() + assert.NotEmpty(t, data, "PreStop should have persisted jobs") + + // Clean up — Stop() should still succeed even after PreStop cancelled the context + _ = module.Stop(ctx) + }) + + t.Run("no persistence configured", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + module.RegisterConfig(app) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + + // PreStop with no persistence should succeed (no-op for persistence) + err := module.PreStop(ctx) + require.NoError(t, err) + + _ = module.Stop(ctx) + }) +} diff --git a/service.go b/service.go index 1239249d..d7d2d475 100644 --- a/service.go +++ b/service.go @@ -253,7 +253,10 @@ func (r *EnhancedServiceRegistry) generateUniqueName(originalName, moduleName st // Still conflicts - try with module type name if moduleType != nil { - typeName := moduleType.Elem().Name() + var typeName string + if moduleType.Kind() == reflect.Ptr { + typeName = moduleType.Elem().Name() + } if typeName == "" { typeName = moduleType.String() }