Files
geo/server/internal/shared/config/store.go
T
root 7fbc2a03d3
Backend CI / Backend (push) Successful in 13m20s
fix(config): debounce fsnotify reload events to avoid mid-write reads
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>
2026-05-01 21:55:49 +08:00

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
}