React to Configuration Changes at Runtime¶
GTB's configuration container watches the config file for changes using fsnotify. When a change is detected, all registered observers are called with the updated Containable. This lets long-running services (daemons, servers) reconfigure themselves without restarting.
How It Works¶
The watchConfig loop starts automatically when the container is loaded from a config file. Observers are invoked synchronously in the watch goroutine. Errors returned via the chan error are logged by the framework โ they do not abort subsequent observers.
Step 1: Implement the Observable Interface¶
Create a struct that implements config.Observable:
import "gitlab.com/phpboyscout/go-tool-base/pkg/config"
type DatabaseReconfigurer struct {
pool *sql.DB
log logger.Logger
}
func (r *DatabaseReconfigurer) Run(cfg config.Containable, errs chan error) {
newDSN := cfg.GetString("database.dsn")
if newDSN == "" {
errs <- errors.New("database.dsn is required")
return
}
if err := r.pool.Reconnect(newDSN); err != nil {
errs <- errors.Wrap(err, "failed to reconfigure database pool")
return
}
r.log.Info("Database pool reconfigured", "dsn", maskDSN(newDSN))
}
Step 2: Register the Observer¶
Register during command setup, after the config has been loaded:
func NewCmdServe(p *props.Props) *setup.Command {
return setup.Wrap("serve", &cobra.Command{
Use: "serve",
RunE: func(cmd *cobra.Command, args []string) error {
pool, err := sql.Open("postgres", p.Config.GetString("database.dsn"))
if err != nil {
return err
}
// Register observer โ called whenever the config file changes
p.Config.AddObserver(&DatabaseReconfigurer{
pool: pool,
log: p.Logger,
})
return runServer(cmd.Context(), p, pool)
},
})
}
Using AddObserverFunc for Simple Cases¶
If you don't need a named struct, register a function directly:
p.Config.AddObserverFunc(func(cfg config.Containable, errs chan error) {
newLevel, err := logger.ParseLevel(cfg.GetString("log.level"))
if err != nil {
errs <- err
return
}
p.Logger.SetLevel(newLevel)
p.Logger.Info("Log level updated", "level", newLevel)
})
This is the idiomatic pattern for simple, stateless reconfiguration.
Multiple Observers¶
Observers execute in registration order. Register independent concerns separately:
// Each observer handles one concern
p.Config.AddObserverFunc(updateLogLevel)
p.Config.AddObserverFunc(updateRateLimit)
p.Config.AddObserver(&DatabaseReconfigurer{pool: pool})
p.Config.AddObserver(&CacheReconfigurer{cache: cache})
Errors from one observer do not prevent subsequent observers from running.
Example: Log Level Hot-Reload¶
A complete pattern for runtime log level changes โ useful for toggling debug output on a running daemon:
// Register once during startup
p.Config.AddObserverFunc(func(cfg config.Containable, errs chan error) {
levelStr := cfg.GetString("log.level")
if levelStr == "" {
return // key absent โ no change
}
level, err := logger.ParseLevel(levelStr)
if err != nil {
errs <- errors.WithHintf(err,
"Valid levels are: debug, info, warn, error")
return
}
current := p.Logger.GetLevel()
if level == current {
return // no change
}
p.Logger.SetLevel(level)
p.Logger.Info("Log level changed",
"from", current,
"to", level,
)
})
Now, changing log.level: debug in the config file takes effect immediately on the running process.
Important Constraints¶
Observers run in the watch goroutine. Keep handlers fast and non-blocking. For expensive operations (e.g. re-establishing a connection pool), dispatch to a separate goroutine and signal a channel:
type AsyncReconfigurer struct {
triggerCh chan config.Containable
}
func (r *AsyncReconfigurer) Run(cfg config.Containable, _ chan error) {
// Non-blocking send; drop the update if the channel is busy
select {
case r.triggerCh <- cfg:
default:
}
}
Observers are not called on startup โ only on subsequent file changes. If you need the same logic at startup and on reload, extract it to a shared function:
reconfigure := func(cfg config.Containable) error {
return updateDatabasePool(cfg)
}
// Run once at startup
if err := reconfigure(p.Config); err != nil {
return err
}
// And again on every config file change
p.Config.AddObserverFunc(func(cfg config.Containable, errs chan error) {
if err := reconfigure(cfg); err != nil {
errs <- err
}
})
Testing¶
In tests, call observer.Run(mockCfg, errCh) directly โ no file watching needed:
func TestDatabaseReconfigurer(t *testing.T) {
mockCfg := mocks_config.NewMockContainable(t)
mockCfg.On("GetString", "database.dsn").Return("postgres://test/db")
mockPool := &mockDB{}
observer := &DatabaseReconfigurer{pool: mockPool, log: logger.NewNoop()}
errCh := make(chan error, 1)
observer.Run(mockCfg, errCh)
select {
case err := <-errCh:
t.Fatalf("unexpected error: %v", err)
default:
assert.True(t, mockPool.ReconnectCalled)
}
}
Related Documentation¶
- Configuration component โ
Containable,Observable,AddObserverAPI reference - Configuration Precedence โ how file watching fits into the config loading lifecycle