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