7fbc2a03d3
Backend CI / Backend (push) Successful in 13m20s
os.WriteFile truncates then writes, so inotify can deliver back-to-back MODIFY events while the file is momentarily empty. Reading mid-sequence made TestStoreReloadsWhenLocalOverrideIsCreated and TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML flake on Linux CI by firing onReload with a blank config before the write settled. Coalesce updates over a 50ms quiet window before reloading, and skip onReload when the diff is empty so subscribers only see real changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
6.8 KiB
Go
330 lines
6.8 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
|
runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env"
|
|
runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file"
|
|
)
|
|
|
|
type Store struct {
|
|
path string
|
|
|
|
mu sync.RWMutex
|
|
cfg *Config
|
|
files []string
|
|
runtime runtime.Config
|
|
watchedFiles map[string]fileSnapshot
|
|
updates chan struct{}
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type ReloadEvent struct {
|
|
Previous *Config
|
|
Current *Config
|
|
Files []string
|
|
Changes []FieldChange
|
|
}
|
|
|
|
func NewStore(configPath string) (*Store, error) {
|
|
cfg, files, err := loadWithFiles(configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
store := &Store{
|
|
path: configPath,
|
|
cfg: cfg,
|
|
files: normalizeConfigFiles(files),
|
|
watchedFiles: snapshotFiles(nil),
|
|
updates: make(chan struct{}, 1),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
store.watchedFiles = snapshotFiles(store.watchFiles())
|
|
store.runtime = store.newRuntimeConfig()
|
|
return store, nil
|
|
}
|
|
|
|
func NewStaticStore(cfg *Config) (*Store, error) {
|
|
if cfg == nil {
|
|
return nil, errors.New("config is nil")
|
|
}
|
|
return &Store{cfg: cfg}, nil
|
|
}
|
|
|
|
func (s *Store) Current() *Config {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg
|
|
}
|
|
|
|
func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error {
|
|
if s == nil {
|
|
return errors.New("config store is nil")
|
|
}
|
|
if s.runtime == nil {
|
|
s.runtime = s.newRuntimeConfig()
|
|
}
|
|
|
|
if err := s.runtime.Load(); err != nil {
|
|
return fmt.Errorf("load config runtime source: %w", err)
|
|
}
|
|
if err := s.runtime.Watch("", func(string, runtime.Value) {
|
|
s.notifyReload()
|
|
}); err != nil {
|
|
_ = s.runtime.Close()
|
|
return fmt.Errorf("watch config runtime source: %w", err)
|
|
}
|
|
|
|
if logger != nil {
|
|
logger.Info("config hot reload watcher started", zap.Strings("files", s.files))
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-s.ctx.Done():
|
|
return nil
|
|
case <-s.updates:
|
|
if !s.coalesceUpdates(ctx, reloadDebounce) {
|
|
return nil
|
|
}
|
|
s.reload(logger, onReload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// reloadDebounce coalesces fsnotify events from a single write. os.WriteFile
|
|
// opens with O_CREATE|O_TRUNC then writes, so inotify can deliver back-to-back
|
|
// MODIFY events with the file momentarily empty. Reading mid-sequence yields a
|
|
// blank config; waiting for a brief quiet period lets the write settle.
|
|
const reloadDebounce = 50 * time.Millisecond
|
|
|
|
func (s *Store) coalesceUpdates(ctx context.Context, window time.Duration) bool {
|
|
timer := time.NewTimer(window)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-s.ctx.Done():
|
|
return false
|
|
case <-s.updates:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
timer.Reset(window)
|
|
case <-timer.C:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) Close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
if s.runtime != nil {
|
|
_ = s.runtime.Close()
|
|
}
|
|
}
|
|
|
|
func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) {
|
|
next, files, err := loadWithFiles(s.path)
|
|
if err != nil {
|
|
if logger != nil {
|
|
logger.Warn("config reload rejected", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
files = normalizeConfigFiles(files)
|
|
s.mu.Lock()
|
|
previous := s.cfg
|
|
s.cfg = next
|
|
s.files = files
|
|
s.watchedFiles = snapshotFiles(s.watchFiles())
|
|
s.mu.Unlock()
|
|
|
|
changes := Diff(previous, next)
|
|
if len(changes) == 0 {
|
|
return
|
|
}
|
|
if logger != nil {
|
|
logger.Info("config reloaded",
|
|
zap.Strings("files", files),
|
|
zap.Strings("hot_changes", ChangePaths(changes, true)),
|
|
zap.Strings("restart_required_changes", ChangePaths(changes, false)),
|
|
)
|
|
}
|
|
if onReload != nil {
|
|
onReload(ReloadEvent{
|
|
Previous: previous,
|
|
Current: next,
|
|
Files: files,
|
|
Changes: changes,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Store) newRuntimeConfig() runtime.Config {
|
|
return runtime.New(runtime.WithSource(
|
|
runtimefile.NewSource(s.watchRoot(), s.watchNames()...),
|
|
runtimeenv.NewSource(),
|
|
))
|
|
}
|
|
|
|
func (s *Store) watchRoot() string {
|
|
for _, candidate := range candidateConfigPaths(s.path, false) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
return filepath.Dir(candidate)
|
|
}
|
|
return filepath.Dir(s.path)
|
|
}
|
|
|
|
func (s *Store) notifyReload() {
|
|
if !s.consumeFileChange() {
|
|
return
|
|
}
|
|
select {
|
|
case s.updates <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (s *Store) consumeFileChange() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
next := snapshotFiles(s.watchFiles())
|
|
if len(s.watchedFiles) == 0 {
|
|
s.watchedFiles = next
|
|
return true
|
|
}
|
|
changed := !fileSnapshotsEqual(s.watchedFiles, next)
|
|
if changed {
|
|
s.watchedFiles = next
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Store) watchNames() []string {
|
|
names := make([]string, 0, 4)
|
|
seen := make(map[string]struct{})
|
|
for _, local := range []bool{false, true} {
|
|
for _, candidate := range candidateConfigPaths(s.path, local) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
name := filepath.Base(candidate)
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (s *Store) watchFiles() []string {
|
|
files := make([]string, 0, 4)
|
|
seen := make(map[string]struct{})
|
|
for _, local := range []bool{false, true} {
|
|
for _, candidate := range candidateConfigPaths(s.path, local) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
abs, err := filepath.Abs(candidate)
|
|
if err == nil {
|
|
candidate = abs
|
|
}
|
|
candidate = filepath.Clean(candidate)
|
|
if _, ok := seen[candidate]; ok {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
files = append(files, candidate)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func normalizeConfigFiles(files []string) []string {
|
|
seen := make(map[string]struct{}, len(files))
|
|
normalized := make([]string, 0, len(files))
|
|
for _, file := range files {
|
|
if file == "" {
|
|
continue
|
|
}
|
|
abs, err := filepath.Abs(file)
|
|
if err == nil {
|
|
file = abs
|
|
}
|
|
file = filepath.Clean(file)
|
|
if _, ok := seen[file]; ok {
|
|
continue
|
|
}
|
|
seen[file] = struct{}{}
|
|
normalized = append(normalized, file)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
type fileSnapshot struct {
|
|
size int64
|
|
modTime time.Time
|
|
exists bool
|
|
}
|
|
|
|
func snapshotFiles(files []string) map[string]fileSnapshot {
|
|
snapshots := make(map[string]fileSnapshot, len(files))
|
|
for _, file := range normalizeConfigFiles(files) {
|
|
info, err := os.Stat(file)
|
|
if err != nil {
|
|
snapshots[file] = fileSnapshot{}
|
|
continue
|
|
}
|
|
snapshots[file] = fileSnapshot{
|
|
size: info.Size(),
|
|
modTime: info.ModTime(),
|
|
exists: true,
|
|
}
|
|
}
|
|
return snapshots
|
|
}
|
|
|
|
func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for key, left := range a {
|
|
right, ok := b[key]
|
|
if !ok || left != right {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|