-
Notifications
You must be signed in to change notification settings - Fork 373
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support for file based live configuration reloading
A new optional flag (long: --watch-dir, short: -w) has been added. When present any files with a ".env" suffix will be loaded into the environment before the *GlobalConfiguration is created, otherwise existing behavior is preserved. In addition when the watch-dir flag is present a goroutine will be started in serve_cmd.go and begin blocking on a call to (*Reloader).Watch with a callback function that accepts a *conf.GlobalConfiguration object. Each time this function is called we create a new API object and store it within our AtomicHandler, previously given as the root handler to the *http.Server. The Reloader uses some simple heuristics to deal with a few edge cases, an overview: - At most 1 configuration reload may happen per 10 seconds with a +-1s margin of error. - After a file within -watch-dir has changed the 10 second grace period begins. After that it will reload the config. - Config reloads first sort each file by name then processes them in sequence. - Directories within watch-dir are ignored during config reloading. - Implementation quirk: directory changes can trigger a config reload, as I don't stat fsnotify events. This and similar superfulous reloads could be easily fixed by storing a snapshot of os.Environ() after successful reloads to compare with the latest via slices.Equal() before reloading. - Files that do not end with a .env suffix are ignored. - It handles the removal or renaming of the -watch-dir during runtime, but an error message will be printed every 10 seconds as long as it's missing. - The config file passed with -c is only loaded once. Live reloads only read the config dir. Meaning it would be possible to create a config dir change that results in a new final configuration on the next reload due to the persistence of os.Environ().
- Loading branch information
Chris Stockton
committed
Sep 10, 2024
1 parent
428d0f0
commit 03ce1cf
Showing
11 changed files
with
634 additions
and
81 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
// Package reloader provides support for live configuration reloading. | ||
package reloader | ||
|
||
import ( | ||
"context" | ||
"log" | ||
"strings" | ||
"time" | ||
|
||
"github.com/fsnotify/fsnotify" | ||
"github.com/sirupsen/logrus" | ||
"github.com/supabase/auth/internal/conf" | ||
) | ||
|
||
const ( | ||
// reloadInterval is the interval between configuration reloading. At most | ||
// one configuration change may be made between this duration. | ||
reloadInterval = time.Second * 10 | ||
|
||
// tickerInterval is the maximum latency between configuration reloads. | ||
tickerInterval = reloadInterval / 10 | ||
) | ||
|
||
type ConfigFunc func(*conf.GlobalConfiguration) | ||
|
||
type Reloader struct { | ||
watchDir string | ||
reloadIval time.Duration | ||
tickerIval time.Duration | ||
} | ||
|
||
func NewReloader(watchDir string) *Reloader { | ||
return &Reloader{ | ||
watchDir: watchDir, | ||
reloadIval: reloadInterval, | ||
tickerIval: tickerInterval, | ||
} | ||
} | ||
|
||
// reload attempts to create a new *conf.GlobalConfiguration after loading the | ||
// currently configured watchDir. | ||
func (rl *Reloader) reload() (*conf.GlobalConfiguration, error) { | ||
if err := conf.LoadDirectory(rl.watchDir); err != nil { | ||
return nil, err | ||
} | ||
|
||
cfg, err := conf.LoadGlobalFromEnv() | ||
if err != nil { | ||
return nil, err | ||
} | ||
return cfg, nil | ||
} | ||
|
||
// reloadCheckAt checks if reloadConfig should be called, returns true if config | ||
// should be reloaded or false otherwise. | ||
func (rl *Reloader) reloadCheckAt(at, lastUpdate time.Time) bool { | ||
if lastUpdate.IsZero() { | ||
return false // no pending updates | ||
} | ||
if at.Sub(lastUpdate) < rl.reloadIval { | ||
return false // waiting for reload interval | ||
} | ||
|
||
// Update is pending. | ||
return true | ||
} | ||
|
||
func (rl *Reloader) Watch(ctx context.Context, fn ConfigFunc) error { | ||
wr, err := fsnotify.NewWatcher() | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
defer wr.Close() | ||
|
||
tr := time.NewTicker(rl.tickerIval) | ||
defer tr.Stop() | ||
|
||
// Ignore errors, if watch dir doesn't exist we can add it later. | ||
if err := wr.Add(rl.watchDir); err != nil { | ||
logrus.WithError(err).Error("watch dir failed") | ||
} | ||
|
||
var lastUpdate time.Time | ||
for { | ||
select { | ||
case <-ctx.Done(): | ||
return ctx.Err() | ||
|
||
case <-tr.C: | ||
// This is a simple way to solve watch dir being added later or | ||
// being moved and then recreated. I've tested all of these basic | ||
// scenarios and wr.WatchList() does not grow which aligns with | ||
// the documented behavior. | ||
if err := wr.Add(rl.watchDir); err != nil { | ||
logrus.WithError(err).Error("watch dir failed") | ||
} | ||
|
||
// Check to see if the config is ready to be relaoded. | ||
if !rl.reloadCheckAt(time.Now(), lastUpdate) { | ||
continue | ||
} | ||
|
||
// Reset the last update time before we try to reload the config. | ||
lastUpdate = time.Time{} | ||
|
||
cfg, err := rl.reload() | ||
if err != nil { | ||
logrus.WithError(err).Error("config reload failed") | ||
continue | ||
} | ||
|
||
// Call the callback function with the latest cfg. | ||
fn(cfg) | ||
|
||
case evt, ok := <-wr.Events: | ||
if !ok { | ||
logrus.WithError(err).Error("fsnotify has exited") | ||
return nil | ||
} | ||
|
||
// We only read files ending in .env | ||
if !strings.HasSuffix(evt.Name, ".env") { | ||
continue | ||
} | ||
|
||
switch { | ||
case evt.Op.Has(fsnotify.Create), | ||
evt.Op.Has(fsnotify.Remove), | ||
evt.Op.Has(fsnotify.Rename), | ||
evt.Op.Has(fsnotify.Write): | ||
lastUpdate = time.Now() | ||
} | ||
case err, ok := <-wr.Errors: | ||
if !ok { | ||
logrus.Error("fsnotify has exited") | ||
return nil | ||
} | ||
logrus.WithError(err).Error("fsnotify has reported an error") | ||
} | ||
} | ||
} |
Oops, something went wrong.