Add in-memory result cache with 1-hour TTL

DB is now queried at most once per hour per user; subsequent requests
within the window are served from memory. TTL matches the existing
Cache-Control header. Restart clears the cache immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
didericis
2026-05-05 17:00:33 -04:00
parent 88bb9749a3
commit 81cd3bd919
2 changed files with 60 additions and 18 deletions
+4 -4
View File
@@ -64,10 +64,10 @@ Set `OP_TYPES=5,6,7,9,11,18,24` for a more inclusive count.
- **Template drift.** The harder part. Gitea's `profile.tmpl` does change
between versions — diff the new upstream against your override on each
upgrade and re-merge the snippet.
- **Cache.** 1-hour `Cache-Control` header keeps load trivial. Drop to 5
minutes for snappier updates if needed; the underlying query is cheap
thanks to the existing index on `(user_id, act_user_id, created_unix)`
added in Gitea 1.24.
- **Cache.** Results are cached in memory for 1 hour (matching the
`Cache-Control: max-age=3600` response header), so the DB is only hit
once per hour per user regardless of traffic. Restart the service to
clear the cache immediately.
## Local dev
+56 -14
View File
@@ -21,6 +21,7 @@ import (
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -83,12 +84,43 @@ func getEnv(key, fallback string) string {
return fallback
}
// ---- cache ----------------------------------------------------------------
const cacheTTL = time.Hour
type cacheEntry struct {
data []dayCount
expires time.Time
}
type resultCache struct {
mu sync.Mutex
entries map[string]cacheEntry
}
func (c *resultCache) get(username string) ([]dayCount, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[username]
if !ok || time.Now().After(e.expires) {
return nil, false
}
return e.data, true
}
func (c *resultCache) set(username string, data []dayCount) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[username] = cacheEntry{data: data, expires: time.Now().Add(cacheTTL)}
}
// ---- handler --------------------------------------------------------------
type heatmapHandler struct {
db *sql.DB
cfg *config
log *slog.Logger
db *sql.DB
cfg *config
log *slog.Logger
cache *resultCache
}
type dayCount struct {
@@ -118,17 +150,22 @@ func (h *heatmapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
counts, ok := h.cache.get(username)
if !ok {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
counts, err := h.queryHeatmap(ctx, username)
if err != nil {
h.log.Error("heatmap query failed", "user", username, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if counts == nil {
counts = []dayCount{}
var err error
counts, err = h.queryHeatmap(ctx, username)
if err != nil {
h.log.Error("heatmap query failed", "user", username, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if counts == nil {
counts = []dayCount{}
}
h.cache.set(username, counts)
}
w.Header().Set("Content-Type", "application/json")
@@ -216,7 +253,12 @@ func main() {
os.Exit(1)
}
h := &heatmapHandler{db: db, cfg: cfg, log: logger}
h := &heatmapHandler{
db: db,
cfg: cfg,
log: logger,
cache: &resultCache{entries: make(map[string]cacheEntry)},
}
mux := http.NewServeMux()
mux.Handle("/heatmap/", h)