Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/cmd/map/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)

Expand Down
165 changes: 165 additions & 0 deletions backend/pkg/settings/cache.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
37 changes: 32 additions & 5 deletions backend/pkg/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -253,23 +255,46 @@ 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
}
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)
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions backend/pkg/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading