Skip to content

Hot-Reload & Observers

Observer Pattern for Configuration Changes

The configuration system includes a built-in observer pattern. The file-backed Container runs its own fsnotify watcher over every configured file. On a change it rebuilds and re-merges all files into a candidate, validates the candidate against the schema (if any), and — only on success — swaps the live config atomically and notifies observers. A reload that fails to parse any file or fails validation is rejected fail-closed: the last-known-good config is retained, Get* keeps serving the previous values, and observers are not notified. Save bursts are coalesced behind a configurable debounce window (default 250 ms; see WithReloadDebounce).

Observable Interface

Run returns an error. A returned error is logged by the framework; it does not abort subsequent observers and never stalls future reloads. (This replaced the previous chan error parameter — see the migration guide.)

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/config for the full API definition.

Adding Observers

Register observers to react to configuration changes:

// Using the Observable interface
type ConfigWatcher struct {
    name string
}

func (cw *ConfigWatcher) Run(cfg config.Containable) error {
    // React to configuration changes
    newPort := cfg.GetInt("app.port")
    fmt.Printf("Configuration updated - new port: %d\n", newPort)

    // Return any error; it is logged by the framework
    if newPort < 1024 {
        return fmt.Errorf("invalid port number: %d", newPort)
    }

    return nil
}

// Register the observer
watcher := &ConfigWatcher{name: "port-monitor"}
container.AddObserver(watcher)

// Or use a function directly
container.AddObserverFunc(func(cfg config.Containable) error {
    l.Info("Configuration reloaded", "timestamp", time.Now())

    return nil
})

Automatic File Watching

Every file-backed container is watched — single-file as well as multi-file — once construction completes:

// This enables file watching automatically
container := config.NewFilesContainer(fs,
    config.WithLogger(l),
    config.WithConfigFiles("config.yaml", "local.yaml"),
    config.WithReloadDebounce(500*time.Millisecond), // optional; default 250ms
)
defer container.Close() // stop the watcher and release OS resources

// File watching triggers observers when either file changes
container.AddObserverFunc(func(cfg config.Containable) error {
    // Called whenever config.yaml or local.yaml changes, after the merged
    // candidate has validated and been swapped in.
    newLogLevel := cfg.GetString("log.level")
    // Reconfigure logging, restart services, etc.
    _ = newLogLevel

    return nil
})

Reacting to rejected reloads

Observers are notified only when a reload succeeds — the candidate config was built, passed schema validation, and was swapped in. They are never handed a rejected reload, because nothing changed and the returned-error contract has no channel to push a reload-time error back to an observer.

To learn about a rejected reload programmatically, register an OnReloadError callback. It fires whenever a reload is rejected and the last-known-good config is retained — that is, when:

  • the candidate failed to build (a fail-closed partial-merge / parse error, or the primary file went missing, honouring ErrConfigFileNotFound); or
  • the candidate failed schema validation.
container := config.NewFilesContainer(fs,
    config.WithLogger(l),
    config.WithConfigFiles("config.yaml", "local.yaml"),
    config.WithSchema(schema),
)

// Fires on a CHANGE that was applied.
container.AddObserverFunc(func(cfg config.Containable) error {
    // apply the new, validated configuration
    return nil
})

// Fires on a CHANGE that was REJECTED (config unchanged, last-known-good kept).
container.OnReloadError(func(err error) {
    l.Warn("config reload rejected; keeping last-known-good", "error", err)
    // e.g. raise an alert, bump a metric, surface a banner
})

Guarantees and ordering

  • The container always logs the rejection at ERROR; OnReloadError callbacks are additive to that log, not a replacement.
  • OnReloadError is never invoked for a successful reload; observers are.
  • Callbacks are stored under the container mutex, copied under the lock, and invoked outside the lock (the same race-safe, deadlock-free discipline as observer notification), so registering a callback concurrently with an active reload is safe under -race.
  • Callbacks run in registration order on the watcher goroutine; a slow callback delays subsequent reloads, so offload expensive work.