diff --git a/README.md b/README.md index 1d23309..7499469 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.go b/main.go index cd6a39a..96139fc 100644 --- a/main.go +++ b/main.go @@ -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)