Merge branch 'mai/knuth/phase-5b-cache' (phase 5b slice B: dashboardCache → cache.TTLCache)
This commit is contained in:
@@ -390,7 +390,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
// Writeback may move a task on or off the timeline, so bust both caches.
|
||||
if s.dashboard != nil {
|
||||
s.dashboard.invalidateAll()
|
||||
s.dashboard.InvalidateAll()
|
||||
}
|
||||
if s.timeline != nil {
|
||||
s.timeline.invalidateAll()
|
||||
|
||||
@@ -15,70 +15,11 @@ import (
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// dashboardCache holds the aggregated dashboard payload for up to TTL. Per
|
||||
// design.md §9 every cache entry is keyed by the encoded TreeFilter (so
|
||||
// `?tag=work` cache is independent of unfiltered), and the TTL is 60s.
|
||||
type dashboardCache struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
rows map[string]cachedDashboard
|
||||
}
|
||||
|
||||
type cachedDashboard struct {
|
||||
at time.Time
|
||||
payload *dashboardPayload
|
||||
}
|
||||
|
||||
func newDashboardCache(ttl time.Duration) *dashboardCache {
|
||||
return &dashboardCache{ttl: ttl, rows: map[string]cachedDashboard{}}
|
||||
}
|
||||
|
||||
func (c *dashboardCache) get(key string) (*dashboardPayload, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.rows[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(v.at) > c.ttl {
|
||||
delete(c.rows, key)
|
||||
return nil, false
|
||||
}
|
||||
return v.payload, true
|
||||
}
|
||||
|
||||
func (c *dashboardCache) invalidate(key string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.rows, key)
|
||||
}
|
||||
|
||||
// invalidateAll wipes every cached payload. Used by writeback paths (Gitea
|
||||
// close/comment/create, CalDAV completion) that can change content under any
|
||||
// filter.
|
||||
func (c *dashboardCache) invalidateAll() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.rows = map[string]cachedDashboard{}
|
||||
}
|
||||
|
||||
func (c *dashboardCache) set(key string, p *dashboardPayload) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.rows[key] = cachedDashboard{at: time.Now(), payload: p}
|
||||
}
|
||||
// dashboardCache TTL — Phase 5b unified the per-cache types into the
|
||||
// generic internal/cache.TTLCache[V]. Design note (design.md §9): every
|
||||
// cache entry is keyed by the encoded TreeFilter (so `?tag=work` cache is
|
||||
// independent of unfiltered).
|
||||
const dashboardCacheTTL = 60 * time.Second
|
||||
|
||||
// dashboardPayload is the full aggregated view rendered into the page. Each
|
||||
// slice is already sorted and capped at the per-card limit.
|
||||
@@ -181,17 +122,17 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// ?refresh=1 busts this filter's cache entry so the next aggregation
|
||||
// runs fresh — used by the ↻ button on the dashboard chrome.
|
||||
if r.URL.Query().Get("refresh") == "1" {
|
||||
s.dashboard.invalidate(cacheKey)
|
||||
s.dashboard.Invalidate(cacheKey)
|
||||
}
|
||||
|
||||
payload, hit := s.dashboard.get(cacheKey)
|
||||
payload, hit := s.dashboard.Get(cacheKey)
|
||||
if !hit {
|
||||
built, err := s.buildDashboard(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.dashboard.set(cacheKey, built)
|
||||
s.dashboard.Set(cacheKey, built)
|
||||
payload = built
|
||||
}
|
||||
displayPayload := *payload
|
||||
@@ -653,7 +594,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
|
||||
}
|
||||
if current == nil {
|
||||
// Task already gone — drop cache + re-render so the row vanishes.
|
||||
s.dashboard.invalidateAll()
|
||||
s.dashboard.InvalidateAll()
|
||||
s.handleDashboard(w, r)
|
||||
return
|
||||
}
|
||||
@@ -692,7 +633,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
|
||||
http.Error(w, "unknown action: "+action, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.dashboard.invalidateAll()
|
||||
s.dashboard.InvalidateAll()
|
||||
s.timeline.invalidateAll()
|
||||
// Re-render whichever surface the request came from. HTMX sets HX-Target
|
||||
// to the swap target's id; the timeline surface uses #timeline-section.
|
||||
|
||||
@@ -100,7 +100,7 @@ func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path,
|
||||
s.Gitea.Cache.Invalidate(repoRef + "|open")
|
||||
s.Gitea.Cache.Invalidate(repoRef + "|closed-recent")
|
||||
if s.dashboard != nil {
|
||||
s.dashboard.invalidateAll()
|
||||
s.dashboard.InvalidateAll()
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path
|
||||
}
|
||||
// Bust the dashboard + timeline caches: a removed dated link should
|
||||
// disappear from both surfaces on next render.
|
||||
s.dashboard.invalidateAll()
|
||||
s.dashboard.InvalidateAll()
|
||||
s.timeline.invalidateAll()
|
||||
// When the delete came from the timeline (HX-Target = timeline-section),
|
||||
// re-render the timeline so the row vanishes in place instead of trying to
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/internal/aggregate"
|
||||
"github.com/m/projax/internal/cache"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ type Server struct {
|
||||
Gitea *GiteaDeps // nil → Gitea integration disabled
|
||||
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
||||
Version string // build-time -ldflags injection; surfaced on /admin
|
||||
dashboard *dashboardCache
|
||||
dashboard *cache.TTLCache[*dashboardPayload]
|
||||
timeline *timelineCache
|
||||
adminHealth *adminHealthCache
|
||||
}
|
||||
@@ -265,7 +266,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
Store: s,
|
||||
pages: pages,
|
||||
Logger: logger,
|
||||
dashboard: newDashboardCache(60 * time.Second),
|
||||
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
|
||||
timeline: newTimelineCache(timelineCacheTTL),
|
||||
adminHealth: newAdminHealthCache(),
|
||||
}, nil
|
||||
|
||||
Reference in New Issue
Block a user