Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

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 <p@pdewey.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

pdewey.com bc4ac2e9 535e0443

verified
+52 -22
+3 -2
internal/bff/render.go
··· 246 246 } 247 247 248 248 // RenderFeedPartial renders just the feed partial (for HTMX async loading) 249 - func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem) error { 249 + func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem, isAuthenticated bool) error { 250 250 t, err := parsePartialTemplate() 251 251 if err != nil { 252 252 return err 253 253 } 254 254 data := &PageData{ 255 - FeedItems: feedItems, 255 + FeedItems: feedItems, 256 + IsAuthenticated: isAuthenticated, 256 257 } 257 258 return t.ExecuteTemplate(w, "feed", data) 258 259 }
+27 -11
internal/feed/service.go
··· 40 40 41 41 // publicFeedCache holds cached feed items for unauthenticated users 42 42 type publicFeedCache struct { 43 - items []*FeedItem 44 - expiresAt time.Time 45 - mu sync.RWMutex 43 + items []*FeedItem 44 + expiresAt time.Time 45 + fromFirehose bool // tracks if cache was populated from firehose 46 + mu sync.RWMutex 46 47 } 47 48 48 49 // FirehoseIndex is the interface for the firehose feed index ··· 96 97 // It returns up to PublicFeedLimit items from the cache, refreshing if expired. 97 98 func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) { 98 99 s.cache.mu.RLock() 99 - if time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 { 100 - items := s.cache.items 101 - s.cache.mu.RUnlock() 102 - log.Debug().Int("item_count", len(items)).Msg("feed: returning cached public feed") 100 + cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 101 + cacheFromFirehose := s.cache.fromFirehose 102 + items := s.cache.items 103 + s.cache.mu.RUnlock() 104 + 105 + // Check if we need to refresh: cache expired, empty, or firehose is now ready but cache was from polling 106 + firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() 107 + needsRefresh := !cacheValid || (firehoseReady && !cacheFromFirehose) 108 + 109 + if !needsRefresh { 110 + log.Debug().Int("item_count", len(items)).Bool("from_firehose", cacheFromFirehose).Msg("feed: returning cached public feed") 103 111 return items, nil 104 112 } 105 - s.cache.mu.RUnlock() 106 113 107 - // Cache is expired or empty, refresh it 114 + // Cache is expired, empty, or we need to switch to firehose data 108 115 return s.refreshPublicFeedCache(ctx) 109 116 } 110 117 ··· 113 120 s.cache.mu.Lock() 114 121 defer s.cache.mu.Unlock() 115 122 123 + // Check if firehose is ready (for tracking cache source) 124 + firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() 125 + 116 126 // Double-check if another goroutine already refreshed the cache 127 + // But still refresh if firehose is ready and cache was from polling 117 128 if time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 { 118 - return s.cache.items, nil 129 + if !firehoseReady || s.cache.fromFirehose { 130 + return s.cache.items, nil 131 + } 132 + // Firehose is ready but cache was from polling, continue to refresh 119 133 } 120 134 121 - log.Debug().Msg("feed: refreshing public feed cache") 135 + log.Debug().Bool("firehose_ready", firehoseReady).Msg("feed: refreshing public feed cache") 122 136 123 137 // Fetch fresh feed items (limited to PublicFeedLimit) 124 138 items, err := s.GetRecentRecords(ctx, PublicFeedLimit) ··· 134 148 // Update cache 135 149 s.cache.items = items 136 150 s.cache.expiresAt = time.Now().Add(PublicFeedCacheTTL) 151 + s.cache.fromFirehose = firehoseReady 137 152 138 153 log.Debug(). 139 154 Int("item_count", len(items)). 140 155 Time("expires_at", s.cache.expiresAt). 156 + Bool("from_firehose", firehoseReady). 141 157 Msg("feed: updated public feed cache") 142 158 143 159 return items, nil
+5 -5
internal/handlers/handlers.go
··· 158 158 func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 159 159 var feedItems []*feed.FeedItem 160 160 161 - if h.feedService != nil { 162 - // Check if user is authenticated 163 - _, err := atproto.GetAuthenticatedDID(r.Context()) 164 - isAuthenticated := err == nil 161 + // Check if user is authenticated 162 + _, err := atproto.GetAuthenticatedDID(r.Context()) 163 + isAuthenticated := err == nil 165 164 165 + if h.feedService != nil { 166 166 if isAuthenticated { 167 167 // Authenticated users get the full feed (20 items), fetched fresh 168 168 feedItems, _ = h.feedService.GetRecentRecords(r.Context(), 20) ··· 172 172 } 173 173 } 174 174 175 - if err := bff.RenderFeedPartial(w, feedItems); err != nil { 175 + if err := bff.RenderFeedPartial(w, feedItems, isAuthenticated); err != nil { 176 176 http.Error(w, "Failed to render feed", http.StatusInternalServerError) 177 177 log.Error().Err(err).Msg("Failed to render feed partial") 178 178 }
+5
templates/partials/feed.tmpl
··· 194 194 {{end}} 195 195 </div> 196 196 {{end}} 197 + <!-- {{if not $.IsAuthenticated}} --> 198 + <!-- <div class="text-center text-brown-600 text-sm py-4"> --> 199 + <!-- Sign in to see more --> 200 + <!-- </div> --> 201 + <!-- {{end}} --> 197 202 {{else}} 198 203 <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 199 204 <p class="mb-2 font-medium">No activity in the feed yet.</p>
+12 -4
web/static/js/data-cache.js
··· 94 94 /** 95 95 * Refresh the cache from the API 96 96 * Returns the fresh data 97 + * @param {boolean} force - If true, always fetch fresh data even if a refresh is in progress 97 98 */ 98 - async function refreshCache() { 99 + async function refreshCache(force = false) { 99 100 if (isRefreshing) { 100 101 // Wait for existing refresh to complete 101 - return new Promise((resolve) => { 102 + await new Promise((resolve) => { 102 103 const checkInterval = setInterval(() => { 103 104 if (!isRefreshing) { 104 105 clearInterval(checkInterval); 105 - resolve(getCachedData()); 106 + resolve(); 106 107 } 107 108 }, 100); 108 109 }); 110 + 111 + // If not forcing, return the cached data from the completed refresh 112 + if (!force) { 113 + return getCachedData(); 114 + } 115 + // Otherwise, continue to do a new refresh with fresh data 109 116 } 110 117 111 118 isRefreshing = true; ··· 152 159 153 160 /** 154 161 * Invalidate and immediately refresh the cache 162 + * Forces a fresh fetch even if a background refresh is in progress 155 163 */ 156 164 async function invalidateAndRefresh() { 157 165 invalidateCache(); 158 - return await refreshCache(); 166 + return await refreshCache(true); 159 167 } 160 168 161 169 /**