fix: episode refresh lag for airing shows

This commit is contained in:
2026-05-26 13:17:54 +02:00
parent 698fcc9b5b
commit f5fd50d472

View File

@@ -19,6 +19,7 @@ import (
const ( const (
retryInterval = 15 * time.Minute retryInterval = 15 * time.Minute
retryWindow = 3 * time.Hour retryWindow = 3 * time.Hour
airingFallbackRefreshInterval = 6 * time.Hour
) )
type Clock interface { type Clock interface {
@@ -59,7 +60,7 @@ func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.
} }
if !forceRefresh { if !forceRefresh {
if cached, ok := s.getFreshCached(ctx, anime.MalID); ok { if cached, ok := s.getFreshCached(ctx, anime); ok {
return cached, nil return cached, nil
} }
} }
@@ -189,10 +190,23 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
} }
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) { func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
nextRefresh := nextBroadcastAfter(anime, now)
var nextRefreshSQL sql.NullTime var nextRefreshSQL sql.NullTime
if anime.Airing && !nextRefresh.IsZero() { if anime.Airing {
nextRefreshSQL = sql.NullTime{Time: nextRefresh, Valid: true} // During the hours immediately following a broadcast time, providers can lag.
// Keep retrying for a short window, even if the provider request succeeded.
lastBroadcast := nextBroadcastBeforeOrAt(anime, now)
if !lastBroadcast.IsZero() && now.Before(lastBroadcast.Add(retryWindow)) {
nextRefreshSQL = sql.NullTime{Time: now.Add(retryInterval).UTC(), Valid: true}
} else {
next := nextBroadcastAfter(anime, now)
if !next.IsZero() {
nextRefreshSQL = sql.NullTime{Time: next, Valid: true}
} else {
// Broadcast metadata is often missing or wrong for currently airing shows.
// Avoid "never refresh again" caches by falling back to a fixed interval.
nextRefreshSQL = sql.NullTime{Time: now.Add(airingFallbackRefreshInterval).UTC(), Valid: true}
}
}
} }
episodes := mergeEpisodes(jikanEpisodes, availability) episodes := mergeEpisodes(jikanEpisodes, availability)
@@ -278,26 +292,34 @@ func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.Can
return payload, true return payload, true
} }
func (s *EpisodeService) getFreshCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil { if err != nil {
s.metrics.ObserveCache("episode_availability_fresh", "miss") s.metrics.ObserveCache("episode_availability_fresh", "miss")
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(s.clock.Now()) {
now := s.clock.Now()
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
s.metrics.ObserveCache("episode_availability_fresh", "miss") s.metrics.ObserveCache("episode_availability_fresh", "miss")
log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", animeID, row.NextRefreshAt.Time.Format(time.RFC3339)) log.Printf("episodes: cached availability due for refresh anime_id=%d next_refresh=%s", anime.MalID, row.NextRefreshAt.Time.Format(time.RFC3339))
return domain.CanonicalEpisodeList{}, false
}
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
s.metrics.ObserveCache("episode_availability_fresh", "miss")
log.Printf("episodes: cached availability too old for airing anime_id=%d updated_at=%s", anime.MalID, row.UpdatedAt.Format(time.RFC3339))
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
var payload domain.CanonicalEpisodeList var payload domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil { if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
s.metrics.ObserveCache("episode_availability_fresh", "miss") s.metrics.ObserveCache("episode_availability_fresh", "miss")
log.Printf("episodes: invalid cached payload anime_id=%d error=%v", animeID, err) log.Printf("episodes: invalid cached payload anime_id=%d error=%v", anime.MalID, err)
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
s.metrics.ObserveCache("episode_availability_fresh", "hit") s.metrics.ObserveCache("episode_availability_fresh", "hit")
log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", animeID, len(payload.Episodes), payload.NextRefreshAt) log.Printf("episodes: served cached availability anime_id=%d episodes=%d next_refresh=%s", anime.MalID, len(payload.Episodes), payload.NextRefreshAt)
return payload, true return payload, true
} }