From bc4ac2e95d475b60bcabb2dc11f2b47b380370fa Mon Sep 17 00:00:00 2001 From: pdewey Date: Wed, 21 Jan 2026 20:01:07 -0500 Subject: [PATCH] fix: resolve feed caching issues - Fix non-authed feed to refresh when firehose becomes ready instead of serving stale polling data until TTL expires - Fix bean dropdown not updating after adding a bean via the brew form by forcing a fresh fetch even when a background refresh is in progress Co-Authored-By: Patrick Dewey Co-Authored-By: Claude Opus 4.5 --- internal/bff/render.go | 5 +++-- internal/feed/service.go | 38 +++++++++++++++++++++++++---------- internal/handlers/handlers.go | 10 ++++----- templates/partials/feed.tmpl | 5 +++++ web/static/js/data-cache.js | 16 +++++++++++---- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/internal/bff/render.go b/internal/bff/render.go index b8b26dc..fd20211 100644 --- a/internal/bff/render.go +++ b/internal/bff/render.go @@ -246,13 +246,14 @@ func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*model } // RenderFeedPartial renders just the feed partial (for HTMX async loading) -func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem) error { +func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem, isAuthenticated bool) error { t, err := parsePartialTemplate() if err != nil { return err } data := &PageData{ - FeedItems: feedItems, + FeedItems: feedItems, + IsAuthenticated: isAuthenticated, } return t.ExecuteTemplate(w, "feed", data) } diff --git a/internal/feed/service.go b/internal/feed/service.go index 64f5a99..451eba5 100644 --- a/internal/feed/service.go +++ b/internal/feed/service.go @@ -40,9 +40,10 @@ type FeedItem struct { // publicFeedCache holds cached feed items for unauthenticated users type publicFeedCache struct { - items []*FeedItem - expiresAt time.Time - mu sync.RWMutex + items []*FeedItem + expiresAt time.Time + fromFirehose bool // tracks if cache was populated from firehose + mu sync.RWMutex } // FirehoseIndex is the interface for the firehose feed index @@ -96,15 +97,21 @@ func (s *Service) SetFirehoseIndex(index FirehoseIndex) { // It returns up to PublicFeedLimit items from the cache, refreshing if expired. func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) { s.cache.mu.RLock() - if time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 { - items := s.cache.items - s.cache.mu.RUnlock() - log.Debug().Int("item_count", len(items)).Msg("feed: returning cached public feed") + cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 + cacheFromFirehose := s.cache.fromFirehose + items := s.cache.items + s.cache.mu.RUnlock() + + // Check if we need to refresh: cache expired, empty, or firehose is now ready but cache was from polling + firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() + needsRefresh := !cacheValid || (firehoseReady && !cacheFromFirehose) + + if !needsRefresh { + log.Debug().Int("item_count", len(items)).Bool("from_firehose", cacheFromFirehose).Msg("feed: returning cached public feed") return items, nil } - s.cache.mu.RUnlock() - // Cache is expired or empty, refresh it + // Cache is expired, empty, or we need to switch to firehose data return s.refreshPublicFeedCache(ctx) } @@ -113,12 +120,19 @@ func (s *Service) refreshPublicFeedCache(ctx context.Context) ([]*FeedItem, erro s.cache.mu.Lock() defer s.cache.mu.Unlock() + // Check if firehose is ready (for tracking cache source) + firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() + // Double-check if another goroutine already refreshed the cache + // But still refresh if firehose is ready and cache was from polling if time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 { - return s.cache.items, nil + if !firehoseReady || s.cache.fromFirehose { + return s.cache.items, nil + } + // Firehose is ready but cache was from polling, continue to refresh } - log.Debug().Msg("feed: refreshing public feed cache") + log.Debug().Bool("firehose_ready", firehoseReady).Msg("feed: refreshing public feed cache") // Fetch fresh feed items (limited to PublicFeedLimit) items, err := s.GetRecentRecords(ctx, PublicFeedLimit) @@ -134,10 +148,12 @@ func (s *Service) refreshPublicFeedCache(ctx context.Context) ([]*FeedItem, erro // Update cache s.cache.items = items s.cache.expiresAt = time.Now().Add(PublicFeedCacheTTL) + s.cache.fromFirehose = firehoseReady log.Debug(). Int("item_count", len(items)). Time("expires_at", s.cache.expiresAt). + Bool("from_firehose", firehoseReady). Msg("feed: updated public feed cache") return items, nil diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c3e00d7..07ba963 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -158,11 +158,11 @@ func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { var feedItems []*feed.FeedItem - if h.feedService != nil { - // Check if user is authenticated - _, err := atproto.GetAuthenticatedDID(r.Context()) - isAuthenticated := err == nil + // Check if user is authenticated + _, err := atproto.GetAuthenticatedDID(r.Context()) + isAuthenticated := err == nil + if h.feedService != nil { if isAuthenticated { // Authenticated users get the full feed (20 items), fetched fresh feedItems, _ = h.feedService.GetRecentRecords(r.Context(), 20) @@ -172,7 +172,7 @@ func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { } } - if err := bff.RenderFeedPartial(w, feedItems); err != nil { + if err := bff.RenderFeedPartial(w, feedItems, isAuthenticated); err != nil { http.Error(w, "Failed to render feed", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render feed partial") } diff --git a/templates/partials/feed.tmpl b/templates/partials/feed.tmpl index 456df6d..3199233 100644 --- a/templates/partials/feed.tmpl +++ b/templates/partials/feed.tmpl @@ -194,6 +194,11 @@ {{end}} {{end}} + + + + + {{else}}

No activity in the feed yet.

diff --git a/web/static/js/data-cache.js b/web/static/js/data-cache.js index b77f998..eba4a09 100644 --- a/web/static/js/data-cache.js +++ b/web/static/js/data-cache.js @@ -94,18 +94,25 @@ async function fetchFreshData() { /** * Refresh the cache from the API * Returns the fresh data + * @param {boolean} force - If true, always fetch fresh data even if a refresh is in progress */ -async function refreshCache() { +async function refreshCache(force = false) { if (isRefreshing) { // Wait for existing refresh to complete - return new Promise((resolve) => { + await new Promise((resolve) => { const checkInterval = setInterval(() => { if (!isRefreshing) { clearInterval(checkInterval); - resolve(getCachedData()); + resolve(); } }, 100); }); + + // If not forcing, return the cached data from the completed refresh + if (!force) { + return getCachedData(); + } + // Otherwise, continue to do a new refresh with fresh data } isRefreshing = true; @@ -152,10 +159,11 @@ function invalidateCache() { /** * Invalidate and immediately refresh the cache + * Forces a fresh fetch even if a background refresh is in progress */ async function invalidateAndRefresh() { invalidateCache(); - return await refreshCache(); + return await refreshCache(true); } /** -- 2.51.2