diff --git a/backend/cmd/map/handlers/handlers.go b/backend/cmd/map/handlers/handlers.go index 3c0b67d..0d6bfb4 100644 --- a/backend/cmd/map/handlers/handlers.go +++ b/backend/cmd/map/handlers/handlers.go @@ -35,6 +35,7 @@ type HandlersMap struct { ServiceName string DB *gorm.DB RedisCache *cache.RedisManager + feeds *respCache Teams *teams.TeamManager Users *users.UserManager Challenges *challenges.ChallengeManager diff --git a/backend/cmd/map/handlers/json.go b/backend/cmd/map/handlers/json.go index d89de1a..db41e2c 100644 --- a/backend/cmd/map/handlers/json.go +++ b/backend/cmd/map/handlers/json.go @@ -89,21 +89,20 @@ func (h *HandlersMap) JSONActivityHandler(w http.ResponseWriter, r *http.Request if !ok { return } - // Get all activity logs for the given UUID - activityLogs, err := h.Logs.AllActivity(uuid) - if err != nil { - log.Err(err).Msg(h.T(r.Context())("feed.error_activity")) - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_activity")}) - return - } - visibleActivityLogs := make([]logs.ActivityLog, 0, len(activityLogs)) - for _, activityLog := range activityLogs { - if activityLog.Visible { - visibleActivityLogs = append(visibleActivityLogs, activityLog) + h.serveCachedJSON(w, feedKey("activity", uuid), func() (int, []byte) { + activityLogs, err := h.Logs.AllActivity(uuid) + if err != nil { + log.Err(err).Msg(h.T(r.Context())("feed.error_activity")) + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_activity")}) } - } - // Send response - HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, visibleActivityLogs) + visibleActivityLogs := make([]logs.ActivityLog, 0, len(activityLogs)) + for _, activityLog := range activityLogs { + if activityLog.Visible { + visibleActivityLogs = append(visibleActivityLogs, activityLog) + } + } + return http.StatusOK, marshalJSON(visibleActivityLogs) + }) } // JSONGameClockHandler returns server-authoritative game clock state. @@ -184,52 +183,49 @@ func (h *HandlersMap) JSONTeamsHandler(w http.ResponseWriter, r *http.Request) { if !ok { return } - // Get all teams for the given UUID - allTeams, err := h.Teams.GetAll(uuid) - if err != nil { - log.Err(err).Msg(h.T(r.Context())("feed.error_teams")) - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_teams")}) - return - } - - showTeamMembers, err := h.Settings.GetGameboardShowTeamMembers(uuid) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - log.Err(err).Msg("error retrieving gameboard_show_team_members setting") - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_team_settings")}) - return - } - - membersByTeamID := make(map[uint][]string) - if showTeamMembers { - allUsers, err := h.Users.GetAll(uuid) + h.serveCachedJSON(w, feedKey("teams", uuid), func() (int, []byte) { + allTeams, err := h.Teams.GetAll(uuid) if err != nil { - log.Err(err).Msg("error retrieving users for teams JSON") - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_team_members")}) - return + log.Err(err).Msg(h.T(r.Context())("feed.error_teams")) + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_teams")}) } - for _, user := range allUsers { - if user.TeamID == 0 || !user.Active || user.Service { - continue + + showTeamMembers, err := h.Settings.GetGameboardShowTeamMembers(uuid) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + log.Err(err).Msg("error retrieving gameboard_show_team_members setting") + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_team_settings")}) + } + + membersByTeamID := make(map[uint][]string) + if showTeamMembers { + allUsers, err := h.Users.GetAll(uuid) + if err != nil { + log.Err(err).Msg("error retrieving users for teams JSON") + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_team_members")}) + } + for _, user := range allUsers { + if user.TeamID == 0 || !user.Active || user.Service { + continue + } + membersByTeamID[user.TeamID] = append(membersByTeamID[user.TeamID], user.Username) } - membersByTeamID[user.TeamID] = append(membersByTeamID[user.TeamID], user.Username) } - } - filteredTeams := make([]JSONTeamResponse, 0, len(allTeams)) - for _, team := range allTeams { - if !team.Active || !team.Visible { - continue + filteredTeams := make([]JSONTeamResponse, 0, len(allTeams)) + for _, team := range allTeams { + if !team.Active || !team.Visible { + continue + } + filteredTeams = append(filteredTeams, JSONTeamResponse{ + Name: team.Name, + Logo: team.Logo, + Points: team.Points, + LastScore: teamLastScorePtr(team.LastScore), + TeamMembers: membersByTeamID[team.ID], + }) } - filteredTeams = append(filteredTeams, JSONTeamResponse{ - Name: team.Name, - Logo: team.Logo, - Points: team.Points, - LastScore: teamLastScorePtr(team.LastScore), - TeamMembers: membersByTeamID[team.ID], - }) - } - // Send response - HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, filteredTeams) + return http.StatusOK, marshalJSON(filteredTeams) + }) } func teamLastScorePtr(lastScore time.Time) *time.Time { @@ -249,15 +245,14 @@ func (h *HandlersMap) JSONChallengesHandler(w http.ResponseWriter, r *http.Reque if !ok { return } - // Get all active challenges for the given UUID - challenges, err := h.Challenges.GetActive(uuid) - if err != nil { - log.Err(err).Msg(h.T(r.Context())("feed.error_challenges")) - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_challenges")}) - return - } - // Send response - HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, challenges) + h.serveCachedJSON(w, feedKey("challenges", uuid), func() (int, []byte) { + challenges, err := h.Challenges.GetActive(uuid) + if err != nil { + log.Err(err).Msg(h.T(r.Context())("feed.error_challenges")) + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_challenges")}) + } + return http.StatusOK, marshalJSON(challenges) + }) } // JSONCountriesHandler returns live gameboard country data for all countries, @@ -545,17 +540,16 @@ func (h *HandlersMap) JSONChatHandler(w http.ResponseWriter, r *http.Request) { if h.Config.DebugHTTP.Enabled { DebugHTTPDump(h.DebugHTTP, r, h.Config.DebugHTTP.ShowBody) } - _, ok := h.validatedJSONUUID(w, r) + uuid, ok := h.validatedJSONUUID(w, r) if !ok { return } - // Get all chat entries for the given UUID - chatEntries, err := h.Chat.GetVisible() - if err != nil { - log.Err(err).Msg(h.T(r.Context())("feed.error_chat")) - HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("feed.error_chat")}) - return - } - // Send response - HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, chatEntries) + h.serveCachedJSON(w, feedKey("chat", uuid), func() (int, []byte) { + chatEntries, err := h.Chat.GetVisible() + if err != nil { + log.Err(err).Msg(h.T(r.Context())("feed.error_chat")) + return http.StatusInternalServerError, marshalJSON(MapErrorResponse{Error: h.T(r.Context())("feed.error_chat")}) + } + return http.StatusOK, marshalJSON(chatEntries) + }) } diff --git a/backend/cmd/map/handlers/json_test.go b/backend/cmd/map/handlers/json_test.go index 5d8755d..e05e927 100644 --- a/backend/cmd/map/handlers/json_test.go +++ b/backend/cmd/map/handlers/json_test.go @@ -846,3 +846,48 @@ func TestJSONWorldDominationHandlerReturnsCurrentTeamMetrics(t *testing.T) { require.Equal(t, 67, resp.WinRatePct) require.Equal(t, 33, resp.LoseRatePct) } + +func TestJSONChallengesFeedCacheServesL1AndInvalidates(t *testing.T) { + db := newJSONTestDB(t) + challengeManager, err := challenges.CreateChallengeManager(db) + require.NoError(t, err) + require.NoError(t, challengeManager.Create(challenges.Challenge{ + Title: "Cached challenge", + Country: "ES", + Active: true, + Points: 100, + UUID: jsonTestUUID, + })) + + handler := CreateHandlersMap( + WithConfig(config.MapCTFConfiguration{Map: config.ConfigurationMap{UUID: jsonTestUUID}}), + WithChallenges(challengeManager), + ) + // L1-only cache (no Redis) keeps the test network-free. + handler.feeds = &respCache{} + + fetch := func() []challenges.Challenge { + req := newRequestWithUUID(http.MethodGet, "/json/challenges", jsonTestUUID) + rec := httptest.NewRecorder() + handler.JSONChallengesHandler(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var out []challenges.Challenge + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + return out + } + + // First fetch misses and populates the cache. + require.Len(t, fetch(), 1) + + // Deactivate the challenge directly in the DB, bypassing the handler/cache. + require.NoError(t, challengeManager.DB.Model(&challenges.Challenge{}). + Where("uuid = ?", jsonTestUUID).Update("active", false).Error) + + // Cached read still returns the stale challenge, proving L1 served it. + cached := fetch() + require.Len(t, cached, 1, "cached feed should not reflect a bypassing DB write") + + // After invalidation the next fetch reflects the DB state. + handler.invalidateFeed("challenges", jsonTestUUID) + require.Empty(t, fetch(), "invalidated feed should reflect the DB state") +} diff --git a/backend/cmd/map/handlers/post.go b/backend/cmd/map/handlers/post.go index ee469e0..c0f8812 100644 --- a/backend/cmd/map/handlers/post.go +++ b/backend/cmd/map/handlers/post.go @@ -137,6 +137,7 @@ func (h *HandlersMap) ChatPOSTHandler(w http.ResponseWriter, r *http.Request) { HTTPResponse(w, JSONApplicationUTF8, http.StatusInternalServerError, MapErrorResponse{Error: h.T(r.Context())("chat.failed_create")}) return } + h.invalidateFeed("chat", uuid) HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, MapChatResponse{ Success: true, }) @@ -318,6 +319,8 @@ func (h *HandlersMap) ScorePOSTHandler(w http.ResponseWriter, r *http.Request) { return } + h.invalidateFeed("teams", uuid) + h.invalidateFeed("activity", uuid) HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, MapScoreResponse{ Success: true, Message: h.T(r.Context())("score.completed"), @@ -523,6 +526,8 @@ func (h *HandlersMap) HintPOSTHandler(w http.ResponseWriter, r *http.Request) { return } + h.invalidateFeed("teams", uuid) + h.invalidateFeed("activity", uuid) HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, MapHintResponse{ Success: true, Message: h.T(r.Context())("hint.unlocked"), diff --git a/backend/cmd/map/handlers/respcache.go b/backend/cmd/map/handlers/respcache.go new file mode 100644 index 0000000..379f5be --- /dev/null +++ b/backend/cmd/map/handlers/respcache.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" + + redis "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" +) + +const ( + // feedCacheL1TTL bounds how long an in-process copy of a feed response is + // reused, coalescing a burst of simultaneous polls (feeds poll every 15s). + feedCacheL1TTL = 4 * time.Second + // feedCacheL2TTL is the Redis TTL for a feed response. + feedCacheL2TTL = 4 * time.Second + feedCacheKeyPrefix = "mapctf:feed:" +) + +// cacheResult is the unit shared by singleflight: an already-marshaled JSON body +// and the HTTP status to write with it. Errors are returned as non-200 results so +// they are never cached, but are still delivered to every concurrent waiter. +type cacheResult struct { + status int + body []byte +} + +type respCache struct { + rdb *redis.Client + l1 sync.Map // key -> *respCacheEntry + sf singleflight.Group +} + +type respCacheEntry struct { + body []byte + fetchedAt time.Time +} + +func (e *respCacheEntry) fresh() bool { return time.Since(e.fetchedAt) < feedCacheL1TTL } + +// feedCache lazily initializes a response cache backed by the configured Redis +// client. It returns nil when Redis is unavailable and no cache has been +// injected, in which case feed handlers build and respond without caching. +func (h *HandlersMap) feedCache() *respCache { + if h.feeds != nil { + return h.feeds + } + if h.RedisCache == nil || h.RedisCache.Client == nil { + return nil + } + h.feeds = &respCache{rdb: h.RedisCache.Client} + return h.feeds +} + +func (c *respCache) l1Get(key string) ([]byte, bool) { + if c == nil { + return nil, false + } + v, ok := c.l1.Load(key) + if !ok { + return nil, false + } + e := v.(*respCacheEntry) + if !e.fresh() { + return nil, false + } + return e.body, true +} + +func (c *respCache) l1Put(key string, body []byte) { + if c == nil { + return + } + c.l1.Store(key, &respCacheEntry{body: body, fetchedAt: time.Now()}) +} + +func feedKey(name, uuid string) string { return feedCacheKeyPrefix + name + ":" + uuid } + +// invalidateFeed drops the cached response for a feed. Call it from mutation +// paths (score, capture, chat post, admin CRUD) for snappier updates; the short +// TTL bounds staleness even without it. +func (h *HandlersMap) invalidateFeed(name, uuid string) { + c := h.feedCache() + if c == nil { + return + } + key := feedKey(name, uuid) + c.l1.Delete(key) + if c.rdb != nil { + c.rdb.Del(context.Background(), key) + } +} + +// serveCachedJSON serves a JSON response with cache-aside. On a cache hit the +// build function is not invoked, so the (expensive) DB queries inside it are +// skipped. build must return the HTTP status and the already-marshaled JSON +// body; only status 200 responses are cached. Concurrent misses for the same key +// are coalesced via singleflight and each waiter writes the shared result to its +// own ResponseWriter. +func (h *HandlersMap) serveCachedJSON(w http.ResponseWriter, key string, build func() (int, []byte)) { + c := h.feedCache() + if c == nil { + status, body := build() + writeStatusJSON(w, status, body) + return + } + if body, ok := c.l1Get(key); ok { + writeStatusJSON(w, http.StatusOK, body) + return + } + v, _, _ := c.sf.Do(key, func() (any, error) { + if body, ok := c.l1Get(key); ok { + return cacheResult{http.StatusOK, body}, nil + } + if c.rdb != nil { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + raw, rerr := c.rdb.Get(ctx, key).Bytes() + cancel() + if rerr == nil { + c.l1Put(key, raw) + return cacheResult{http.StatusOK, raw}, nil + } + } + status, body := build() + if status == http.StatusOK && c.rdb != nil { + c.rdb.Set(context.Background(), key, body, feedCacheL2TTL) + } + if status == http.StatusOK { + c.l1Put(key, body) + } + return cacheResult{status, body}, nil + }) + cr := v.(cacheResult) + writeStatusJSON(w, cr.status, cr.body) +} + +func writeStatusJSON(w http.ResponseWriter, status int, body []byte) { + w.Header().Set(ContentType, JSONApplicationUTF8) + w.WriteHeader(status) + _, _ = w.Write(body) +} + +// marshalJSON is a thin helper for feed build closures. +func marshalJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{"error":"internal error"}`) + } + return b +}