core: Retry dynamic config load if config unchanged

(see discussion in #4603)
This commit is contained in:
Matthew Holt 2022-03-03 21:41:51 -07:00
parent a6199cf814
commit a72acd21b0
No known key found for this signature in database
GPG key ID: 2A349DD577D586A5
2 changed files with 34 additions and 15 deletions

View file

@ -938,7 +938,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
forceReload := r.Header.Get("Cache-Control") == "must-revalidate" forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
err := changeConfig(r.Method, r.URL.Path, body, forceReload) err := changeConfig(r.Method, r.URL.Path, body, forceReload)
if err != nil { if err != nil && !errors.Is(err, errSameConfig) {
return err return err
} }

View file

@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -110,14 +111,19 @@ func Load(cfgJSON []byte, forceReload bool) error {
} }
}() }()
return changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, forceReload) err := changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, forceReload)
if errors.Is(err, errSameConfig) {
err = nil // not really an error
}
return err
} }
// changeConfig changes the current config (rawCfg) according to the // changeConfig changes the current config (rawCfg) according to the
// method, traversed via the given path, and uses the given input as // method, traversed via the given path, and uses the given input as
// the new value (if applicable; i.e. "DELETE" doesn't have an input). // the new value (if applicable; i.e. "DELETE" doesn't have an input).
// If the resulting config is the same as the previous, no reload will // If the resulting config is the same as the previous, no reload will
// occur unless forceReload is true. This function is safe for // occur unless forceReload is true. If the config is unchanged and not
// forcefully reloaded, then errConfigUnchanged This function is safe for
// concurrent use. // concurrent use.
func changeConfig(method, path string, input []byte, forceReload bool) error { func changeConfig(method, path string, input []byte, forceReload bool) error {
switch method { switch method {
@ -149,7 +155,7 @@ func changeConfig(method, path string, input []byte, forceReload bool) error {
// if nothing changed, no need to do a whole reload unless the client forces it // if nothing changed, no need to do a whole reload unless the client forces it
if !forceReload && bytes.Equal(rawCfgJSON, newCfg) { if !forceReload && bytes.Equal(rawCfgJSON, newCfg) {
Log().Info("config is unchanged") Log().Info("config is unchanged")
return nil return errSameConfig
} }
// find any IDs in this config and index them // find any IDs in this config and index them
@ -492,39 +498,47 @@ func finishSettingUp(ctx Context, cfg *Config) error {
zap.String("module", val.(Module).CaddyModule().ID.Name()), zap.String("module", val.(Module).CaddyModule().ID.Name()),
zap.Int("load_delay", int(cfg.Admin.Config.LoadDelay))) zap.Int("load_delay", int(cfg.Admin.Config.LoadDelay)))
runLoadedConfig := func(config []byte) { runLoadedConfig := func(config []byte) error {
logger.Info("applying dynamically-loaded config") logger.Info("applying dynamically-loaded config")
err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, false) err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, false)
if err == nil { if errors.Is(err, errSameConfig) {
logger.Info("successfully applied dynamically-loaded config") return err
} else {
logger.Error("failed to run dynamically-loaded config", zap.Error(err))
} }
if err != nil {
logger.Error("failed to run dynamically-loaded config", zap.Error(err))
return err
}
logger.Info("successfully applied dynamically-loaded config")
return nil
} }
if cfg.Admin.Config.LoadDelay > 0 { if cfg.Admin.Config.LoadDelay > 0 {
go func() { go func() {
// the loop is here only to iterate if there is an error or a no-op config load, // the loop is here to iterate ONLY if there is an error, a no-op config load,
// in which case we simply wait the delay and try again // or an unchanged config; in which case we simply wait the delay and try again
for { for {
timer := time.NewTimer(time.Duration(cfg.Admin.Config.LoadDelay)) timer := time.NewTimer(time.Duration(cfg.Admin.Config.LoadDelay))
select { select {
case <-timer.C: case <-timer.C:
loadedConfig, err := val.(ConfigLoader).LoadConfig(ctx) loadedConfig, err := val.(ConfigLoader).LoadConfig(ctx)
if err != nil { if err != nil {
Log().Error("failed loading dynamic config; will retry", zap.Error(err)) logger.Error("failed loading dynamic config; will retry", zap.Error(err))
continue continue
} }
if loadedConfig == nil { if loadedConfig == nil {
Log().Info("dynamically-loaded config was nil; will retry") logger.Info("dynamically-loaded config was nil; will retry")
continue
}
err = runLoadedConfig(loadedConfig)
if errors.Is(err, errSameConfig) {
logger.Info("dynamically-loaded config was unchanged; will retry")
continue continue
} }
runLoadedConfig(loadedConfig)
case <-ctx.Done(): case <-ctx.Done():
if !timer.Stop() { if !timer.Stop() {
<-timer.C <-timer.C
} }
Log().Info("stopping dynamic config loading") logger.Info("stopping dynamic config loading")
} }
break break
} }
@ -798,5 +812,10 @@ var (
rawCfgIndex map[string]string rawCfgIndex map[string]string
) )
// errSameConfig is returned if the new config is the same
// as the old one. This isn't usually an actual, actionable
// error; it's mostly a sentinel value.
var errSameConfig = errors.New("config is unchanged")
// ImportPath is the package import path for Caddy core. // ImportPath is the package import path for Caddy core.
const ImportPath = "github.com/caddyserver/caddy/v2" const ImportPath = "github.com/caddyserver/caddy/v2"