v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
3c35601179
commit
e08467bf38
42 files changed
+2533
-4274
No files matched your search
+30
-343
@@ -1,349 +1,36 @@
|
||||
# Live Reconfiguration
|
||||
# Live reconfiguration
|
||||
|
||||
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
|
||||
`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and
|
||||
subscribes to notifications. Option variants configure the polling interval
|
||||
(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks.
|
||||
Options for an already-running watcher stay unchanged; stop/restart to change them.
|
||||
|
||||
## Basic File Watching
|
||||
One worker reads and fingerprints the regular file per poll, including same-size,
|
||||
same-timestamp edits and atomic replacements. Both successful and erroneous reads
|
||||
must settle for the debounce interval. Stable errors are reported once per observed
|
||||
state. Deletion preserves the last valid state, and recreation can reload normally.
|
||||
This trades one file read per poll for reliable detection of metadata-preserving edits.
|
||||
|
||||
### Enable Auto-Update
|
||||
Before publishing, the worker checks its context, current watcher identity, watched
|
||||
path and source generation under the Config lock. Type checks and allocation finish
|
||||
before the final timeout check. Expired, stopped or superseded operations cannot
|
||||
publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt
|
||||
a kernel filesystem operation. Reads never hold the Config lock.
|
||||
Timed-out operations retry on later polls even if the content is unchanged; repeated
|
||||
timeouts for the same observation emit only one notification until recovery.
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithFile("config.toml").
|
||||
Build()
|
||||
Events are changed registered paths, or `file_deleted`, `permissions_changed`,
|
||||
`reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means
|
||||
the effective value changed; a file value masked by CLI need not notify. Group/world
|
||||
permission changes are blocked when VerifyPermissions is enabled; correcting the
|
||||
permissions allows loading again. Default options enable that check.
|
||||
|
||||
// Enable automatic reloading
|
||||
cfg.AutoUpdate()
|
||||
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss
|
||||
events and should read the latest full state. MaxWatchers bounds retained channels.
|
||||
StopAutoUpdate cancels publication and closes channels synchronously without waiting
|
||||
under the Config lock. There is no goroutine per subscriber or per timed-out reload.
|
||||
A new Watch call after stopping creates a new channel and worker.
|
||||
|
||||
// Your application continues running
|
||||
// Config reloads automatically when file changes
|
||||
|
||||
// Stop watching when done
|
||||
defer cfg.StopAutoUpdate()
|
||||
```
|
||||
|
||||
### Watch for Changes
|
||||
|
||||
```go
|
||||
// Get notified of configuration changes
|
||||
changes := cfg.Watch()
|
||||
|
||||
go func() {
|
||||
for path := range changes {
|
||||
log.Printf("Configuration changed: %s", path)
|
||||
|
||||
// React to specific changes
|
||||
switch path {
|
||||
case "server.port":
|
||||
// Restart server with new port
|
||||
restartServer()
|
||||
case "log.level":
|
||||
// Update log level
|
||||
updateLogLevel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Watch Options
|
||||
|
||||
### Custom Watch Configuration
|
||||
|
||||
```go
|
||||
opts := config.WatchOptions{
|
||||
PollInterval: 500 * time.Millisecond, // Check every 500ms
|
||||
Debounce: 200 * time.Millisecond, // Wait 200ms after changes
|
||||
MaxWatchers: 50, // Limit concurrent watchers
|
||||
ReloadTimeout: 10 * time.Second, // Timeout for reload
|
||||
VerifyPermissions: true, // Security check
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
### Watch Without Auto-Update
|
||||
|
||||
```go
|
||||
// Just watch, don't auto-reload
|
||||
changes := cfg.WatchWithOptions(config.WatchOptions{
|
||||
PollInterval: time.Second,
|
||||
})
|
||||
|
||||
// Manually reload when desired
|
||||
go func() {
|
||||
for range changes {
|
||||
if shouldReload() {
|
||||
cfg.LoadFile("config.toml")
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Change Detection
|
||||
|
||||
### Value Changes
|
||||
|
||||
The watcher detects and notifies about:
|
||||
- New values added
|
||||
- Existing values modified
|
||||
- Values removed
|
||||
- Type changes
|
||||
|
||||
```go
|
||||
changes := cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
newVal, exists := cfg.Get(path)
|
||||
if !exists {
|
||||
log.Printf("Removed: %s", path)
|
||||
continue
|
||||
}
|
||||
|
||||
sources := cfg.GetSources(path)
|
||||
fileVal, hasFile := sources[config.SourceFile]
|
||||
|
||||
log.Printf("Changed: %s = %v (from file: %v)",
|
||||
path, newVal, hasFile)
|
||||
}
|
||||
```
|
||||
|
||||
### Special Notifications
|
||||
|
||||
```go
|
||||
changes := cfg.Watch()
|
||||
|
||||
for notification := range changes {
|
||||
switch notification {
|
||||
case "file_deleted":
|
||||
log.Warn("Config file was deleted")
|
||||
|
||||
case "permissions_changed":
|
||||
log.Error("Config file permissions changed - potential security issue")
|
||||
|
||||
case "reload_timeout":
|
||||
log.Error("Config reload timed out")
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(notification, "reload_error:") {
|
||||
log.Error("Reload error:", notification)
|
||||
} else {
|
||||
// Normal path change
|
||||
handleConfigChange(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debouncing
|
||||
|
||||
Rapid file changes are automatically debounced:
|
||||
|
||||
```go
|
||||
// Multiple rapid saves to config.toml
|
||||
// Only triggers one reload after debounce period
|
||||
|
||||
opts := config.WatchOptions{
|
||||
PollInterval: 100 * time.Millisecond,
|
||||
Debounce: 500 * time.Millisecond, // Wait 500ms
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
## Permission Monitoring
|
||||
|
||||
```go
|
||||
opts := config.WatchOptions{
|
||||
VerifyPermissions: true, // Enabled by default
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
|
||||
// Detects if file becomes world-writable
|
||||
changes := cfg.Watch()
|
||||
for change := range changes {
|
||||
if change == "permissions_changed" {
|
||||
// File permissions changed
|
||||
// Possible security breach
|
||||
alert("Config file permissions modified!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Reconfiguration
|
||||
|
||||
```go
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
listener net.Listener
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *Server) watchConfig() {
|
||||
changes := s.cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "server."):
|
||||
s.scheduleRestart()
|
||||
|
||||
case path == "log.level":
|
||||
s.updateLogLevel()
|
||||
|
||||
case strings.HasPrefix(path, "feature."):
|
||||
s.reloadFeatures()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scheduleRestart() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Graceful restart logic
|
||||
log.Info("Scheduling server restart for config changes")
|
||||
// ... drain connections, restart listener ...
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Feature Flags
|
||||
|
||||
```go
|
||||
type FeatureFlags struct {
|
||||
cfg *config.Config
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (ff *FeatureFlags) Watch() {
|
||||
changes := ff.cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
if strings.HasPrefix(path, "features.") {
|
||||
feature := strings.TrimPrefix(path, "features.")
|
||||
enabled, _ := ff.cfg.Get(path)
|
||||
|
||||
log.Printf("Feature %s: %v", feature, enabled)
|
||||
ff.notifyFeatureChange(feature, enabled.(bool))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ff *FeatureFlags) IsEnabled(feature string) bool {
|
||||
ff.mu.RLock()
|
||||
defer ff.mu.RUnlock()
|
||||
|
||||
val, exists := ff.cfg.Get("features." + feature)
|
||||
return exists && val.(bool)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Multi-Stage Reload
|
||||
|
||||
```go
|
||||
func watchConfigWithValidation(cfg *config.Config) {
|
||||
changes := cfg.Watch()
|
||||
|
||||
for range changes {
|
||||
// Stage 1: Snapshot current config
|
||||
backup := cfg.Clone()
|
||||
|
||||
// Stage 2: Validate new configuration
|
||||
if err := validateNewConfig(cfg); err != nil {
|
||||
log.Error("Invalid configuration:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stage 3: Apply changes
|
||||
if err := applyConfigChanges(cfg, backup); err != nil {
|
||||
log.Error("Failed to apply changes:", err)
|
||||
// Could restore from backup here
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("Configuration successfully reloaded")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Watch Status
|
||||
|
||||
```go
|
||||
// Check if watching is active
|
||||
if cfg.IsWatching() {
|
||||
log.Printf("Auto-update is enabled")
|
||||
log.Printf("Active watchers: %d", cfg.WatcherCount())
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Management
|
||||
|
||||
```go
|
||||
// Limit watchers to prevent resource exhaustion
|
||||
opts := config.WatchOptions{
|
||||
MaxWatchers: 10, // Max 10 concurrent watch channels
|
||||
}
|
||||
|
||||
// Watchers beyond limit receive closed channels
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Stop Watching**: Use `defer cfg.StopAutoUpdate()` to clean up
|
||||
2. **Handle All Notifications**: Check for special error notifications
|
||||
3. **Validate After Reload**: Ensure new config is valid before applying
|
||||
4. **Use Debouncing**: Prevent reload storms from rapid edits
|
||||
5. **Monitor Permissions**: Enable permission verification for security
|
||||
6. **Graceful Updates**: Plan how your app handles config changes
|
||||
7. **Log Changes**: Audit configuration modifications
|
||||
|
||||
## Limitations
|
||||
|
||||
- File watching uses polling (not inotify/kqueue)
|
||||
- No support for watching multiple files
|
||||
- Changes only detected for registered paths
|
||||
- Reloads entire file (no partial updates)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Changes Not Detected
|
||||
|
||||
```go
|
||||
// Ensure path is registered before watching
|
||||
cfg.Register("new.value", "default")
|
||||
|
||||
// Now changes to new.value will be detected
|
||||
```
|
||||
|
||||
### Rapid Reloads
|
||||
|
||||
```go
|
||||
// Increase debounce to prevent rapid reloads
|
||||
opts := config.WatchOptions{
|
||||
Debounce: 2 * time.Second, // Wait 2s after changes stop
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Leaks
|
||||
|
||||
```go
|
||||
// Always stop watching to prevent goroutine leaks
|
||||
watcher := cfg.Watch()
|
||||
|
||||
// Use context for cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case change := <-watcher:
|
||||
handleChange(change)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
`WatchFile` loads a replacement path before replacing the watcher and preserves its
|
||||
options. Failure leaves the prior watcher and values active. Loading a different
|
||||
file directly stops the previous watcher; call Watch to watch the new path.
|
||||
Reference in new issue
Block a user