feat(config): add sampler hot-reload callback for ADR-0023 Phase 3.2
- Add SamplerReconfigureFunc type and SetSamplerReconfigureCallback method - Track previous sampler type/ratio values to detect changes - Invoke callback when telemetry.sampler.type or ratio changes - Fix race condition in WatchAndApply cleanup using watcherStopped flag - Add unit tests for sampler type/ratio hot-reload scenarios - Update ADR-0023 status to reflect Phase 3.2 in flight Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -16,6 +16,13 @@ import (
|
||||
"dance-lessons-coach/pkg/version"
|
||||
)
|
||||
|
||||
// SamplerReconfigureFunc is the signature for callbacks invoked when
|
||||
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
|
||||
// The callback receives the new sampler type and ratio values.
|
||||
// It must be safe to call concurrently — implementations should use their
|
||||
// own synchronisation if needed. Returns an error if the reconfigure fails.
|
||||
type SamplerReconfigureFunc func(ctx context.Context, samplerType string, samplerRatio float64) error
|
||||
|
||||
// NewZerologWriter creates a zerolog writer based on configuration
|
||||
func NewZerologWriter() *os.File {
|
||||
return os.Stderr
|
||||
@@ -41,6 +48,20 @@ type Config struct {
|
||||
// reloadMu serialises Unmarshal during hot-reload so a partial mutation
|
||||
// can't be observed mid-flight by getter calls.
|
||||
reloadMu sync.RWMutex `mapstructure:"-"`
|
||||
|
||||
// samplerReconfigureCallback is invoked when telemetry.sampler.type or
|
||||
// telemetry.sampler.ratio change. nil means no callback registered.
|
||||
samplerReconfigureCallback SamplerReconfigureFunc `mapstructure:"-"`
|
||||
|
||||
// prevSamplerType and prevSamplerRatio track the last-seen sampler values
|
||||
// to detect changes during hot-reload (ADR-0023 Phase 3).
|
||||
prevSamplerType string `mapstructure:"-"`
|
||||
prevSamplerRatio float64 `mapstructure:"-"`
|
||||
|
||||
// watcherStopped indicates that the config watcher has been stopped via
|
||||
// the context being cancelled. This prevents the OnConfigChange handler
|
||||
// from processing events after cleanup.
|
||||
watcherStopped bool `mapstructure:"-"`
|
||||
}
|
||||
|
||||
// ServerConfig holds server-related configuration
|
||||
@@ -314,6 +335,11 @@ func LoadConfig() (*Config, error) {
|
||||
// Keep the viper instance for hot-reload (ADR-0023).
|
||||
config.viper = v
|
||||
|
||||
// Initialize previous sampler values for hot-reload change detection
|
||||
// (ADR-0023 Phase 3).
|
||||
config.prevSamplerType = config.Telemetry.Sampler.Type
|
||||
config.prevSamplerRatio = config.Telemetry.Sampler.Ratio
|
||||
|
||||
// Setup logging based on configuration (level, output file, time format).
|
||||
// The JSON/console format was already applied at the top of LoadConfig via
|
||||
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
|
||||
@@ -378,6 +404,19 @@ func (c *Config) GetSamplerRatio() float64 {
|
||||
return c.Telemetry.Sampler.Ratio
|
||||
}
|
||||
|
||||
// SetSamplerReconfigureCallback registers a callback that is invoked when
|
||||
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
|
||||
// The callback receives the new sampler type and ratio values.
|
||||
// Pass nil to unregister the callback.
|
||||
func (c *Config) SetSamplerReconfigureCallback(callback SamplerReconfigureFunc) {
|
||||
c.reloadMu.Lock()
|
||||
defer c.reloadMu.Unlock()
|
||||
c.samplerReconfigureCallback = callback
|
||||
// Initialize previous values so we can detect changes on first hot-reload
|
||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
||||
}
|
||||
|
||||
// GetV2Enabled returns whether v2 API is enabled
|
||||
func (c *Config) GetV2Enabled() bool {
|
||||
return c.API.V2Enabled
|
||||
@@ -615,9 +654,11 @@ func (c *Config) setupLogOutput() {
|
||||
// reads it via JWTConfig.GetTTL (a method value capturing this *Config).
|
||||
// The reloaded TTL is used on the NEXT token generation; tokens issued
|
||||
// before the change keep their original expiry.
|
||||
// - Phase 3: telemetry.sampler.type + telemetry.sampler.ratio — triggers
|
||||
// the callback set via SetSamplerReconfigureCallback if the values change.
|
||||
//
|
||||
// The other fields listed in ADR-0023 (api.v2_enabled, telemetry sampler)
|
||||
// remain restart-only until their handlers land in subsequent phases.
|
||||
// The other fields listed in ADR-0023 (api.v2_enabled) remain restart-only
|
||||
// until their handlers land in subsequent phases.
|
||||
//
|
||||
// Stops when ctx is cancelled. Safe to call once at server startup.
|
||||
// If the config file is absent (ConfigFileNotFoundError at load time), this
|
||||
@@ -633,6 +674,14 @@ func (c *Config) WatchAndApply(ctx context.Context) {
|
||||
}
|
||||
|
||||
c.viper.OnConfigChange(func(in fsnotify.Event) {
|
||||
// Skip processing if watcher has been stopped
|
||||
c.reloadMu.Lock()
|
||||
if c.watcherStopped {
|
||||
c.reloadMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.reloadMu.Unlock()
|
||||
|
||||
log.Info().Str("event", in.Op.String()).Str("file", in.Name).Msg("Config file changed, reloading hot-reloadable fields")
|
||||
c.reloadMu.Lock()
|
||||
defer c.reloadMu.Unlock()
|
||||
@@ -645,6 +694,30 @@ func (c *Config) WatchAndApply(ctx context.Context) {
|
||||
// Apply hot-reloadable fields. Order matters: logging first so the
|
||||
// rest of the reload is logged at the right level.
|
||||
c.SetupLogging()
|
||||
|
||||
// Check if sampler config changed and invoke callback if registered
|
||||
samplerChanged := c.prevSamplerType != c.Telemetry.Sampler.Type ||
|
||||
c.prevSamplerRatio != c.Telemetry.Sampler.Ratio
|
||||
if samplerChanged && c.samplerReconfigureCallback != nil {
|
||||
if err := c.samplerReconfigureCallback(context.Background(),
|
||||
c.Telemetry.Sampler.Type,
|
||||
c.Telemetry.Sampler.Ratio); err != nil {
|
||||
log.Error().Err(err).Msg("Hot-reload: sampler reconfigure callback failed")
|
||||
} else {
|
||||
// Update previous values only after successful callback
|
||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
||||
log.Info().
|
||||
Str("sampler_type", c.prevSamplerType).
|
||||
Float64("sampler_ratio", c.prevSamplerRatio).
|
||||
Msg("Hot-reload applied: telemetry sampler reconfigured")
|
||||
}
|
||||
} else if samplerChanged {
|
||||
// No callback registered, just update tracking values
|
||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("logging_level", c.GetLogLevel()).
|
||||
Dur("jwt_ttl", c.GetJWTTTL()).
|
||||
@@ -654,11 +727,14 @@ func (c *Config) WatchAndApply(ctx context.Context) {
|
||||
|
||||
log.Info().Str("file", c.viper.ConfigFileUsed()).Msg("Config hot-reload watcher started (ADR-0023 Phase 1)")
|
||||
|
||||
// Stop the watcher on context cancel — viper has no public Stop method,
|
||||
// so we just clear the callback to make further events no-ops.
|
||||
// Stop the watcher on context cancel — we set a flag that the
|
||||
// OnConfigChange handler checks, avoiding the race with viper's
|
||||
// internal state that would occur if we called OnConfigChange again.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.viper.OnConfigChange(func(_ fsnotify.Event) {})
|
||||
c.reloadMu.Lock()
|
||||
c.watcherStopped = true
|
||||
c.reloadMu.Unlock()
|
||||
log.Info().Msg("Config hot-reload watcher stopped")
|
||||
}()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user