Skip to content

Migration: config observer signature (channel β†’ returned error)

The config hot-reload rework replaces the container's unbuffered, unread error channel with a returned-error contract. This removes the deadlock class that the channel caused and is the idiomatic Go shape. The change is a one-line update at every observer call site.

This is a breaking change to the config.Observable interface and the AddObserverFunc signature. GTB is pre-1.0, so this ships as a minor bump with no compatibility shim.


Breaking Changes

config.Observable / AddObserverFunc now return an error

Package: pkg/config

Before:

// Observable interface
type Observable interface {
    Run(Containable, chan error)
}

// Function observer
cfg.AddObserverFunc(func(c config.Containable, errs chan error) {
    if err := apply(c); err != nil {
        errs <- err
        return
    }
})

After:

// Observable interface
type Observable interface {
    Run(Containable) error
}

// Function observer
cfg.AddObserverFunc(func(c config.Containable) error {
    return apply(c)
})

Migration:

  1. Change every observer's Run(Containable, chan error) to Run(Containable) error.
  2. Replace errs <- err; return with return err.
  3. Replace early return (no error) with return nil.
  4. In tests, replace observer.Run(cfg, errCh) with err := observer.Run(cfg) and assert on the returned error directly.

The framework logs any returned error. An observer error no longer blocks subsequent observers or stalls future reloads.


New Features

The same rework also brings, with no further action required:

  • Multi-file merge is preserved on reload β€” all configured files are re-read and re-merged on change (previously only the last file was watched and the merge was discarded).
  • Candidate-validate-swap β€” an invalid reload is rejected and the last-known-good config is retained; Get* never serves an invalid or half-merged config (fail-closed).
  • Single-file containers are watched (previously only multi-file ones were).
  • Configurable debounce via config.WithReloadDebounce(d) (default 250 ms).
  • Container.Close() to stop the watcher and release its OS resources.

Learning about FAILED reloads

Under the previous contract, an observer received a chan error and could be handed an error on a failed reload. With the returned-error contract, observers return errors β€” there is no channel to push a reload-time error to them, and observers are only ever called for a reload that succeeded.

To react to a rejected reload (a fail-closed parse/merge error, a missing primary file, or a schema-validation failure β€” all of which retain last-known-good), register an OnReloadError callback. This is the supported replacement for the old "notify observers of the error" behaviour:

container.OnReloadError(func(err error) {
    log.Warn("config reload rejected; keeping last-known-good", "error", err)
})

OnReloadError is additive to the container's own ERROR log, never fires for a successful reload (observers handle that), and follows the same race-safe, deadlock-free locking discipline as observer notification. See Reacting to rejected reloads.