Files
teamai-test/.teamai/skills/common/golang-spf13-viper/references/watch-and-reload.md
T

104 lines
3.2 KiB
Markdown

# Viper WatchConfig and Hot Reload
## Basic setup
```go
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
log.Printf("config changed: %s (op: %s)", e.Name, e.Op)
// re-read affected values and apply them
})
```
`WatchConfig` starts a background goroutine that watches the config file using fsnotify. Call it after `ReadInConfig`.
## The atomic-rename trap
Most editors (vim, neovim, many CI tools) write config files by creating a new file and then renaming it over the old one. This replaces the inode that fsnotify is watching — the watch may not fire, or may fire with `Op: RENAME` instead of `Op: WRITE`, or may fire twice.
**Test hot reload with direct file writes, not editor saves:**
```go
// reliable in tests:
os.WriteFile("config.yaml", newContent, 0644)
// unreliable for testing:
// opening vim and :w — may trigger RENAME instead of WRITE
```
In production, this is less of an issue if your config management tool (Kubernetes ConfigMap volume mount, Consul Template, etc.) is aware of inode behavior.
## Race-safe reload pattern
Config reload happens in a background goroutine. Any shared state updated in `OnConfigChange` must be synchronized:
```go
type Config struct {
mu sync.RWMutex
LogLevel string `mapstructure:"log_level"`
MaxConn int `mapstructure:"max_conn"`
}
var cfg Config
viper.OnConfigChange(func(e fsnotify.Event) {
var newCfg Config
if err := viper.Unmarshal(&newCfg); err != nil {
log.Printf("error reloading config: %v", err)
return // keep old config on error
}
cfg.mu.Lock()
cfg.LogLevel = newCfg.LogLevel
cfg.MaxConn = newCfg.MaxConn
cfg.mu.Unlock()
log.Printf("config reloaded: log_level=%s", newCfg.LogLevel)
})
```
Reads use `appCfg.mu.RLock()`. Never read directly from viper in hot paths during reload — the window between `OnConfigChange` firing and viper updating its internal state is non-deterministic.
## Debouncing rapid changes
Some filesystems fire multiple events per save. Debounce to avoid reloading multiple times:
```go
var reloadTimer *time.Timer
var reloadMu sync.Mutex
viper.OnConfigChange(func(e fsnotify.Event) {
reloadMu.Lock()
defer reloadMu.Unlock()
if reloadTimer != nil {
reloadTimer.Stop()
}
reloadTimer = time.AfterFunc(100*time.Millisecond, func() {
applyNewConfig()
})
})
```
## Validating config before applying
Always validate reloaded config before applying it — an invalid config mid-reload should keep the previous working config:
```go
viper.OnConfigChange(func(e fsnotify.Event) {
var candidate Config
if err := viper.Unmarshal(&candidate); err != nil {
log.Printf("reload: invalid config, keeping previous: %v", err)
return
}
if err := validate(candidate); err != nil {
log.Printf("reload: validation failed, keeping previous: %v", err)
return
}
applyConfig(candidate)
})
```
## Stopping the watcher
There is no documented way to stop `WatchConfig` once started. Design your application so that the watcher's lifetime matches the process lifetime. For testing, create a new `viper.New()` instance per test — the watcher is per-instance and is garbage-collected with the instance.