v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 16:55:58 -04:00
1 parent 3c35601179
commit e08467bf38
42 files changed
+2533 -4274

No files matched your search

+30 -343
View File
@@ -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.