diff --git a/backend/cmd/map/main.go b/backend/cmd/map/main.go index 3279a89..e932103 100644 --- a/backend/cmd/map/main.go +++ b/backend/cmd/map/main.go @@ -234,6 +234,8 @@ func mapCTFService() { if err != nil { log.Fatal().Msgf("Failed to initialize settings: %v", err) } + // Cache platform settings in Redis (read-heavy, write-invalidate). + settingsMgr.SetCache(redis.Client) if err := settingsMgr.Initialization(flagParams.ConfigValues.Map.UUID); err != nil { log.Fatal().Msgf("Failed to initialize default settings: %v", err) } diff --git a/backend/go.mod b/backend/go.mod index b48bf49..280eeca 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/crypto v0.53.0 + golang.org/x/sync v0.21.0 golang.org/x/text v0.38.0 gorm.io/gorm v1.31.1 ) @@ -36,7 +37,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect ) diff --git a/backend/pkg/settings/cache.go b/backend/pkg/settings/cache.go new file mode 100644 index 0000000..b401b38 --- /dev/null +++ b/backend/pkg/settings/cache.go @@ -0,0 +1,165 @@ +package settings + +import ( + "context" + "encoding/json" + "sync" + "time" + + redis "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" +) + +const ( + // settingsCacheL1TTL bounds how long an in-process copy is reused, coalescing + // repeated reads within a single request burst (e.g. the gameboard handler + // reads many settings at once). + settingsCacheL1TTL = 2 * time.Second + // settingsCacheL2TTL is the Redis TTL; a safety net since writes invalidate. + settingsCacheL2TTL = 60 * time.Second + settingsCacheKeyPrefix = "mapctf:settings:" +) + +// settingsCacheEntry holds both views of a UUID's settings: the ordered slice +// (for GetAll) and a name lookup map (for Get), built once when loaded. +type settingsCacheEntry struct { + slice []PlatformSetting + byName map[string]PlatformSetting + fetchedAt time.Time +} + +func newEntry(slice []PlatformSetting) *settingsCacheEntry { + byName := make(map[string]PlatformSetting, len(slice)) + for _, s := range slice { + byName[s.Name] = s + } + return &settingsCacheEntry{slice: slice, byName: byName, fetchedAt: time.Now()} +} + +func (e *settingsCacheEntry) fresh() bool { + return time.Since(e.fetchedAt) < settingsCacheL1TTL +} + +// settingsCache is a per-UUID, two-layer (in-process L1 + Redis L2) cache-aside +// for platform settings. Settings are read on most requests but change only via +// admin writes, so reads hit the cache and writes invalidate it. +type settingsCache struct { + rdb *redis.Client + l1 sync.Map // uuid -> *settingsCacheEntry + sf singleflight.Group +} + +func settingsCacheKey(uuid string) string { return settingsCacheKeyPrefix + uuid } + +// SetCache enables Redis-backed caching for this manager. Passing nil disables +// it; reads then use only the in-process L1 over the DB. +func (m *SettingsManager) SetCache(rdb *redis.Client) { + if rdb == nil { + m.cache = &settingsCache{} + return + } + m.cache = &settingsCache{rdb: rdb} +} + +func (m *settingsCache) l1Get(uuid string) (*settingsCacheEntry, bool) { + if m == nil { + return nil, false + } + v, ok := m.l1.Load(uuid) + if !ok { + return nil, false + } + e := v.(*settingsCacheEntry) + if !e.fresh() { + return nil, false + } + return e, true +} + +func (m *settingsCache) l1Put(uuid string, e *settingsCacheEntry) { + if m == nil { + return + } + m.l1.Store(uuid, e) +} + +func (m *settingsCache) l1Delete(uuid string) { + if m == nil { + return + } + m.l1.Delete(uuid) +} + +// loadCached returns the cached entry for a UUID, filling L1/Redis from the DB +// on miss. Cache errors never fail the read: any cache problem falls back to DB. +func (m *SettingsManager) loadCached(uuid string) (*settingsCacheEntry, error) { + if e, ok := m.cacheL1Get(uuid); ok { + return e, nil + } + if m.cache == nil { + // Caching disabled: uncached DB read. + return m.loadFromDB(uuid) + } + v, err, _ := m.cache.sf.Do(settingsCacheKey(uuid), func() (any, error) { + if e, ok := m.cacheL1Get(uuid); ok { + return e, nil + } + var slice []PlatformSetting + // L2: Redis (best-effort; a miss or error falls through to the DB). + if m.cache.rdb != nil { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + raw, rerr := m.cache.rdb.Get(ctx, settingsCacheKey(uuid)).Bytes() + cancel() + if rerr == nil { + if json.Unmarshal(raw, &slice) == nil { + e := newEntry(slice) + m.cacheL1Put(uuid, e) + return e, nil + } + } + } + // DB load, then populate L2 (if enabled) and L1. + var derr error + slice, derr = m.getAllFromDB(uuid) + if derr != nil { + return nil, derr + } + if m.cache.rdb != nil { + if data, merr := json.Marshal(slice); merr == nil { + m.cache.rdb.Set(context.Background(), settingsCacheKey(uuid), data, settingsCacheL2TTL) + } + } + e := newEntry(slice) + m.cacheL1Put(uuid, e) + return e, nil + }) + if err != nil { + return nil, err + } + return v.(*settingsCacheEntry), nil +} + +func (m *SettingsManager) cacheL1Get(uuid string) (*settingsCacheEntry, bool) { + if m.cache == nil { + return nil, false + } + return m.cache.l1Get(uuid) +} + +func (m *SettingsManager) cacheL1Put(uuid string, e *settingsCacheEntry) { + if m.cache == nil { + return + } + m.cache.l1Put(uuid, e) +} + +// invalidate drops the cached settings for a UUID after a write. +func (m *SettingsManager) invalidate(uuid string) { + if m.cache == nil { + return + } + m.cache.l1Delete(uuid) + if m.cache.rdb != nil { + m.cache.rdb.Del(context.Background(), settingsCacheKey(uuid)) + } +} diff --git a/backend/pkg/settings/settings.go b/backend/pkg/settings/settings.go index 66b8ce5..d876851 100644 --- a/backend/pkg/settings/settings.go +++ b/backend/pkg/settings/settings.go @@ -140,6 +140,7 @@ type SettingLog struct { type SettingsManager struct { DB *gorm.DB Service string + cache *settingsCache } // CreateSettingsManager to initialize the settings struct and tables @@ -242,6 +243,7 @@ func (m *SettingsManager) Create(setting PlatformSetting) error { if err := m.LogEvent(setting.ID, EventCreate, m.Service, setting.UUID); err != nil { return fmt.Errorf("logEvent PlatformSetting %w", err) } + m.invalidate(setting.UUID) return nil } @@ -253,16 +255,30 @@ func (m *SettingsManager) Exists(name string, uuid string) bool { } // Get setting by name including service settings +// Get returns a setting by name, served from the settings cache when enabled. +// It preserves gorm.ErrRecordNotFound semantics so existing callers are unaffected. func (m *SettingsManager) Get(name string, uuid string) (PlatformSetting, error) { - var setting PlatformSetting - if err := m.DB.Where("name = ? AND uuid = ?", name, uuid).First(&setting).Error; err != nil { - return setting, err + entry, err := m.loadCached(uuid) + if err != nil { + return PlatformSetting{}, err + } + if setting, ok := entry.byName[name]; ok { + return setting, nil } - return setting, nil + return PlatformSetting{}, gorm.ErrRecordNotFound } -// GetAll settings for a given uuid +// GetAll settings for a given uuid, served from the settings cache when enabled. func (m *SettingsManager) GetAll(uuid string) ([]PlatformSetting, error) { + entry, err := m.loadCached(uuid) + if err != nil { + return nil, err + } + return entry.slice, nil +} + +// getAllFromDB is the uncached DB read used to populate the cache. +func (m *SettingsManager) getAllFromDB(uuid string) ([]PlatformSetting, error) { var settings []PlatformSetting if err := m.DB.Where("uuid = ?", uuid).Find(&settings).Error; err != nil { return nil, err @@ -270,6 +286,15 @@ func (m *SettingsManager) GetAll(uuid string) ([]PlatformSetting, error) { return settings, nil } +// loadFromDB builds a cache entry straight from the database. +func (m *SettingsManager) loadFromDB(uuid string) (*settingsCacheEntry, error) { + slice, err := m.getAllFromDB(uuid) + if err != nil { + return nil, err + } + return newEntry(slice), nil +} + // ExistsGet checks if setting exists and returns the setting func (m *SettingsManager) ExistsGet(name string, uuid string) (bool, PlatformSetting) { setting, err := m.Get(name, uuid) @@ -330,6 +355,7 @@ func (m *SettingsManager) Save(setting PlatformSetting, username string) error { if err := m.LogEvent(setting.ID, EventUpdate, username, setting.UUID); err != nil { return fmt.Errorf("logEvent PlatformSetting %w", err) } + m.invalidate(setting.UUID) return nil } @@ -339,6 +365,7 @@ func (m *SettingsManager) Change(name, valueType string, uuid string, value any, if err != nil { return fmt.Errorf("failed to get setting: %w", err) } + defer m.invalidate(uuid) column, err := valueColumnByType(valueType) if err != nil { return fmt.Errorf("failed to resolve setting value column: %w", err) diff --git a/backend/pkg/settings/settings_test.go b/backend/pkg/settings/settings_test.go index 5477974..36d5e11 100644 --- a/backend/pkg/settings/settings_test.go +++ b/backend/pkg/settings/settings_test.go @@ -576,3 +576,43 @@ func TestSaveFailsWhenLogInsertFails(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "logEvent PlatformSetting") } + +func TestSettingsCacheServesReadsAndInvalidatesOnWrite(t *testing.T) { + // L1-only cache (no Redis) keeps the test network-free; the Redis L2 path + // uses the same load/invalidate logic and is exercised in environments with + // a reachable Redis. + db, sqlDB := newTestDB(t) + defer sqlDB.Close() + m, err := CreateSettingsManager(db, "test-service") + require.NoError(t, err) + m.SetCache(nil) + require.NoError(t, m.Initialization("tenant-a")) + + // Seed a value through the manager (write path invalidates the cache). + require.NoError(t, m.SetLanguage("es", "alice", "tenant-a")) + got, err := m.GetLanguage("tenant-a") + require.NoError(t, err) + require.Equal(t, "es", got) // miss -> DB -> populate cache + + // Mutate the DB directly, bypassing the manager so no invalidation happens. + require.NoError(t, db.Model(&PlatformSetting{}). + Where("name = ? AND uuid = ?", Language, "tenant-a"). + Update("value_string", "fr").Error) + + // Cached read still returns the stale value, proving the cache served it. + got, err = m.GetLanguage("tenant-a") + require.NoError(t, err) + require.Equal(t, "es", got, "cached read should not reflect a bypassing DB write") + + // After invalidation the next read picks up the DB value. + m.invalidate("tenant-a") + got, err = m.GetLanguage("tenant-a") + require.NoError(t, err) + require.Equal(t, "fr", got) + + // A manager write invalidates, so the new value is visible immediately. + require.NoError(t, m.SetLanguage("de", "alice", "tenant-a")) + got, err = m.GetLanguage("tenant-a") + require.NoError(t, err) + require.Equal(t, "de", got) +}